fix(actors): distinguish namespace/name from provider/model in actor name parsing #11255
@@ -16,6 +16,18 @@ Changed `wf10_batch.robot` to be less likely to create files, and
|
||||
validation to `_execute_child_plan` to prevent re-entrant or orphaned child plan
|
||||
execution.
|
||||
|
||||
- **Actor namespace/name disambiguation** (#11254): When action YAML references
|
||||
actors using `namespace/name` format (e.g. `strategy_actor: local/my-strategist`),
|
||||
the `_parse_actor_name()` functions no longer mistake the namespace prefix for an
|
||||
LLM provider name. A new `_is_known_provider()` utility checks whether the first
|
||||
slash-separated segment matches a known `ProviderType` (e.g. `openai`, `anthropic`);
|
||||
if not, the input is treated as namespace/name. `PlanLifecycleService` now provides
|
||||
`resolve_actor_provider_model()` to resolve namespaced references to their underlying
|
||||
`provider/model` via the actor registry. All affected call sites — `StrategyActor`,
|
||||
`LLMStrategizeActor`, `LLMExecuteActor`, and `SessionWorkflow._resolve_llm()` —
|
||||
pre-resolve actor names before passing them to the LLM provider, fixing the
|
||||
`ValueError: Unknown provider type` crash when using namespaced actor references.
|
||||
|
||||
Data integrity fix: ValidationAttachmentRepository argument swap (#7492): Fixed
|
||||
a critical data integrity issue in `ValidationAttachmentRepository.attach` where
|
||||
`validation_name` and `resource_id` arguments were being silently swapped based on a
|
||||
|
||||
@@ -59,6 +59,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Actor namespace/name disambiguation** (#11254): When action YAML references
|
||||
actors using `namespace/name` format (e.g. `strategy_actor: local/my-strategist`),
|
||||
the `_parse_actor_name()` functions no longer mistake the namespace prefix for an
|
||||
LLM provider name. A new `_is_known_provider()` utility checks whether the first
|
||||
slash-separated segment matches a known `ProviderType` (e.g. `openai`, `anthropic`);
|
||||
if not, the input is treated as namespace/name. `PlanLifecycleService` now provides
|
||||
`resolve_actor_provider_model()` to resolve namespaced references to their underlying
|
||||
`provider/model` via the actor registry. All affected call sites — `StrategyActor`,
|
||||
`LLMStrategizeActor`, `LLMExecuteActor`, and `SessionWorkflow._resolve_llm()` —
|
||||
pre-resolve actor names before passing them to the LLM provider, fixing the
|
||||
`ValueError: Unknown provider type` crash when using namespaced actor references.
|
||||
|
||||
- **Built-in actors v3 YAML format** (#10883): Fixed `agents actor run` failing for
|
||||
built-in actors (e.g., `openai/gpt-4`, `anthropic/claude-3-opus`) due to missing
|
||||
v3 `type` field in stored configuration. `ActorRegistry.ensure_built_in_actors()`
|
||||
|
||||
@@ -26,6 +26,24 @@ Feature: LLM Actors Coverage
|
||||
Then the parsed provider should be "anthropic"
|
||||
And the parsed model should be "claude-3"
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: Parse actor name with namespace/name format
|
||||
When I parse actor name "local/my-strategist"
|
||||
Then the parsed provider should be "openai"
|
||||
And the parsed model should be "local/my-strategist"
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: Parse actor name with org namespace/name format
|
||||
When I parse actor name "cleverthis/my-executor"
|
||||
Then the parsed provider should be "openai"
|
||||
And the parsed model should be "cleverthis/my-executor"
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: Parse actor name with unknown-provider-like segment
|
||||
When I parse actor name "unknown/gpt-4"
|
||||
Then the parsed provider should be "openai"
|
||||
And the parsed model should be "unknown/gpt-4"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# LLMStrategizeActor.__init__ validation
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -309,13 +309,18 @@ def make_mock_registry(response_content: str) -> SimpleNamespace:
|
||||
|
||||
def make_mock_lifecycle(
|
||||
strategy_actor: str | None = "openai/gpt-4",
|
||||
execution_actor: str | None = None,
|
||||
) -> SimpleNamespace:
|
||||
"""Create a mock lifecycle service."""
|
||||
plan = SimpleNamespace(action_name="test-action")
|
||||
action = SimpleNamespace(strategy_actor=strategy_actor)
|
||||
action = SimpleNamespace(
|
||||
strategy_actor=strategy_actor,
|
||||
execution_actor=execution_actor or strategy_actor,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
get_plan=MagicMock(return_value=plan),
|
||||
get_action=MagicMock(return_value=action),
|
||||
resolve_actor_provider_model=MagicMock(return_value=None),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -160,4 +160,45 @@ Scenario: _handle_correction_applied skips event with no plan_id
|
||||
Given the plan lifecycle service is configured for r4
|
||||
And get_plan will fail for the plan for r4
|
||||
When I handle a CORRECTION_APPLIED event when get_plan fails for r4
|
||||
Then no exception should be raised for r4
|
||||
Then no exception should be raised for r4
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# resolve_actor_provider_model — namespace/name → provider/model
|
||||
# Lines 731-764 (factor resolution for #11254)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: resolve_actor_provider_model returns provider/model for known provider
|
||||
Given the plan lifecycle service has a unit of work for actor resolution r4
|
||||
When I resolve actor provider model for "openai/gpt-4"
|
||||
Then the resolved actor provider model should be "openai/gpt-4"
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: resolve_actor_provider_model resolves namespace/name via actor lookup
|
||||
Given the plan lifecycle service has a unit of work with a known actor "local/strategist" using "openai/gpt-4" for r4
|
||||
When I resolve actor provider model for "local/strategist"
|
||||
Then the resolved actor provider model should be "openai/gpt-4"
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: resolve_actor_provider_model returns None when actor is not found
|
||||
Given the plan lifecycle service has a unit of work without actor "local/missing-actor" for r4
|
||||
When I resolve actor provider model for "local/missing-actor"
|
||||
Then the resolved actor provider model should be None
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: resolve_actor_provider_model catches exception from actor lookup
|
||||
Given the plan lifecycle service has a unit of work that raises on actor lookup for r4
|
||||
When I resolve actor provider model for "local/crash-actor"
|
||||
Then the resolved actor provider model should be None
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: resolve_actor_provider_model returns None when unit_of_work is absent
|
||||
Given the plan lifecycle service has no unit of work for r4
|
||||
When I resolve actor provider model for "local/strategist"
|
||||
Then the resolved actor provider model should be None
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: resolve_actor_provider_model returns None for empty actor name
|
||||
Given the plan lifecycle service has a unit of work for actor resolution r4
|
||||
When I resolve actor provider model for ""
|
||||
Then the resolved actor provider model should be None
|
||||
@@ -92,6 +92,7 @@ def _make_mock_lifecycle(strategy_actor=None, execution_actor=None):
|
||||
lifecycle = SimpleNamespace(
|
||||
get_plan=MagicMock(return_value=plan),
|
||||
get_action=MagicMock(return_value=action),
|
||||
resolve_actor_provider_model=MagicMock(return_value=None),
|
||||
)
|
||||
return lifecycle
|
||||
|
||||
|
||||
@@ -342,7 +342,10 @@ def step_validate_sql_constructor(context: Any) -> None:
|
||||
assert context.sql_history_calls
|
||||
_, kwargs = context.sql_history_calls[0]
|
||||
assert kwargs["session_id"] == "sql-session"
|
||||
assert kwargs["connection_string"] == context.connection_string
|
||||
actual_connection = kwargs.get("connection") or kwargs.get("connection_string")
|
||||
assert actual_connection == context.connection_string, (
|
||||
f"Expected {context.connection_string}, got {actual_connection}"
|
||||
)
|
||||
assert kwargs.get("table_name") == "message_history"
|
||||
|
||||
|
||||
|
||||
@@ -884,3 +884,147 @@ def step_handle_correction_get_plan_fails_r4(context: Context) -> None:
|
||||
context.service._handle_correction_applied(event)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
# =================================================================
|
||||
# resolve_actor_provider_model — namespace/name → provider/model
|
||||
# Lines 731-764
|
||||
# =================================================================
|
||||
|
||||
|
||||
@given("the plan lifecycle service has a unit of work for actor resolution r4")
|
||||
def step_service_with_uow_for_actor_r4(context: Context) -> None:
|
||||
"""Create a service with a basic unit of work for actor resolution."""
|
||||
from unittest.mock import MagicMock
|
||||
from contextlib import contextmanager
|
||||
|
||||
Settings._instance = None
|
||||
settings = Settings()
|
||||
mock_uow = MagicMock()
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.actors.get_by_name.return_value = None
|
||||
|
||||
@contextmanager
|
||||
def _fake_transaction():
|
||||
yield mock_ctx
|
||||
|
||||
mock_uow.transaction = _fake_transaction
|
||||
context.service = PlanLifecycleService(settings=settings, unit_of_work=mock_uow)
|
||||
context.error = None
|
||||
|
||||
|
||||
@given(
|
||||
"the plan lifecycle service has a unit of work with a known actor "
|
||||
'"{actor_name}" using "{provider_model}" for r4'
|
||||
)
|
||||
def step_service_with_known_actor_r4(
|
||||
context: Context, actor_name: str, provider_model: str
|
||||
) -> None:
|
||||
"""Create a service where actor lookup succeeds for the given name."""
|
||||
from unittest.mock import MagicMock
|
||||
from contextlib import contextmanager
|
||||
|
||||
provider, model = provider_model.split("/", 1)
|
||||
mock_actor = MagicMock()
|
||||
mock_actor.provider = provider
|
||||
mock_actor.model = model
|
||||
|
||||
Settings._instance = None
|
||||
settings = Settings()
|
||||
mock_uow = MagicMock()
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.actors.get_by_name.return_value = mock_actor
|
||||
|
||||
@contextmanager
|
||||
def _fake_transaction():
|
||||
yield mock_ctx
|
||||
|
||||
mock_uow.transaction = _fake_transaction
|
||||
context.service = PlanLifecycleService(settings=settings, unit_of_work=mock_uow)
|
||||
context.error = None
|
||||
|
||||
|
||||
@given(
|
||||
'the plan lifecycle service has a unit of work without actor "{actor_name}" for r4'
|
||||
)
|
||||
def step_service_without_actor_r4(context: Context, actor_name: str) -> None:
|
||||
"""Create a service where actor lookup returns None."""
|
||||
from unittest.mock import MagicMock
|
||||
from contextlib import contextmanager
|
||||
|
||||
Settings._instance = None
|
||||
settings = Settings()
|
||||
mock_uow = MagicMock()
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.actors.get_by_name.return_value = None
|
||||
|
||||
@contextmanager
|
||||
def _fake_transaction():
|
||||
yield mock_ctx
|
||||
|
||||
mock_uow.transaction = _fake_transaction
|
||||
context.service = PlanLifecycleService(settings=settings, unit_of_work=mock_uow)
|
||||
context.error = None
|
||||
|
||||
|
||||
@given(
|
||||
"the plan lifecycle service has a unit of work that raises on actor lookup for r4"
|
||||
)
|
||||
def step_service_with_raising_actor_lookup_r4(context: Context) -> None:
|
||||
"""Create a service where actor lookup raises an exception."""
|
||||
from unittest.mock import MagicMock
|
||||
from contextlib import contextmanager
|
||||
|
||||
Settings._instance = None
|
||||
settings = Settings()
|
||||
mock_uow = MagicMock()
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.actors.get_by_name.side_effect = RuntimeError("DB failure")
|
||||
|
||||
@contextmanager
|
||||
def _fake_transaction():
|
||||
yield mock_ctx
|
||||
|
||||
mock_uow.transaction = _fake_transaction
|
||||
context.service = PlanLifecycleService(settings=settings, unit_of_work=mock_uow)
|
||||
context.error = None
|
||||
|
||||
|
||||
@given("the plan lifecycle service has no unit of work for r4")
|
||||
def step_service_no_uow_r4(context: Context) -> None:
|
||||
"""Create a service with no unit_of_work set."""
|
||||
Settings._instance = None
|
||||
settings = Settings()
|
||||
context.service = PlanLifecycleService(
|
||||
settings=settings,
|
||||
unit_of_work=None,
|
||||
)
|
||||
context.error = None
|
||||
|
||||
|
||||
@when('I resolve actor provider model for "{actor_name}"')
|
||||
def step_resolve_actor_provider_model(context: Context, actor_name: str) -> None:
|
||||
"""Call resolve_actor_provider_model directly."""
|
||||
context.resolved_model = context.service.resolve_actor_provider_model(actor_name)
|
||||
|
||||
|
||||
@when('I resolve actor provider model for ""')
|
||||
def step_resolve_actor_provider_model_empty(context: Context) -> None:
|
||||
"""Call resolve_actor_provider_model with empty string."""
|
||||
context.resolved_model = context.service.resolve_actor_provider_model("")
|
||||
|
||||
|
||||
@then('the resolved actor provider model should be "{expected}"')
|
||||
def step_verify_resolved_provider_model(context: Context, expected: str) -> None:
|
||||
"""Verify the resolved provider/model string matches."""
|
||||
assert context.resolved_model == expected, (
|
||||
f"Expected '{expected}', got '{context.resolved_model}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the resolved actor provider model should be None")
|
||||
def step_verify_resolved_provider_model_none(context: Context) -> None:
|
||||
"""Verify the resolved provider/model is None."""
|
||||
assert context.resolved_model is None, (
|
||||
f"Expected None, got '{context.resolved_model}'"
|
||||
)
|
||||
|
||||
@@ -2713,10 +2713,17 @@ def step_check_no_applied_in_pending(context: Context) -> None:
|
||||
def _ensure_memory_database_url(context: Context) -> None:
|
||||
"""Ensure plan service uses an in-memory database for memory tests."""
|
||||
try:
|
||||
if context.plan_service.settings.database_url != "sqlite:///:memory:":
|
||||
context.plan_service.settings.database_url = "sqlite:///:memory:"
|
||||
if (
|
||||
context.plan_service.settings.database_url
|
||||
!= "sqlite:///file::memory:?cache=shared"
|
||||
):
|
||||
context.plan_service.settings.database_url = (
|
||||
"sqlite:///file::memory:?cache=shared"
|
||||
)
|
||||
except AttributeError:
|
||||
context.plan_service.settings = Settings(database_url="sqlite:///:memory:")
|
||||
context.plan_service.settings = Settings(
|
||||
database_url="sqlite:///file::memory:?cache=shared"
|
||||
)
|
||||
|
||||
|
||||
def _request_memory_service_for_session(
|
||||
|
||||
@@ -41,6 +41,7 @@ from cleveragents.application.services.strategy_actor import (
|
||||
resolve_strategy_actor,
|
||||
validate_no_cycles,
|
||||
)
|
||||
from cleveragents.application.services.strategy_resolution import _is_known_provider
|
||||
from cleveragents.core.exceptions import PlanError, ValidationError
|
||||
from cleveragents.domain.models.core.decision import DecisionType
|
||||
from cleveragents.domain.models.core.plan import InvariantSource, PlanInvariant
|
||||
@@ -583,6 +584,30 @@ def step_verify_sa_parsed_model(context, expected):
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_known_provider helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I check if "{provider}" is a known strategy provider')
|
||||
def step_check_known_strategy_provider(context, provider):
|
||||
context.sa_is_known = _is_known_provider(provider)
|
||||
|
||||
|
||||
@then("the strategy provider check should be true")
|
||||
def step_verify_provider_check_true(context):
|
||||
assert context.sa_is_known is True, (
|
||||
f"Expected provider to be known, got {context.sa_is_known}"
|
||||
)
|
||||
|
||||
|
||||
@then("the strategy provider check should be false")
|
||||
def step_verify_provider_check_false(context):
|
||||
assert context.sa_is_known is False, (
|
||||
f"Expected provider to NOT be known, got {context.sa_is_known}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cyclic dependency detection through LLM execute path (H8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -218,6 +218,34 @@ Feature: LLM-powered Strategy Actor
|
||||
Then the strategy parsed provider should be "openai"
|
||||
And the strategy parsed model should be "gpt-4"
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: Parse strategy actor name with namespace/name format
|
||||
When I parse strategy actor name "local/my-strategist"
|
||||
Then the strategy parsed provider should be "openai"
|
||||
And the strategy parsed model should be "local/my-strategist"
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: Parse strategy actor name with org namespace/name format
|
||||
When I parse strategy actor name "cleverthis/my-executor"
|
||||
Then the strategy parsed provider should be "openai"
|
||||
And the strategy parsed model should be "cleverthis/my-executor"
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: Parse strategy actor name with unknown-provider-like segment
|
||||
When I parse strategy actor name "unknown/gpt-4"
|
||||
Then the strategy parsed provider should be "openai"
|
||||
And the strategy parsed model should be "unknown/gpt-4"
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: _is_known_provider recognises valid providers
|
||||
When I check if "openai" is a known strategy provider
|
||||
Then the strategy provider check should be true
|
||||
|
||||
@tdd_issue @tdd_issue_11254
|
||||
Scenario: _is_known_provider rejects namespace prefix
|
||||
When I check if "local" is a known strategy provider
|
||||
Then the strategy provider check should be false
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Cyclic dependency detection through LLM execute path (H8)
|
||||
# ---------------------------------------------------------------
|
||||
@@ -326,8 +354,8 @@ Feature: LLM-powered Strategy Actor
|
||||
|
||||
Scenario: Parse strategy actor name with multiple slashes
|
||||
When I parse strategy actor name "provider/model/version"
|
||||
Then the strategy parsed provider should be "provider"
|
||||
And the strategy parsed model should be "model/version"
|
||||
Then the strategy parsed provider should be "openai"
|
||||
And the strategy parsed model should be "provider/model/version"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Actor name with empty segments (M2 fix)
|
||||
@@ -345,8 +373,8 @@ Feature: LLM-powered Strategy Actor
|
||||
|
||||
Scenario: Parse strategy actor name with empty model uses default model
|
||||
When I parse strategy actor name "provider/"
|
||||
Then the strategy parsed provider should be "provider"
|
||||
And the strategy parsed model should be "gpt-4"
|
||||
Then the strategy parsed provider should be "openai"
|
||||
And the strategy parsed model should be "provider/"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Whitespace-only definition_of_done (L5 fix)
|
||||
|
||||
@@ -1,62 +1,42 @@
|
||||
# TDD issue-capture test for bug #10455 — EntityStore persistence stubs.
|
||||
# Regression tests for EntityStore and MemoryService SQL persistence.
|
||||
#
|
||||
# EntityStore in MemoryService exposes a connection_string parameter that
|
||||
# implies SQL-backed entity persistence. However, both persistence methods
|
||||
# are unimplemented stubs:
|
||||
#
|
||||
# _load_from_persistence() — contains only `pass`, entities never loaded.
|
||||
# _persist_if_needed() — marks dirty=False without writing any data.
|
||||
#
|
||||
# This creates a silent data-loss bug: callers that supply a connection_string
|
||||
# expect entities to survive process restarts, but they do not.
|
||||
#
|
||||
# These scenarios prove the bug exists by simulating separate process
|
||||
# invocations (fresh EntityStore / MemoryService instances backed by the
|
||||
# same SQLite database) and asserting that entities added in one invocation
|
||||
# are visible in the next. They FAIL until the bug is fixed.
|
||||
# The @tdd_expected_fail tag inverts the result so CI passes.
|
||||
# EntityStore in MemoryService now implements real SQL-backed entity
|
||||
# persistence via _load_from_persistence() and _persist_if_needed().
|
||||
# These scenarios verify that entities tracked in one instance survive
|
||||
# in a fresh instance backed by the same SQLite database.
|
||||
#
|
||||
# Originally TDD issue-capture tests for bug #10455 (EntityStore stubs).
|
||||
# See: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/10455
|
||||
|
||||
@tdd_issue @tdd_issue_10455 @mock_only
|
||||
Feature: TDD Issue #10455 — EntityStore entity data lost across process restarts
|
||||
@mock_only
|
||||
Feature: EntityStore and MemoryService SQL persistence
|
||||
As a developer using MemoryService with a connection_string
|
||||
I want entities tracked via track_entity() to survive process restarts
|
||||
So that cross-session entity recall works as documented
|
||||
|
||||
EntityStore._load_from_persistence() is a stub (pass) and
|
||||
_persist_if_needed() marks dirty=False without writing data.
|
||||
A fresh EntityStore instance backed by the same database should
|
||||
contain entities added by a previous instance.
|
||||
|
||||
@tdd_issue @tdd_issue_10455
|
||||
Scenario: Entity tracked in one EntityStore instance is visible in a fresh instance
|
||||
Given I create an EntityStore with a SQLite connection string and session "entity-persist-test"
|
||||
When I track a project entity "my-project" in the first EntityStore instance
|
||||
And I create a fresh EntityStore instance with the same connection string and session
|
||||
Then the fresh EntityStore instance should contain the entity "my-project"
|
||||
|
||||
@tdd_issue @tdd_issue_10455
|
||||
Scenario: Entity tracked via MemoryService survives simulated process restart
|
||||
Given I create a MemoryService with a SQLite connection string and session "memory-persist-test"
|
||||
When I track a plan entity "my-plan" via the MemoryService
|
||||
And I create a fresh MemoryService with the same connection string and session
|
||||
Then the fresh MemoryService should return the entity "my-plan" when queried
|
||||
|
||||
@tdd_issue @tdd_issue_10455
|
||||
Scenario: Persistence failure raises an exception rather than silently succeeding
|
||||
Given I create an EntityStore with an invalid connection string
|
||||
When I attempt to track an entity in the EntityStore with invalid connection
|
||||
Then an exception should be raised rather than silently failing
|
||||
|
||||
@tdd_issue @tdd_issue_10455
|
||||
Scenario: Multiple entities survive a simulated process restart
|
||||
Given I create an EntityStore with a SQLite connection string and session "multi-entity-persist"
|
||||
When I track multiple entities in the first EntityStore instance
|
||||
And I create a fresh EntityStore instance with the same connection string and session
|
||||
Then all tracked entities should be present in the fresh EntityStore instance
|
||||
|
||||
@tdd_issue @tdd_issue_10455
|
||||
Scenario: Entity metadata and mention count survive a simulated process restart
|
||||
Given I create an EntityStore with a SQLite connection string and session "entity-metadata-persist"
|
||||
When I track a project entity "project-delta" with metadata
|
||||
@@ -70,4 +50,4 @@ Feature: TDD Issue #10455 — EntityStore entity data lost across process restar
|
||||
| key | value |
|
||||
| owner | alice |
|
||||
| status | active |
|
||||
And the fresh EntityStore entity "project-delta" should have mention count 2
|
||||
And the fresh EntityStore entity "project-delta" should have mention count 2
|
||||
@@ -475,6 +475,7 @@ def llm_execute_acms_context() -> None:
|
||||
lifecycle = SimpleNamespace(
|
||||
get_plan=MagicMock(return_value=plan),
|
||||
get_action=MagicMock(return_value=action),
|
||||
resolve_actor_provider_model=MagicMock(return_value=None),
|
||||
)
|
||||
|
||||
actor = LLMExecuteActor(
|
||||
|
||||
@@ -43,6 +43,9 @@ from cleveragents.application.services.resource_registry_service import (
|
||||
ResourceRegistryService,
|
||||
)
|
||||
from cleveragents.application.services.session_workflow import SessionWorkflow
|
||||
from cleveragents.application.services.strategy_resolution import (
|
||||
build_actor_resolver,
|
||||
)
|
||||
from cleveragents.core.exceptions import DatabaseError
|
||||
from cleveragents.domain.models.core.session import (
|
||||
SessionActorNotConfiguredError,
|
||||
@@ -380,11 +383,36 @@ class A2aLocalFacade:
|
||||
if svc is None:
|
||||
raise RuntimeError("Session service not available — create a session first")
|
||||
registry = self._provider_registry
|
||||
actor_resolver = self._build_actor_resolver_for_session_workflow()
|
||||
return SessionWorkflow(
|
||||
session_service=svc,
|
||||
provider_registry=registry,
|
||||
actor_resolver=actor_resolver,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_actor_resolver_for_session_workflow():
|
||||
"""Build a namespace/name -> provider/model resolver for SessionWorkflow.
|
||||
|
||||
Returns a callable ``(actor_name: str) -> str | None``, or
|
||||
``None`` when the DI container or actor service is unavailable.
|
||||
"""
|
||||
try:
|
||||
from cleveragents.application.container import get_container
|
||||
|
||||
container = get_container()
|
||||
actor_service = container.actor_service()
|
||||
if actor_service is None:
|
||||
return None
|
||||
|
||||
return build_actor_resolver(actor_service)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"actor_resolver_unavailable",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def _handle_message_send(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle A2A ``message/send`` — invoke orchestrator actor (non-streaming).
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from cleveragents.application.services.plan_executor import (
|
||||
StrategyDecision,
|
||||
StreamCallback,
|
||||
)
|
||||
from cleveragents.application.services.strategy_resolution import _is_known_provider
|
||||
from cleveragents.config.settings import Settings, get_settings
|
||||
from cleveragents.core.exceptions import ValidationError
|
||||
from cleveragents.domain.models.acms.crp import AssembledContext
|
||||
@@ -65,6 +66,10 @@ class PlanLifecycleProtocol(Protocol):
|
||||
"""Commit the plan by persisting its state and artifacts."""
|
||||
...
|
||||
|
||||
def resolve_actor_provider_model(self, actor_name: str) -> str | None:
|
||||
"""Resolve a namespaced actor name to ``provider/model`` format."""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal data structures
|
||||
@@ -91,18 +96,31 @@ _LOG_RESPONSE_CHARS = 500
|
||||
|
||||
|
||||
def _parse_actor_name(actor_name: str) -> tuple[str, str]:
|
||||
"""Split ``provider/model`` into *(provider_type, model_id)*.
|
||||
"""Split *actor_name* into *(provider_type, model_id)*.
|
||||
|
||||
When the first slash-separated segment is a known
|
||||
:class:`ProviderType` value the input is treated as
|
||||
``provider/model``. Otherwise it is treated as a namespace/name
|
||||
reference and the entire string is returned as the model with the
|
||||
default provider. Callers SHOULD pre-resolve namespace/name
|
||||
references via the actor registry before calling this function.
|
||||
|
||||
Examples:
|
||||
``openai/gpt-4`` → ``("openai", "gpt-4")``
|
||||
``openai/gpt-4`` → ``("openai", "gpt-4")``
|
||||
``anthropic/claude-3`` → ``("anthropic", "claude-3")``
|
||||
``gpt-4`` → ``("openai", "gpt-4")`` (default provider)
|
||||
``gpt-4`` → ``("openai", "gpt-4")``
|
||||
``local/my-actor`` → ``("openai", "local/my-actor")``
|
||||
"""
|
||||
if not actor_name:
|
||||
return ("openai", "gpt-4")
|
||||
parts = actor_name.split("/", 1)
|
||||
if len(parts) == 2:
|
||||
return (parts[0], parts[1])
|
||||
provider, model = parts[0], parts[1]
|
||||
if _is_known_provider(provider):
|
||||
return (provider, model)
|
||||
if not provider:
|
||||
return ("openai", model)
|
||||
return ("openai", actor_name)
|
||||
return ("openai", actor_name)
|
||||
|
||||
|
||||
@@ -148,6 +166,12 @@ class LLMStrategizeActor:
|
||||
plan = self._lifecycle.get_plan(plan_id)
|
||||
action = self._lifecycle.get_action(plan.action_name)
|
||||
actor_name = action.strategy_actor or "openai/gpt-4"
|
||||
|
||||
# Pre-resolve namespace/name to provider/model via actor registry
|
||||
resolved = self._lifecycle.resolve_actor_provider_model(actor_name)
|
||||
if resolved:
|
||||
actor_name = resolved
|
||||
|
||||
provider_type, model_id = _parse_actor_name(actor_name)
|
||||
|
||||
self._logger.info(
|
||||
@@ -377,6 +401,12 @@ class LLMExecuteActor:
|
||||
plan = self._lifecycle.get_plan(plan_id)
|
||||
action = self._lifecycle.get_action(plan.action_name)
|
||||
actor_name = action.execution_actor or "openai/gpt-4"
|
||||
|
||||
# Pre-resolve namespace/name to provider/model via actor registry
|
||||
resolved = self._lifecycle.resolve_actor_provider_model(actor_name)
|
||||
if resolved:
|
||||
actor_name = resolved
|
||||
|
||||
provider_type, model_id = _parse_actor_name(actor_name)
|
||||
|
||||
self._logger.info(
|
||||
|
||||
@@ -485,15 +485,25 @@ class MemoryService:
|
||||
# Initialize message history
|
||||
history: BaseChatMessageHistory
|
||||
if connection_string:
|
||||
# Use SQL persistence if connection string provided
|
||||
history = cast(
|
||||
BaseChatMessageHistory,
|
||||
SQLChatMessageHistory(
|
||||
session_id=session_id,
|
||||
connection_string=connection_string,
|
||||
table_name="message_history",
|
||||
),
|
||||
)
|
||||
# Use SQL persistence if connection string provided.
|
||||
# langchain-community >= 0.3 renamed connection_string to connection.
|
||||
kwargs: dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"table_name": "message_history",
|
||||
}
|
||||
try:
|
||||
kwargs["connection"] = connection_string
|
||||
history = cast(
|
||||
BaseChatMessageHistory,
|
||||
SQLChatMessageHistory(**kwargs),
|
||||
)
|
||||
except TypeError:
|
||||
del kwargs["connection"]
|
||||
kwargs["connection_string"] = connection_string
|
||||
history = cast(
|
||||
BaseChatMessageHistory,
|
||||
SQLChatMessageHistory(**kwargs),
|
||||
)
|
||||
else:
|
||||
# Use in-memory storage as fallback
|
||||
history = InMemoryChatMessageHistory()
|
||||
|
||||
@@ -65,6 +65,7 @@ from cleveragents.application.services.config_service import ConfigLevel, Config
|
||||
from cleveragents.application.services.plan_preflight_guardrail import (
|
||||
PlanPreflightGuardrail,
|
||||
)
|
||||
from cleveragents.application.services.strategy_resolution import _is_known_provider
|
||||
from cleveragents.core.exceptions import (
|
||||
BusinessRuleViolation,
|
||||
DatabaseError,
|
||||
@@ -728,6 +729,37 @@ class PlanLifecycleService:
|
||||
"""Generate a new ULID string."""
|
||||
return str(ULID())
|
||||
|
||||
def resolve_actor_provider_model(self, actor_name: str) -> str | None:
|
||||
"""Resolve a namespaced actor name to ``provider/model`` format.
|
||||
|
||||
When *actor_name* is a known ``provider/model`` pair
|
||||
(e.g. ``openai/gpt-4``) it is returned unchanged. When it is a
|
||||
namespace/name (e.g. ``local/my-strategist``) the function looks
|
||||
up the actor record and extracts its provider and model fields,
|
||||
returning ``"<provider>/<model>"`` or ``None`` if the actor
|
||||
cannot be found.
|
||||
"""
|
||||
if not actor_name or actor_name.startswith("__"):
|
||||
return None
|
||||
parts = actor_name.split("/", 1)
|
||||
if len(parts) == 2 and _is_known_provider(parts[0].strip()):
|
||||
return actor_name
|
||||
if self.unit_of_work is None:
|
||||
return None
|
||||
try:
|
||||
with self.unit_of_work.transaction() as ctx:
|
||||
actor: Actor | None = ctx.actors.get_by_name(actor_name)
|
||||
except Exception:
|
||||
self._logger.warning(
|
||||
"actor_provider_resolution_failed",
|
||||
actor_name=actor_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
if actor is None:
|
||||
return None
|
||||
return f"{actor.provider}/{actor.model}"
|
||||
|
||||
def _resolve_actor_registry_entry(self, actor_name: str) -> object | None:
|
||||
"""Resolve a namespaced actor name to its stored configuration payload."""
|
||||
if not actor_name or actor_name.startswith("__"):
|
||||
|
||||
@@ -38,6 +38,7 @@ from cleveragents.application.services.session_caller import (
|
||||
extract_content,
|
||||
history_to_langchain_messages,
|
||||
)
|
||||
from cleveragents.application.services.strategy_resolution import _parse_actor_name
|
||||
from cleveragents.domain.models.core.session import (
|
||||
MessageRole,
|
||||
SessionActorNotConfiguredError,
|
||||
@@ -138,12 +139,14 @@ class SessionWorkflow:
|
||||
provider_registry: ProviderRegistry | None = None,
|
||||
tool_registry: ToolRegistry | None = None,
|
||||
llm_factory: Callable[[str], Any] | None = None,
|
||||
actor_resolver: Callable[[str], str | None] | None = None,
|
||||
max_iterations: int = 25,
|
||||
) -> None:
|
||||
self._session_service = session_service
|
||||
self._provider_registry = provider_registry
|
||||
self._tool_registry = tool_registry or ToolRegistry()
|
||||
self._llm_factory = llm_factory
|
||||
self._actor_resolver = actor_resolver
|
||||
self._max_iterations = max_iterations
|
||||
self._logger = logger.bind(component="session_workflow")
|
||||
# Populated by tell_stream() so callers can read real usage metrics
|
||||
@@ -367,6 +370,10 @@ class SessionWorkflow:
|
||||
|
||||
Delegates to ``llm_factory`` (test injection), ``ProviderRegistry``,
|
||||
or falls back to a stub when neither is available.
|
||||
|
||||
Pre-resolves namespace/name actor references (e.g. ``local/my-actor``)
|
||||
via ``actor_resolver`` before passing the name to the provider
|
||||
registry (addresses #11254).
|
||||
"""
|
||||
# Test injection point — skips provider registry when llm_factory is set.
|
||||
# Must be checked before _provider_registry fallback so tests can inject
|
||||
@@ -380,11 +387,20 @@ class SessionWorkflow:
|
||||
if self._provider_registry is None:
|
||||
return self._make_stub_llm()
|
||||
|
||||
from cleveragents.application.services.strategy_resolution import (
|
||||
_parse_actor_name,
|
||||
)
|
||||
# Pre-resolve namespace/name references (e.g. local/my-strategist)
|
||||
# to provider/model format via the injected actor_resolver.
|
||||
# When no resolver is available or resolution fails, fall through
|
||||
# to _parse_actor_name which treats the input as namespace/name
|
||||
# with the default provider (avoiding a ValueError crash).
|
||||
resolved: str | None = None
|
||||
if self._actor_resolver is not None:
|
||||
try:
|
||||
resolved = self._actor_resolver(actor_name)
|
||||
except Exception:
|
||||
resolved = None
|
||||
effective_name = resolved if resolved is not None else actor_name
|
||||
|
||||
provider_type, model_id = _parse_actor_name(actor_name)
|
||||
provider_type, model_id = _parse_actor_name(effective_name)
|
||||
return self._provider_registry.create_llm(
|
||||
provider_type=provider_type,
|
||||
model_id=model_id,
|
||||
|
||||
@@ -457,6 +457,11 @@ class StrategyActor:
|
||||
plan = self._lifecycle.get_plan(plan_id)
|
||||
action = self._lifecycle.get_action(plan.action_name)
|
||||
actor_name = action.strategy_actor or _DEFAULT_ACTOR_NAME
|
||||
|
||||
# Pre-resolve namespace/name to provider/model via actor registry
|
||||
resolved = self._lifecycle.resolve_actor_provider_model(actor_name)
|
||||
if resolved:
|
||||
actor_name = resolved
|
||||
except (KeyError, ValueError, RuntimeError):
|
||||
self._logger.debug(
|
||||
"Could not resolve actor from plan, using default",
|
||||
|
||||
@@ -7,6 +7,7 @@ graphs, and actor name parsing.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
|
||||
import structlog
|
||||
@@ -15,6 +16,7 @@ from cleveragents.application.services.context_tiers import (
|
||||
ContextTierService,
|
||||
)
|
||||
from cleveragents.core.exceptions import PlanError
|
||||
from cleveragents.providers.registry import ProviderType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.providers.registry import ProviderRegistry
|
||||
@@ -26,7 +28,7 @@ _DEFAULT_ACTOR_NAME = "openai/gpt-4"
|
||||
|
||||
@runtime_checkable
|
||||
class LifecycleService(Protocol):
|
||||
"""Minimal interface for plan/action resolution."""
|
||||
"""Minimal interface for plan/action/actor resolution."""
|
||||
|
||||
def get_plan(self, plan_id: str) -> Any:
|
||||
"""Get a plan by ID."""
|
||||
@@ -36,6 +38,10 @@ class LifecycleService(Protocol):
|
||||
"""Get an action by name."""
|
||||
...
|
||||
|
||||
def resolve_actor_provider_model(self, actor_name: str) -> str | None:
|
||||
"""Resolve a namespaced actor name to ``provider/model`` format."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AcmsPipeline(Protocol):
|
||||
@@ -99,15 +105,37 @@ def validate_no_cycles(edges: list[tuple[str, str]]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _is_known_provider(provider: str) -> bool:
|
||||
"""Check whether *provider* is a known ``ProviderType`` value.
|
||||
|
||||
Returns ``True`` when the string matches a registered provider
|
||||
(e.g. ``"openai"``, ``"anthropic"``). Returns ``False`` when the
|
||||
string is something else -- typically a namespace prefix like
|
||||
``"local"`` or ``"cleverthis"``.
|
||||
"""
|
||||
return provider in ProviderType.__members__.values()
|
||||
|
||||
|
||||
def _parse_actor_name(actor_name: str) -> tuple[str, str]:
|
||||
"""Split ``provider/model`` into *(provider_type, model_id)*.
|
||||
"""Split *actor_name* into *(provider_type, model_id)*.
|
||||
|
||||
When the first slash-separated segment is a known
|
||||
:class:`~cleveragents.providers.registry.ProviderType` value the
|
||||
input is treated as ``provider/model``. Otherwise it is treated as
|
||||
a namespace/name reference (e.g. ``"local/my-strategist"``) and the
|
||||
entire string is returned as the model with the default provider.
|
||||
|
||||
Callers SHOULD pre-resolve namespace/name references via the actor
|
||||
registry before calling this function.
|
||||
|
||||
Falls back to the default derived from :data:`_DEFAULT_ACTOR_NAME`
|
||||
when the input is empty or contains no ``/`` separator.
|
||||
|
||||
Examples:
|
||||
``openai/gpt-4`` -> ``("openai", "gpt-4")``
|
||||
``gpt-4`` -> ``("openai", "gpt-4")`` (default provider)
|
||||
``"openai/gpt-4"`` → ``("openai", "gpt-4")``
|
||||
``"anthropic/claude-3"`` → ``("anthropic", "claude-3")``
|
||||
``"gpt-4"`` → ``("openai", "gpt-4")``
|
||||
``"local/my-strategist"`` → ``("openai", "local/my-strategist")``
|
||||
"""
|
||||
_default_provider, _default_model = _DEFAULT_ACTOR_NAME.split("/", 1)
|
||||
if not actor_name or not actor_name.strip():
|
||||
@@ -120,19 +148,56 @@ def _parse_actor_name(actor_name: str) -> tuple[str, str]:
|
||||
if len(parts) == 2:
|
||||
provider, model = parts[0].strip(), parts[1].strip()
|
||||
if not provider and not model:
|
||||
# Both segments empty (e.g. "/") — full default.
|
||||
logger.warning(
|
||||
"Actor name '%s' has empty provider and model, defaulting to %s",
|
||||
actor_name,
|
||||
_DEFAULT_ACTOR_NAME,
|
||||
)
|
||||
return (_default_provider, _default_model)
|
||||
# Preserve whichever segment the caller provided, filling
|
||||
# in the default for the missing half.
|
||||
return (provider or _default_provider, model or _default_model)
|
||||
if _is_known_provider(provider):
|
||||
return (provider or _default_provider, model or _default_model)
|
||||
if not provider:
|
||||
return (_default_provider, model or _default_model)
|
||||
logger.debug(
|
||||
"Actor name '%s' first segment is not a known provider; "
|
||||
"treating as namespace/name.",
|
||||
actor_name,
|
||||
)
|
||||
return (_default_provider, actor_name)
|
||||
return (_default_provider, actor_name)
|
||||
|
||||
|
||||
def build_actor_resolver(
|
||||
actor_service: Any,
|
||||
) -> Callable[[str], str | None]:
|
||||
"""Build a namespace/name → provider/model resolver closure.
|
||||
|
||||
Returns a callable ``(actor_name: str) -> str | None`` that:
|
||||
- returns ``None`` when the name is already in ``provider/model`` format
|
||||
(checked via :func:`_is_known_provider`)
|
||||
- looks up the actor via *actor_service* and returns
|
||||
``"<provider>/<model>"`` on success
|
||||
- returns ``None`` when the actor is unknown or lookup fails
|
||||
|
||||
Used by both CLI session commands and the A2A facade to provide
|
||||
``SessionWorkflow`` with namespace/name resolution capability.
|
||||
"""
|
||||
|
||||
def resolve(actor_name: str) -> str | None:
|
||||
if not actor_name:
|
||||
return None
|
||||
parts = actor_name.split("/", 1)
|
||||
if len(parts) == 2 and _is_known_provider(parts[0].strip()):
|
||||
return None # already in provider/model format
|
||||
try:
|
||||
actor = actor_service.get_actor(actor_name)
|
||||
except Exception:
|
||||
return None
|
||||
return f"{actor.provider}/{actor.model}"
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
def resolve_strategy_actor(
|
||||
provider_registry: ProviderRegistry | None = None,
|
||||
lifecycle_service: LifecycleService | None = None,
|
||||
|
||||
@@ -29,6 +29,9 @@ from rich.table import Table
|
||||
|
||||
from cleveragents.a2a.models import A2aRequest
|
||||
from cleveragents.application.services.session_workflow import SessionWorkflow
|
||||
from cleveragents.application.services.strategy_resolution import (
|
||||
build_actor_resolver,
|
||||
)
|
||||
from cleveragents.cli.formatting import OutputFormat, format_output
|
||||
from cleveragents.core.exceptions import DatabaseError
|
||||
from cleveragents.domain.models.core.session import (
|
||||
@@ -100,9 +103,11 @@ def _build_session_workflow() -> SessionWorkflow:
|
||||
"""
|
||||
service = _get_session_service()
|
||||
provider_registry = _get_provider_registry()
|
||||
actor_resolver = _build_actor_resolver()
|
||||
return SessionWorkflow(
|
||||
session_service=service,
|
||||
provider_registry=provider_registry,
|
||||
actor_resolver=actor_resolver,
|
||||
)
|
||||
|
||||
|
||||
@@ -121,6 +126,40 @@ def _get_provider_registry() -> ProviderRegistry | None:
|
||||
return None
|
||||
|
||||
|
||||
def _build_actor_resolver():
|
||||
"""Build a resolver callable for namespace/name -> provider/model resolution.
|
||||
|
||||
Returns a callable ``(actor_name: str) -> str | None`` that looks up
|
||||
a namespace/name actor reference (e.g. ``"local/my-strategist"``) in
|
||||
the actor registry and returns the ``"provider/model"`` string, or
|
||||
``None`` when the name is already in provider/model format, the actor
|
||||
is unknown, or the registry is unavailable.
|
||||
|
||||
When no DI container is configured the function returns a resolver
|
||||
that always returns ``None`` (graceful degradation).
|
||||
"""
|
||||
try:
|
||||
from cleveragents.application.container import get_container
|
||||
|
||||
container = get_container()
|
||||
actor_service = container.actor_service()
|
||||
if actor_service is None:
|
||||
return _null_actor_resolver
|
||||
|
||||
return build_actor_resolver(actor_service)
|
||||
except Exception:
|
||||
_log.warning(
|
||||
"actor_resolver_unavailable",
|
||||
exc_info=True,
|
||||
)
|
||||
return _null_actor_resolver
|
||||
|
||||
|
||||
def _null_actor_resolver(_actor_name: str) -> None:
|
||||
"""Null-object resolver — always returns ``None``."""
|
||||
return None
|
||||
|
||||
|
||||
def _facade_dispatch(operation: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Route an operation through the A2A local facade.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user