fix(actors): distinguish namespace/name from provider/model in actor name parsing #11255

Merged
CoreRasurae merged 5 commits from bugfix/m3-namespace-provider-name-collision into master 2026-05-23 13:31:18 +00:00
21 changed files with 566 additions and 64 deletions
+12
View File
@@ -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
+12
View File
@@ -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()`
+18
View File
@@ -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
# ---------------------------------------------------------------
+6 -1
View File
@@ -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}'"
)
+10 -3
View File
@@ -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)
# ---------------------------------------------------------------------------
+32 -4
View File
@@ -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
+1
View File
@@ -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(
+28
View File
@@ -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,
+39
View File
@@ -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.