38fe40553d
Adds 104 Robot Framework integration tests exercising the full 6-agent email categorization actor graph across five configuration strategies: local, remote, mixed, component-decomposed (merge_configs), and namespaced (local: refs via ReferenceResolver + LocalPackageStore). - 7 new email graph YAML fixtures under tests/fixtures/email_graph/ - 7 namespaced component YAMLs with local namespace identifiers - 9 robot test suite files (email_categorization_graph, per-agent, negative tests, merge_configs validation, components, namespaced) - EmailGraphLib.py keyword library (executor, graph, merge, components) - CleverActorsLib.py refactored into per-module libs: robot/lib/config_lib.py, app_lib.py, registry_lib.py - email_testdata.resource with 12 realistic email scenarios - All 104 integration tests pass, all 23 existing tests preserved Closes #30
275 lines
11 KiB
Python
275 lines
11 KiB
Python
"""Robot Framework keyword library for application lifecycle and agent factory.
|
|
|
|
Covers: context manager, exceptions, progress bar, agent factory,
|
|
application, and stream router.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
from cleveractors import ContextManager, ReactiveCleverAgentsApp
|
|
from cleveractors.agents.factory import Agent, AgentFactory
|
|
from cleveractors.agents.tool import ToolAgent
|
|
from cleveractors.core.exceptions import (
|
|
AgentCreationError,
|
|
CleverAgentsException,
|
|
ConfigurationError,
|
|
)
|
|
from cleveractors.core.progress import ProgressBarManager
|
|
from cleveractors.registry.exceptions import (
|
|
AccessDeniedError,
|
|
AuthenticationRequiredError,
|
|
ConflictError,
|
|
InvalidPackageIdError,
|
|
InvalidPackageReferenceError,
|
|
PackageNotFoundError,
|
|
RegistryError,
|
|
RegistryNetworkError,
|
|
ValidationError,
|
|
VersionNotFoundError,
|
|
exception_for_status,
|
|
)
|
|
from cleveractors.templates.renderer import TemplateEngine, TemplateRenderer
|
|
|
|
_EXCEPTION_CLASS_MAP: dict[str, type[CleverAgentsException]] = {
|
|
"CleverAgentsException": CleverAgentsException,
|
|
"ConfigurationError": ConfigurationError,
|
|
"AgentCreationError": AgentCreationError,
|
|
"RegistryError": RegistryError,
|
|
"PackageNotFoundError": PackageNotFoundError,
|
|
"InvalidPackageIdError": InvalidPackageIdError,
|
|
"InvalidPackageReferenceError": InvalidPackageReferenceError,
|
|
"VersionNotFoundError": VersionNotFoundError,
|
|
"ValidationError": ValidationError,
|
|
"AuthenticationRequiredError": AuthenticationRequiredError,
|
|
"AccessDeniedError": AccessDeniedError,
|
|
"ConflictError": ConflictError,
|
|
"RegistryNetworkError": RegistryNetworkError,
|
|
}
|
|
|
|
|
|
class AppLib: # pragma: no cover - integration test library
|
|
"""Keyword library for application lifecycle and agent factory."""
|
|
|
|
ROBOT_LIBRARY_SCOPE = "TEST SUITE"
|
|
|
|
# ── context manager ───────────────────────────────────────────────
|
|
|
|
def create_context(self, name: str) -> None:
|
|
self._temp_dir = tempfile.mkdtemp(prefix="ca_test_ctx_")
|
|
self._ctx = ContextManager(name, context_dir=Path(self._temp_dir))
|
|
self._ctx.clear()
|
|
|
|
def add_context_message(self, role: str, content: str) -> None:
|
|
self._ctx.add_message(role, content)
|
|
|
|
def context_history_count_equals(self, expected: str) -> None:
|
|
history = self._ctx.get_conversation_history()
|
|
if len(history) != int(expected):
|
|
raise AssertionError(
|
|
f"Expected {expected} messages, got {len(history)}: {history}"
|
|
)
|
|
|
|
def context_last_n_count_equals(self, n: str, expected: str) -> None:
|
|
messages = self._ctx.get_last_n_messages(int(n))
|
|
if len(messages) != int(expected):
|
|
raise AssertionError(f"Expected {expected} messages, got {len(messages)}")
|
|
|
|
def context_state_equals(self, key: str, expected: str) -> None:
|
|
value = self._ctx.get_state(key)
|
|
if str(value) != expected:
|
|
raise AssertionError(f"State {key}: expected {expected!r}, got {value!r}")
|
|
|
|
def context_update_state(self, key: str, value: str) -> None:
|
|
self._ctx.update_state(key, value)
|
|
|
|
def context_save_and_reload(self) -> None:
|
|
self._ctx.save()
|
|
self._ctx = ContextManager(
|
|
self._ctx.context_name, context_dir=Path(self._temp_dir)
|
|
)
|
|
|
|
def context_exists_should_be(self, expected: str) -> None:
|
|
exists = self._ctx.exists()
|
|
expected_bool = expected.lower() == "true"
|
|
if exists != expected_bool:
|
|
raise AssertionError(
|
|
f"Expected exists={expected_bool}, got exists={exists}"
|
|
)
|
|
|
|
def context_should_have_global_key(self, key: str, value: str) -> None:
|
|
gc = self._ctx.get_global_context()
|
|
stored = gc.get(key)
|
|
if str(stored) != value:
|
|
raise AssertionError(
|
|
f"Global context {key}: expected {value!r}, got {stored!r}"
|
|
)
|
|
|
|
def context_save_global(self, key: str, value: str) -> None:
|
|
self._ctx.save_global_context({key: value})
|
|
|
|
def context_clear(self) -> None:
|
|
self._ctx.clear()
|
|
|
|
def context_delete(self) -> None:
|
|
self._ctx.delete()
|
|
|
|
def context_export_import(self, export_key: str, export_val: str) -> None:
|
|
self._ctx.update_state(export_key, export_val)
|
|
self._ctx.save()
|
|
export_path = Path(self._temp_dir) / "export.json"
|
|
self._ctx.export_context(export_path)
|
|
self._ctx.delete()
|
|
os.makedirs(Path(self._temp_dir) / self._ctx.context_name, exist_ok=True)
|
|
self._ctx.import_context(export_path)
|
|
imported = self._ctx.get_state(export_key)
|
|
if str(imported) != export_val:
|
|
raise AssertionError(
|
|
f"Import mismatch: {export_key}={export_val!r} -> {imported!r}"
|
|
)
|
|
|
|
def context_list_contains(self, name: str) -> None:
|
|
contexts = ContextManager.list_contexts(context_dir=Path(self._temp_dir))
|
|
if name not in contexts:
|
|
raise AssertionError(f"Context {name!r} not in list: {contexts}")
|
|
|
|
def cleanup_temp_dir(self) -> None:
|
|
if hasattr(self, "_temp_dir") and os.path.isdir(self._temp_dir):
|
|
shutil.rmtree(self._temp_dir, ignore_errors=True)
|
|
|
|
# ── exceptions ────────────────────────────────────────────────────
|
|
|
|
def exception_is_subclass_of(self, child: str, parent: str) -> None:
|
|
child_cls = _EXCEPTION_CLASS_MAP[child]
|
|
parent_cls = _EXCEPTION_CLASS_MAP[parent]
|
|
if not issubclass(child_cls, parent_cls):
|
|
raise AssertionError(f"{child} is not a subclass of {parent}")
|
|
|
|
def exception_is_instance_cleveragents_exception(self, exc_name: str) -> None:
|
|
cls = _EXCEPTION_CLASS_MAP[exc_name]
|
|
if not issubclass(cls, CleverAgentsException):
|
|
raise AssertionError(f"{exc_name} is not a CleverAgentsException")
|
|
|
|
def exception_for_status_returns(self, code: str, expected_class: str) -> None:
|
|
result = exception_for_status(int(code), f"status {code}")
|
|
expected = _EXCEPTION_CLASS_MAP[expected_class]
|
|
if not isinstance(result, expected):
|
|
raise AssertionError(
|
|
f"Expected {expected_class} for code {code}, "
|
|
f"got {type(result).__name__}"
|
|
)
|
|
if (
|
|
getattr(result, "status_code", None) is not None
|
|
and str(result.status_code) != code
|
|
):
|
|
raise AssertionError(
|
|
f"Expected status_code {code}, got {result.status_code}"
|
|
)
|
|
|
|
# ── progress bar ──────────────────────────────────────────────────
|
|
|
|
def create_progress_bar(self) -> None:
|
|
self._pb = ProgressBarManager()
|
|
self._pb_exists = True
|
|
|
|
def progress_bar_renders_without_error(self) -> None:
|
|
self._pb.update(stage="test", current=1, total=2, message="start")
|
|
result = self._pb.update(stage="test", current=2, total=2, message="done")
|
|
if not result:
|
|
raise AssertionError("ProgressBarManager.update returned empty")
|
|
|
|
# ── agent factory ─────────────────────────────────────────────────
|
|
|
|
def create_agent_factory_from_yaml(self, yaml_text: str) -> None:
|
|
self._renderer = TemplateRenderer(TemplateEngine.SIMPLE)
|
|
config = yaml.safe_load(yaml_text)
|
|
self._factory = AgentFactory(config, self._renderer)
|
|
|
|
def create_agent_factory_from_fixture(self, fixture_name: str) -> None:
|
|
base = Path(__file__).resolve().parent.parent.parent / "tests" / "fixtures"
|
|
path = base / fixture_name
|
|
with open(path) as f:
|
|
config = yaml.safe_load(f)
|
|
self._renderer = TemplateRenderer(TemplateEngine.SIMPLE)
|
|
self._factory = AgentFactory(config, self._renderer)
|
|
|
|
def factory_registers_tool_agent(self) -> None:
|
|
self._factory.register_agent_type("tool", ToolAgent)
|
|
agent_types = self._factory.get_agent_types()
|
|
if "tool" not in agent_types:
|
|
raise AssertionError(f"Tool not registered: {agent_types}")
|
|
|
|
def factory_creates_agent(self, agent_name: str) -> None:
|
|
agent = self._factory.create_agent(agent_name)
|
|
if not isinstance(agent, Agent):
|
|
raise AssertionError(f"Not an Agent: {type(agent)}")
|
|
|
|
def factory_get_metadata_has_key(self, agent_name: str, key: str) -> None:
|
|
meta = self._factory.get_agent_metadata(agent_name)
|
|
if key not in meta:
|
|
raise AssertionError(f"Metadata missing key {key!r}: {meta}")
|
|
|
|
def factory_create_all_agents_count(self, expected: str) -> None:
|
|
self._factory.register_agent_type("tool", ToolAgent)
|
|
agents = self._factory.create_agents_from_config()
|
|
if len(agents) != int(expected):
|
|
raise AssertionError(f"Expected {expected} agents, got {len(agents)}")
|
|
|
|
def factory_invalid_config_should_raise(self, yaml_text: str) -> None:
|
|
config = yaml.safe_load(yaml_text)
|
|
self._renderer = TemplateRenderer(TemplateEngine.SIMPLE)
|
|
try:
|
|
factory = AgentFactory(config, self._renderer)
|
|
except Exception:
|
|
return
|
|
try:
|
|
factory.create_agent("nonexistent_agent_name")
|
|
except Exception:
|
|
return
|
|
raise AssertionError("Expected exception for invalid config")
|
|
|
|
# ── application ───────────────────────────────────────────────────
|
|
|
|
def create_app_with_fixture(self, fixture_name: str) -> None:
|
|
base = Path(__file__).resolve().parent.parent.parent / "tests" / "fixtures"
|
|
path = base / fixture_name
|
|
self._app = ReactiveCleverAgentsApp(config_files=[path], verbose=0)
|
|
|
|
def app_loads_without_error(self) -> None:
|
|
if self._app is None:
|
|
raise AssertionError("App is None")
|
|
|
|
def app_has_agents(self) -> None:
|
|
if not self._app.agents:
|
|
raise AssertionError("App has no agents")
|
|
|
|
def app_can_dispose(self) -> None:
|
|
async def _dispose():
|
|
await self._app.dispose()
|
|
|
|
try:
|
|
asyncio.get_event_loop().run_until_complete(_dispose())
|
|
except RuntimeError:
|
|
asyncio.run(_dispose())
|
|
|
|
def app_can_visualize(self) -> None:
|
|
result = self._app.visualize_network("mermaid")
|
|
if not result:
|
|
raise AssertionError("Visualization returned empty result")
|
|
|
|
# ── stream router ─────────────────────────────────────────────────
|
|
|
|
def app_stream_router_has_streams(self) -> None:
|
|
router = getattr(self._app, "stream_router", None)
|
|
if router is None:
|
|
raise AssertionError("App has no stream_router")
|
|
if not router.streams:
|
|
raise AssertionError("Stream router has no streams")
|