Files
hurui200320 15e81e58f1
CI / dead_code (pull_request) Successful in 40s
CI / security (pull_request) Successful in 1m10s
CI / coverage (pull_request) Successful in 1m11s
CI / typecheck (pull_request) Successful in 1m19s
CI / unit_tests (pull_request) Successful in 1m18s
CI / lint (pull_request) Successful in 1m20s
CI / build (pull_request) Successful in 23s
CI / lint (push) Successful in 51s
CI / typecheck (push) Successful in 47s
CI / unit_tests (push) Successful in 49s
CI / coverage (push) Successful in 53s
CI / dead_code (push) Successful in 26s
CI / build (push) Successful in 26s
CI / security (push) Successful in 42s
fix: sync doc with extraction scope, remove boundary violation, add ProviderRegistryPort
Boundary fix:
- Delete src/cleveractors/acms/index.py (ACMSIndex, FileTraversalEngine,
  IndexEntry, FileType, TierLevel) — these are CLI/storage concerns that
  belong in cleveragents-core per ADR-001; the file already exists there
  at cleveragents/acms/index.py
- Clean src/cleveractors/acms/__init__.py: remove all index.py imports
  and re-exports; __all__ now derives purely from uko.__all__

ProviderRegistryPort (ADR-005):
- Add src/cleveractors/ports/provider_registry.py: ProviderRegistryPort
  Protocol with get(provider, model) -> Agent | None; structural typing,
  no host imports, mirrors the shape of ToolRegistryPort
- Update src/cleveractors/ports/__init__.py: export ProviderRegistryPort
  alongside ToolRegistryPort; update module docstring

Wire provider resolution into the node executor:
- compiler.py: _map_node now accepts actor_provider/actor_model defaults
  and merges them into AGENT node metadata (setdefault so per-node config
  values still win); compile_actor passes config.provider/config.model
- nodes.py: Node.__init__ gains optional provider_registry parameter;
  _execute_agent resolution order is now: (1) pre-resolved agents dict,
  (2) ProviderRegistryPort.get(provider, model) from node metadata,
  (3) graceful synthetic fallback — the ValueError guard for missing
  config.agent is removed since the registry is a valid alternative path

Documentation:
- Port ADR-003 (actor abstraction definition) from cleveragents-core ADR-031
- Port ADR-004 (Jinja2 YAML template preprocessing) from cleveragents-core
  ADR-032
- Add ADR-005 (Provider Registry Protocol) for the new provider_registry port
- Port five reference docs: actors_schema.md, actor_compiler.md,
  actor_config.md, actor_hierarchy.md, actors_examples.md
- Port API reference: api/actor.md
- Add provider field to all YAML examples in actors_examples.md,
  actor_hierarchy.md, and actor_config.md
- Update error messages in actor_config.md to match actual validator output
- Add graph-level provider/model propagation docs to actor_compiler.md
- Add ADR-005 cross-references to ADR-001, ADR-002, and actors_schema.md
- Fix ADR-005 status section to reflect that implementation is in this PR
- Fix ADR-004 to reference actual test files (smoke.feature, not phantom ones)
- Remove fabricated reserved-namespace constraint from ADR-003 Constraints
- Fix broken LICENSE link in docs/index.md for MkDocs rendering
- Add provider field to ActorConfigSchema table in api/actor.md
- Add internal modules section to docs/specification.md (ticket item 7)
- Fix markdown formatting in actors_schema.md provider field definition
- Add ADR-005 cross-reference to actors_schema.md provider field section

BDD coverage (7 scenarios, 30 steps):
- ProviderRegistryPort happy-path resolution
- Graceful fallback when provider registry returns None
- Graceful fallback when no provider registry is supplied
- Pre-resolved agents dict takes precedence over provider registry
- Compile a minimal graph actor, reject missing provider, render template

Post-review fixes applied in amend:
- actors_examples.md and actor_hierarchy.md: added provider to all examples
- ADR-003: removed fabricated reserved-namespace constraint
- ADR-004: corrected phantom test file references
- ADR-005: updated status to reflect implementation is included
- Added 'no registry' and 'agents dict precedence' BDD scenarios
- Added provider to api/actor.md ActorConfigSchema fields table
- ADR-001 and ADR-002: added ADR-005 cross-references
- actor_config.md: fixed error message to match validator
- actor_compiler.md: documented graph-level provider/model propagation
- index.md: fixed broken LICENSE link
- actors_schema.md: added ADR-005 cross-reference

ISSUES CLOSED: #4
2026-05-21 10:10:37 +00:00

292 lines
9.9 KiB
Python

"""Behave step definitions for the library smoke feature."""
from __future__ import annotations
import asyncio
from typing import Any
from behave import given, then, when
from cleveractors.actor.compiler import compile_actor
from cleveractors.actor.schema import ActorConfigSchema
from cleveractors.agents.base import Agent
from cleveractors.langgraph.nodes import Node, NodeConfig, NodeType
from cleveractors.langgraph.state import GraphState
from cleveractors.ports.provider_registry import ProviderRegistryPort
from cleveractors.templates.secure_renderer import SecureTemplateRenderer
def _minimal_graph_actor() -> dict[str, Any]:
return {
"name": "local/smoke",
"type": "graph",
"provider": "openai",
"model": "gpt-4o-mini",
"description": "smoke test actor",
"route": {
"entry_node": "start",
"exit_nodes": ["end"],
"nodes": [
{
"id": "start",
"name": "start",
"type": "agent",
"description": "entry",
},
{
"id": "end",
"name": "end",
"type": "agent",
"description": "exit",
},
],
"edges": [
{"from_node": "start", "to_node": "end"},
],
},
}
@given("a v3 actor configuration with a start node and an exit node")
def given_minimal_actor(context: Any) -> None:
context.actor_raw = _minimal_graph_actor()
@given("a v3 actor configuration missing the provider field")
def given_actor_missing_provider(context: Any) -> None:
raw = _minimal_graph_actor()
raw.pop("provider")
context.actor_raw = raw
@given('a simple template "{template}"')
def given_template(context: Any, template: str) -> None:
context.template = template
context.context_data = {}
@given('a context with greeting "{greeting}" and name "{name}"')
def given_context(context: Any, greeting: str, name: str) -> None:
context.context_data = {"greeting": greeting, "name": name}
@when("the actor configuration is compiled")
def when_compiled(context: Any) -> None:
context.compile_exc = None
try:
# NB: ``context.config`` is a reserved attribute on Behave's
# Context; using ``actor_config`` keeps us out of its way.
context.actor_config = ActorConfigSchema.model_validate(context.actor_raw)
context.compiled = compile_actor(context.actor_config)
except Exception as exc:
context.compile_exc = exc
@when("the actor configuration is validated")
def when_validated(context: Any) -> None:
context.validation_exc = None
try:
ActorConfigSchema.model_validate(context.actor_raw)
except Exception as exc:
context.validation_exc = exc
@when("the template is rendered")
def when_rendered(context: Any) -> None:
renderer = SecureTemplateRenderer()
context.rendered = renderer.render(context.template, context.context_data)
@then("the compile succeeds without errors")
def then_compile_ok(context: Any) -> None:
assert context.compile_exc is None, f"unexpected: {context.compile_exc!r}"
@then("the compiled metadata lists both node ids")
def then_node_ids(context: Any) -> None:
ids = set(context.compiled.metadata.node_ids)
assert ids == {"start", "end"}, f"got {ids}"
@then("a ValidationError is raised")
def then_validation_error(context: Any) -> None:
assert context.validation_exc is not None, "expected an exception"
name = type(context.validation_exc).__name__
assert "Validation" in name, f"expected validation error, got {name}"
@then('the result is "{expected}"')
def then_result_equals(context: Any, expected: str) -> None:
assert context.rendered == expected, (
f"expected {expected!r}, got {context.rendered!r}"
)
# ---------------------------------------------------------------------------
# ProviderRegistryPort scenario helpers
# ---------------------------------------------------------------------------
class _StubAgent(Agent):
"""Minimal Agent stub for testing ProviderRegistryPort resolution."""
def __init__(self) -> None:
super().__init__(name="stub-agent")
async def process_message(
self, message: Any, context: dict[str, Any] | None = None
) -> Any:
return "stub response"
def get_capabilities(self) -> list[str]:
return ["stub"]
class _StubProviderRegistry:
"""Minimal ProviderRegistryPort implementation for testing."""
def __init__(self, provider: str, model: str) -> None:
self._provider = provider
self._model = model
self.calls: list[tuple[str, str]] = []
def get(self, provider: str, model: str) -> Agent | None:
self.calls.append((provider, model))
if provider == self._provider and model == self._model:
return _StubAgent()
return None
@given(
'a compiled graph actor with an agent node using provider "{provider}"'
' and model "{model}" and agent name "{agent_name}"'
)
def given_compiled_actor_with_agent_name(
context: Any, provider: str, model: str, agent_name: str
) -> None:
context.node_provider = provider
context.node_model = model
context.agent_name = agent_name
context.node_config = NodeConfig(
name="planner",
type=NodeType.AGENT,
agent=agent_name,
metadata={"provider": provider, "model": model},
)
@given(
'a compiled graph actor with an agent node using provider "{provider}"'
' and model "{model}"'
)
def given_compiled_actor_with_agent_node(
context: Any, provider: str, model: str
) -> None:
context.node_provider = provider
context.node_model = model
# Build a NodeConfig that simulates what the compiler produces for an
# AGENT node — provider and model are in metadata, agent name is None
# (the common case when no explicit agent reference is configured).
context.node_config = NodeConfig(
name="planner",
type=NodeType.AGENT,
agent=None,
metadata={"provider": provider, "model": model},
)
@given(
'a provider registry stub that returns a stub agent for "{provider}" and "{model}"'
)
def given_stub_provider_registry(context: Any, provider: str, model: str) -> None:
context.provider_registry = _StubProviderRegistry(provider, model)
@when("the agent node is executed without a pre-resolved agents dict")
def when_node_executed(context: Any) -> None:
node = Node(
config=context.node_config,
agents={}, # empty — forces ProviderRegistryPort lookup
provider_registry=context.provider_registry,
)
state = GraphState(
messages=[{"role": "user", "content": "hello"}],
metadata={"current_message": "hello"},
)
context.node_result = asyncio.run(node.execute(state))
@then('the provider registry was called with provider "{provider}" and model "{model}"')
def then_registry_called(context: Any, provider: str, model: str) -> None:
calls = context.provider_registry.calls
assert (provider, model) in calls, (
f"expected registry.get({provider!r}, {model!r}) to be called; "
f"actual calls: {calls}"
)
@then("the execution result contains an assistant message")
def then_has_assistant_message(context: Any) -> None:
messages = context.node_result.get("messages", [])
assistant_msgs = [m for m in messages if m.get("role") == "assistant"]
assert assistant_msgs, f"no assistant message in result: {context.node_result}"
@then('the assistant message content is "{expected_content}"')
def then_assistant_message_content(context: Any, expected_content: str) -> None:
messages = context.node_result.get("messages", [])
assistant_msgs = [m for m in messages if m.get("role") == "assistant"]
assert assistant_msgs, f"no assistant message in result: {context.node_result}"
actual = assistant_msgs[0].get("content", "")
assert actual == expected_content, f"expected {expected_content!r}, got {actual!r}"
@when("the agent node is executed without a provider registry")
def when_node_executed_no_registry(context: Any) -> None:
node = Node(
config=context.node_config,
agents={},
provider_registry=None,
)
state = GraphState(
messages=[{"role": "user", "content": "hello"}],
metadata={"current_message": "hello"},
)
context.node_result = asyncio.run(node.execute(state))
@when('the agent node is executed with a pre-resolved agent named "{agent_name}"')
def when_node_executed_with_agents_dict(context: Any, agent_name: str) -> None:
node = Node(
config=context.node_config,
agents={agent_name: _StubAgent()},
provider_registry=context.provider_registry,
)
state = GraphState(
messages=[{"role": "user", "content": "hello"}],
metadata={"current_message": "hello"},
)
context.node_result = asyncio.run(node.execute(state))
@then("the pre-resolved agents dict was used instead of the provider registry")
def then_agents_dict_precedence(context: Any) -> None:
# The provider registry should not have been called — the agents dict
# took priority.
calls = context.provider_registry.calls
assert not calls, (
f"provider registry should not have been called; got calls: {calls}"
)
messages = context.node_result.get("messages", [])
assistant_msgs = [m for m in messages if m.get("role") == "assistant"]
assert assistant_msgs, f"no assistant message in result: {context.node_result}"
actual = assistant_msgs[0].get("content", "")
assert actual == "stub response", f"expected 'stub response', got {actual!r}"
@then("the stub registry satisfies ProviderRegistryPort")
def then_stub_satisfies_protocol(context: Any) -> None:
assert isinstance(context.provider_registry, ProviderRegistryPort), (
"stub registry must satisfy ProviderRegistryPort"
)