From 38fe40553d919e2f1c43fe1b6a3d27d20fd0b984 Mon Sep 17 00:00:00 2001 From: CoreRasurae Date: Fri, 12 Jun 2026 20:15:33 +0000 Subject: [PATCH 1/3] test(robot): add end-to-end integration tests for email categorization actor graph 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 --- noxfile.py | 8 +- robot/CleverActorsLib.py | 1106 +---------------- robot/EmailGraphLib.py | 506 ++++++++ robot/business_email_agent.robot | 49 + robot/client_agent.robot | 41 + robot/component_orders_agent.robot | 52 + robot/email_categorization_graph.robot | 134 ++ robot/email_graph_components.robot | 100 ++ robot/email_graph_merge_configs.robot | 47 + robot/email_graph_namespaced.robot | 71 ++ robot/email_graph_negative_tests.robot | 52 + robot/lib/__init__.py | 1 + robot/lib/app_lib.py | 274 ++++ robot/lib/config_lib.py | 239 ++++ robot/lib/registry_lib.py | 602 +++++++++ .../personal_email_summarization_agent.robot | 41 + robot/resources/email_testdata.resource | 14 + robot/supplier_agent.robot | 41 + .../components/business_email.yaml | 17 + .../email_graph/components/client.yaml | 19 + .../components/component_orders.yaml | 19 + .../components/email_categorizer.yaml | 21 + .../email_graph/components/graph_routes.yaml | 34 + .../components/personal_summarizer.yaml | 18 + .../email_graph/components/supplier.yaml | 19 + tests/fixtures/email_graph/local_graph.yaml | 168 +++ tests/fixtures/email_graph/mixed_graph.yaml | 135 ++ .../namespaced/business_email.yaml | 17 + .../email_graph/namespaced/client.yaml | 19 + .../namespaced/component_orders.yaml | 19 + .../namespaced/email_categorizer.yaml | 21 + .../email_graph/namespaced/email_graph.yaml | 40 + .../namespaced/personal_summarizer.yaml | 18 + .../email_graph/namespaced/supplier.yaml | 19 + tests/fixtures/email_graph/remote_graph.yaml | 150 +++ 35 files changed, 3046 insertions(+), 1085 deletions(-) create mode 100644 robot/EmailGraphLib.py create mode 100644 robot/business_email_agent.robot create mode 100644 robot/client_agent.robot create mode 100644 robot/component_orders_agent.robot create mode 100644 robot/email_categorization_graph.robot create mode 100644 robot/email_graph_components.robot create mode 100644 robot/email_graph_merge_configs.robot create mode 100644 robot/email_graph_namespaced.robot create mode 100644 robot/email_graph_negative_tests.robot create mode 100644 robot/lib/__init__.py create mode 100644 robot/lib/app_lib.py create mode 100644 robot/lib/config_lib.py create mode 100644 robot/lib/registry_lib.py create mode 100644 robot/personal_email_summarization_agent.robot create mode 100644 robot/resources/email_testdata.resource create mode 100644 robot/supplier_agent.robot create mode 100644 tests/fixtures/email_graph/components/business_email.yaml create mode 100644 tests/fixtures/email_graph/components/client.yaml create mode 100644 tests/fixtures/email_graph/components/component_orders.yaml create mode 100644 tests/fixtures/email_graph/components/email_categorizer.yaml create mode 100644 tests/fixtures/email_graph/components/graph_routes.yaml create mode 100644 tests/fixtures/email_graph/components/personal_summarizer.yaml create mode 100644 tests/fixtures/email_graph/components/supplier.yaml create mode 100644 tests/fixtures/email_graph/local_graph.yaml create mode 100644 tests/fixtures/email_graph/mixed_graph.yaml create mode 100644 tests/fixtures/email_graph/namespaced/business_email.yaml create mode 100644 tests/fixtures/email_graph/namespaced/client.yaml create mode 100644 tests/fixtures/email_graph/namespaced/component_orders.yaml create mode 100644 tests/fixtures/email_graph/namespaced/email_categorizer.yaml create mode 100644 tests/fixtures/email_graph/namespaced/email_graph.yaml create mode 100644 tests/fixtures/email_graph/namespaced/personal_summarizer.yaml create mode 100644 tests/fixtures/email_graph/namespaced/supplier.yaml create mode 100644 tests/fixtures/email_graph/remote_graph.yaml diff --git a/noxfile.py b/noxfile.py index 8e79678..78db5b2 100644 --- a/noxfile.py +++ b/noxfile.py @@ -86,7 +86,9 @@ def integration_tests(session: nox.Session) -> None: """ session.install("-e", ".[tests]") session.env["NO_COLOR"] = "1" - session.env["PYTHONPATH"] = "src" + src_path = str(Path("src").resolve()) + robot_path = str(Path("robot").resolve()) + session.env["PYTHONPATH"] = src_path + os.pathsep + robot_path venv_bin = os.path.join(session.virtualenv.location, "bin") session.env["PATH"] = venv_bin + os.pathsep + os.environ.get("PATH", "") @@ -119,7 +121,9 @@ def e2e_tests(session: nox.Session) -> None: """ session.install("-e", ".[tests]") session.env["NO_COLOR"] = "1" - session.env["PYTHONPATH"] = "src" + src_path = str(Path("src").resolve()) + robot_path = str(Path("robot").resolve()) + session.env["PYTHONPATH"] = src_path + os.pathsep + robot_path venv_bin = os.path.join(session.virtualenv.location, "bin") session.env["PATH"] = venv_bin + os.pathsep + os.environ.get("PATH", "") diff --git a/robot/CleverActorsLib.py b/robot/CleverActorsLib.py index 91f31a9..b36f6c6 100644 --- a/robot/CleverActorsLib.py +++ b/robot/CleverActorsLib.py @@ -1,1096 +1,36 @@ -"""Robot Framework resource and keyword library for cleveractors integration tests. -This module is imported directly by Robot Framework test files. +"""Robot Framework keyword library for cleveractors integration tests. + +Backward-compatibility wrapper that imports and re-exports from per-module +keyword libraries under ``robot/lib/``. New test suites should import the +appropriate per-module library directly rather than this monolithic module. """ from __future__ import annotations -import asyncio -import json import os -import shutil -import tempfile -from pathlib import Path -from typing import Any +import sys -import yaml +sys.path.insert(0, os.path.dirname(__file__)) -import cleveractors -from cleveractors import ( - ContextManager, - ReactiveCleverAgentsApp, - __version__, - merge_configs, -) -from cleveractors.agents.factory import Agent, AgentFactory -from cleveractors.agents.tool import ToolAgent -from cleveractors.core.config import ConfigurationManager, SchemaValidator -from cleveractors.core.exceptions import ( - AgentCreationError, - CleverAgentsException, - ConfigurationError, -) -from cleveractors.core.progress import ProgressBarManager -from cleveractors.registry.cache import CacheFactory -from cleveractors.registry.exceptions import ( - AccessDeniedError, - AuthenticationRequiredError, - ConflictError, - InvalidPackageIdError, - InvalidPackageReferenceError, - PackageNotFoundError, - RegistryError, - RegistryNetworkError, - ValidationError, - VersionNotFoundError, - exception_for_status, -) -from cleveractors.registry.local_store import LocalPackageStore -from cleveractors.registry.reference_resolver import PackageContentResolver -from cleveractors.registry.resolver import ReferenceResolver -from cleveractors.registry.types import PackageReference -from cleveractors.templates.base import ( - ComponentReference, - InstantiationContext, - TemplateType, -) -from cleveractors.templates.enhanced_registry import EnhancedTemplateRegistry -from cleveractors.templates.registry import TemplateRegistry -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, -} +from lib.app_lib import AppLib +from lib.config_lib import ConfigLib +from lib.registry_lib import RegistryLib -class CleverActorsLib: # pragma: no cover - integration test library - """Keyword library for cleveractors Robot Framework integration tests. +class CleverActorsLib( # pragma: no cover - backward-compat wrapper + ConfigLib, + AppLib, + RegistryLib, +): + """Monolithic keyword library — use per-module libs for new tests. - Wraps the cleveractors public API as Robot Framework keywords. - Not a production path — used only by Robot Framework test suites. + This class exists so existing ``.robot`` files that import + ``CleverActorsLib.py`` continue to work without modification. + New suites should import from: + + - ``robot/lib/config_lib.py`` (ConfigLib) + - ``robot/lib/app_lib.py`` (AppLib) + - ``robot/lib/registry_lib.py`` (RegistryLib) """ ROBOT_LIBRARY_SCOPE = "TEST SUITE" - - # ── package metadata ────────────────────────────────────────────── - - def package_version(self) -> str: - return __version__ - - def version_matches(self, expected: str) -> None: - actual = self.package_version() - if actual != expected: - raise AssertionError( - f"Version mismatch: expected {expected!r}, got {actual!r}" - ) - - # ── configuration ───────────────────────────────────────────────── - - def create_config_manager(self) -> None: - self._cm = ConfigurationManager() - - def load_config_files(self, *paths: str) -> None: - self._cm.load_files([Path(p) for p in paths]) - - def config_value_equals(self, path: str, expected: str) -> None: - value = self._cm.get(path) - if str(value) != str(expected): - raise AssertionError(f"Config {path}: expected {expected!r}, got {value!r}") - - def config_path_exists(self, path: str) -> None: - value = self._cm.get(path) - if value is None: - raise AssertionError(f"Config path {path!r} not found or is None") - - def load_config_should_raise(self, *paths: str) -> None: - try: - self._cm = ConfigurationManager() - self._cm.load_files([Path(p) for p in paths]) - except Exception: - return - raise AssertionError("Expected an exception but none was raised") - - def interpolate_env_var(self, var_name: str, var_value: str) -> None: - os.environ[var_name] = var_value - data = {"key": f"${{{var_name}}}"} - result = self._cm.interpolate_env_vars(data) - if result.get("key") != var_value: - raise AssertionError( - f"Interpolation failed: expected {var_value!r}, got {result.get('key')!r}" - ) - - # ── merge_configs public API ────────────────────────────────────── - - def merge_configs_returns_empty_for_no_args(self) -> None: - result = merge_configs() - if result != {}: - raise AssertionError(f"Expected {{}}, got {result!r}") - - def merge_configs_two_dicts( - self, - base_key: str, - base_val: str, - overlay_key: str, - overlay_val: str, - ) -> None: - base = {base_key: base_val} - overlay = {overlay_key: overlay_val} - result = merge_configs(base, overlay) - if len(result) != 2: - raise AssertionError( - f"Expected result length 2, got {len(result)}: {result!r}" - ) - if result.get(base_key) != base_val: - raise AssertionError(f"Expected {base_key}={base_val!r}, got {result!r}") - if result.get(overlay_key) != overlay_val: - raise AssertionError( - f"Expected {overlay_key}={overlay_val!r}, got {result!r}" - ) - # Verify inputs not mutated - if base != {base_key: base_val}: - raise AssertionError(f"Base dict was mutated: {base!r}") - if overlay != {overlay_key: overlay_val}: - raise AssertionError(f"Overlay dict was mutated: {overlay!r}") - - def merge_configs_deep_merge(self) -> None: - base = {"config": {"a": 1, "b": 2}} - overlay = {"config": {"b": 99, "c": 3}} - result = merge_configs(base, overlay) - expected = {"config": {"a": 1, "b": 99, "c": 3}} - if result != expected: - raise AssertionError(f"Expected {expected!r}, got {result!r}") - - def merge_configs_sequence_append(self) -> None: - base = {"items": [1, 2]} - overlay = {"items": [3, 4]} - result = merge_configs(base, overlay) - if result != {"items": [1, 2, 3, 4]}: - raise AssertionError(f"Expected items=[1,2,3,4], got {result!r}") - - def merge_configs_through_config_pipeline( - self, base_key: str, overlay_val: str - ) -> None: - """End-to-end test: load YAML via ConfigurationManager, merge with overlay. - - Loads the test_config.yaml fixture through the canonical config-loading - pipeline (ConfigurationManager.load_files → to_dict), merges an overlay - dict via merge_configs, and asserts the result preserves base keys plus - the overlay injection. - """ - fixtures = Path(__file__).resolve().parent.parent / "tests" / "fixtures" - cm_base = ConfigurationManager() - cm_base.load_files([fixtures / "test_config.yaml"]) - base_dict = cm_base.to_dict() - - overlay: dict[str, Any] = {base_key: overlay_val, "meta": {"merged": True}} - result = merge_configs(base_dict, overlay) - - # Verify overlay keys are present - if result.get(base_key) != overlay_val: - raise AssertionError( - f"Overlay key {base_key!r} expected {overlay_val!r}," - f" got {result.get(base_key)!r}" - ) - if result.get("meta", {}).get("merged") is not True: - raise AssertionError( - f"Deep-merged overlay meta missing: {result.get('meta')!r}" - ) - # Verify base keys from fixture survived the merge - if "agents" not in result: - raise AssertionError( - f"Base fixture key 'agents' lost during merge: {sorted(result.keys())}" - ) - if "cleveragents" not in result: - raise AssertionError("Base fixture key 'cleveragents' lost during merge") - - def schema_validator_accepts_minimum_config(self) -> None: - config = { - "cleveragents": {"default_router": "main"}, - "agents": { - "echo": { - "type": "tool", - "config": {"tools": ["echo"]}, - } - }, - "routes": { - "main": { - "type": "stream", - "operators": [{"type": "map", "params": {"agent": "echo"}}], - "publications": ["__output__"], - } - }, - } - sv = SchemaValidator() - sv.validate(config) - - def schema_validator_rejects(self, yaml_text: str) -> None: - sv = SchemaValidator() - config = yaml.safe_load(yaml_text) - try: - sv.validate(config) - except Exception: - return - raise AssertionError( - "Expected schema validation to reject config, but it passed" - ) - - # ── templates ───────────────────────────────────────────────────── - - def create_template_renderer(self, engine: str = "SIMPLE") -> None: - eng = TemplateEngine[engine.upper()] - self._renderer = TemplateRenderer(eng) - - def template_engine_is_string(self, engine: str) -> None: - assert engine.lower() in ("simple", "jinja2", "mustache"), f"Unknown: {engine}" - - def register_template(self, name: str, content: str) -> None: - self._renderer.register_template(name, content) - - def render_template_equals( - self, name: str, context_json: str, expected: str - ) -> None: - ctx = json.loads(context_json) - result = self._renderer.render(name, ctx) - if str(result) != expected: - raise AssertionError( - f"Render mismatch: expected {expected!r}, got {result!r}" - ) - - def render_string_equals( - self, template: str, context_json: str, expected: str - ) -> None: - ctx = json.loads(context_json) - result = self._renderer.render_string(template, ctx) - if str(result) != expected: - raise AssertionError( - f"Render mismatch: expected {expected!r}, got {result!r}" - ) - - def render_string_contains( - self, template: str, context_json: str, expected: str - ) -> None: - ctx = json.loads(context_json) - result = self._renderer.render_string(template, ctx) - if expected not in str(result): - raise AssertionError(f"Expected {expected!r} in result, got {result!r}") - - def render_unknown_template_should_raise(self, name: str) -> None: - try: - self._renderer.render(name, {}) - except Exception: - return - raise AssertionError("Expected exception for unknown template, but none raised") - - def list_templates_count(self, expected_count: str) -> None: - templates = self._renderer.list_templates() - if len(templates) != int(expected_count): - raise AssertionError( - f"Expected {expected_count} templates, got {len(templates)}: {templates}" - ) - - def create_template_registry(self) -> None: - self._registry = TemplateRegistry() - - def create_enhanced_registry(self) -> None: - self._reg_enhanced = EnhancedTemplateRegistry() - - # ── 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() - # Re-create dir after delete for import to work - 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 / "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) - # Argument validation in AgentFactory.__init__ rejects non-dict config - # immediately (fail-fast). If that doesn't raise, verify create_agent - # with a missing name also raises. - 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 / "tests" / "fixtures" - path = base / fixture_name - self._app = ReactiveCleverAgentsApp(config_files=[path], verbose=0) - - def app_loads_without_error(self) -> None: - # Already loaded in constructor - 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") - - # ── template engine coverage (MUSTACHE) ─────────────────────────── - - def create_mustache_renderer(self) -> None: - self._mustache_renderer = TemplateRenderer(TemplateEngine.MUSTACHE) - - def mustache_render_equals(self, template: str, expected: str) -> None: - self._mustache_renderer.register_template("test", template) - result = self._mustache_renderer.render("test", {"name": "World"}) - if expected not in str(result): - raise AssertionError( - f"Mustache render: expected containing {expected!r}, got {result!r}" - ) - - def create_jinja2_renderer(self) -> None: - self._jinja_renderer = TemplateRenderer(TemplateEngine.JINJA2) - - def jinja2_render_contains(self, template: str, expected: str) -> None: - self._jinja_renderer.register_template("test", template) - result = self._jinja_renderer.render("test", {"name": "World"}) - if expected not in str(result): - raise AssertionError( - f"Jinja2 render: expected containing {expected!r}, got {result!r}" - ) - - # ── template type enumeration (issue #27) ────────────────────────── - - def template_type_count_equals(self, expected: str) -> None: - count = len(TemplateType) - if count != int(expected): - raise AssertionError( - f"Expected {expected} TemplateType values, got {count}" - ) - - def template_type_value_equals(self, name: str, expected: str) -> None: - value = TemplateType[name].value - if value != expected: - raise AssertionError( - f"TemplateType.{name}: expected {expected!r}, got {value!r}" - ) - - def template_type_has_member(self, name: str) -> None: - if name not in TemplateType.__members__: - raise AssertionError( - f"TemplateType missing member {name!r}: {list(TemplateType.__members__)}" - ) - - # ── component reference with package_ref (issue #27) ─────────────── - - def create_component_reference( - self, ref_type: str, ref_name: str, ref_params_json: str = "{}" - ) -> None: - params = json.loads(ref_params_json) - self._comp_ref = ComponentReference( - ref_type=ref_type, ref_name=ref_name, ref_params=params - ) - - def component_ref_field_equals(self, field: str, expected: str) -> None: - value = getattr(self._comp_ref, field) - if str(value) != expected: - raise AssertionError( - f"ComponentReference.{field}: expected {expected!r}, got {value!r}" - ) - - def component_ref_package_ref_is_none(self) -> None: - if self._comp_ref.package_ref is not None: - raise AssertionError( - f"Expected package_ref to be None, got {self._comp_ref.package_ref}" - ) - - def set_component_ref_package_ref(self, ref_string: str) -> None: - self._comp_ref.package_ref = PackageReference.from_string(ref_string) - - def component_ref_package_ref_has_attr(self, attr: str, expected: str) -> None: - pr = self._comp_ref.package_ref - if pr is None: - raise AssertionError("package_ref is None") - value = getattr(pr, attr) - if str(value) != expected: - raise AssertionError( - f"package_ref.{attr}: expected {expected!r}, got {value!r}" - ) - - # ── template registry with 8 types (issue #27) ───────────────────── - - def register_template_by_type_name( - self, type_name: str, template_name: str, definition_json: str - ) -> None: - ttype = TemplateType[type_name] - definition = json.loads(definition_json) - self._registry.register_template(ttype, template_name, definition) - - def template_registry_has_template( - self, type_name: str, template_name: str - ) -> None: - ttype = TemplateType[type_name] - if not self._registry.has_template(ttype, template_name): - raise AssertionError(f"Registry does not have {type_name}/{template_name}") - - def template_registry_count_for_type(self, type_name: str, expected: str) -> None: - ttype = TemplateType[type_name] - count = len(self._registry.templates[ttype]) - if count != int(expected): - raise AssertionError( - f"Expected {expected} templates for {type_name}, got {count}" - ) - - def template_registry_list_all_count(self, expected: str) -> None: - result = self._registry.list_templates() - if len(result) != int(expected): - raise AssertionError( - f"Expected {expected} template types in list, got {len(result)}: {sorted(result.keys())}" - ) - - # ── enhanced registry with 8 types (issue #27) ───────────────────── - - def register_enhanced_template_by_type_name( - self, type_name: str, template_name: str, definition_json: str - ) -> None: - ttype = TemplateType[type_name] - definition = json.loads(definition_json) - self._reg_enhanced.register_template_dict(ttype, template_name, definition) - - def enhanced_registry_has_template( - self, type_name: str, template_name: str - ) -> None: - ttype = TemplateType[type_name] - if not self._reg_enhanced.has_template(ttype, template_name): - raise AssertionError( - f"Enhanced registry does not have {type_name}/{template_name}" - ) - - # ── reference resolver (issue #27) ───────────────────────────────── - - def create_reference_resolver(self) -> None: - - self._tmpdir = tempfile.TemporaryDirectory() - base_dir = Path(self._tmpdir.name) - (base_dir / "test").mkdir(parents=True, exist_ok=True) - (base_dir / "test" / "file.yaml").write_text( - "name: test-package\ndescription: test\n", encoding="utf-8" - ) - (base_dir / "cache_test.yaml").write_text( - "name: cache-test\ndescription: test\n", encoding="utf-8" - ) - (base_dir / "to_clear.yaml").write_text( - "name: to-clear\ndescription: test\n", encoding="utf-8" - ) - self._local_store = LocalPackageStore(base_dir) - self._ref_resolver = PackageContentResolver(local_store=self._local_store) - - def resolve_reference(self, ref_string: str) -> None: - pr = PackageReference.from_string(ref_string) - self._resolved = self._ref_resolver.resolve(pr) - - def resolved_has_key(self, key: str) -> None: - if key not in self._resolved: - raise AssertionError( - f"Resolved result missing key {key!r}: {sorted(self._resolved.keys())}" - ) - - def resolved_key_value_equals(self, key: str, expected: str) -> None: - value = str(self._resolved.get(key)) - if value != expected: - raise AssertionError( - f"Resolved[{key}]: expected {expected!r}, got {value!r}" - ) - - def resolve_should_return_none(self, ref_string: str) -> None: - try: - pr = PackageReference.from_string(ref_string) - result = self._ref_resolver.resolve(pr) - if result is not None: - raise AssertionError( - f"Expected None for {ref_string!r}, got {result!r}" - ) - except (ValueError, Exception): - return - - def resolver_cache_contains(self, ref_string: str) -> None: - if ref_string not in self._ref_resolver.cache: - raise AssertionError( - f"Cache missing {ref_string!r}: {sorted(self._ref_resolver.cache.keys())}" - ) - - def resolver_client_pool_has_server(self, server: str) -> None: - if server not in self._ref_resolver.clients: - raise AssertionError( - f"Client pool missing {server!r}: {sorted(self._ref_resolver.clients.keys())}" - ) - - def create_ref_resolver_client_for_server(self, server: str) -> None: - asyncio.run(self._ref_resolver._get_client(server)) - - def resolver_clear_cache(self) -> None: - self._ref_resolver.clear_cache() - - def resolver_cache_is_empty(self) -> None: - if len(self._ref_resolver.cache) != 0: - raise AssertionError( - f"Expected empty cache, got {len(self._ref_resolver.cache)} entries" - ) - - # ── cache factory + transparent caching (issue #28) ─────────────── - - def create_reference_resolver_with_cache_factory( - self, max_size: str = "256", ttl: str = "300.0" - ) -> None: - factory = CacheFactory( - max_size=int(max_size), ttl=float(ttl), validate_content=False - ) - self._ref_resolver = PackageContentResolver(cache_factory=factory) - - def set_resolver_server_alias(self, alias: str, resolved_url: str) -> None: - self._ref_resolver.set_server_alias(alias, resolved_url) - - def resolve_reference_and_collect_stats( - self, ref_string: str, package_type: str = "actor" - ) -> None: - pr = PackageReference.from_string(ref_string) - self._resolved = self._ref_resolver.resolve(pr, package_type=package_type) - - def total_stats_hits_greater_than(self, expected: str) -> None: - actual = self._ref_resolver.total_stats.hits - if actual <= int(expected): - raise AssertionError( - f"Expected total_stats.hits > {expected}, got {actual}" - ) - - def resolver_clear_all_caches(self) -> None: - self._ref_resolver.clear_cache() - - def resolver_close_all_resources(self) -> None: - asyncio.run(self._ref_resolver.close_all()) - if len(self._ref_resolver.clients) != 0: - raise AssertionError( - f"Expected 0 clients after close_all, " - f"got {len(self._ref_resolver.clients)}" - ) - if len(self._ref_resolver._content_caches) != 0: - raise AssertionError( - f"Expected 0 content caches after close_all, " - f"got {len(self._ref_resolver._content_caches)}" - ) - - def resolver_resolution_cache_is_empty(self) -> None: - if len(self._ref_resolver.cache) != 0: - raise AssertionError( - f"Expected empty resolution cache, " - f"got {len(self._ref_resolver.cache)} entries" - ) - - def resolver_total_stats_misses_equals(self, expected: str) -> None: - actual = self._ref_resolver.total_stats.misses - if actual != int(expected): - raise AssertionError(f"Expected {expected} misses, got {actual}") - - # ── instantiation context with registry awareness (issue #27) ────── - - def create_context_with_resolver(self) -> None: - self._ref_resolver = PackageContentResolver() - self._reg_ctx = InstantiationContext(reference_resolver=self._ref_resolver) - - def context_has_component_type(self, comp_type: str) -> None: - plural = f"{comp_type}s" - if plural not in self._reg_ctx.components: - raise AssertionError( - f"Context missing component type {plural!r}: {sorted(self._reg_ctx.components.keys())}" - ) - - def context_component_count(self, comp_type: str, expected: str) -> None: - plural = f"{comp_type}s" - count = len(self._reg_ctx.components.get(plural, {})) - if count != int(expected): - raise AssertionError( - f"Expected {expected} components for {plural}, got {count}" - ) - - # ── _original_reference propagation (issue #27) ──────────────────── - - def resolve_local_ref_with_original_reference(self, ref_string: str) -> None: - - pr = PackageReference.from_string(ref_string) - tmpdir = tempfile.TemporaryDirectory() - base_dir = Path(tmpdir.name) - (base_dir / "my").mkdir(parents=True, exist_ok=True) - (base_dir / "my" / "package.yaml").write_text( - "name: test\ndescription: test package\n", encoding="utf-8" - ) - local_store = LocalPackageStore(base_dir) - self._ref_resolver = PackageContentResolver(local_store=local_store) - result = self._ref_resolver.resolve(pr) - self._resolved = result - - def resolved_original_reference_equals(self, expected: str) -> None: - value = self._resolved.get("_original_reference") - if str(value) != expected: - raise AssertionError( - f"_original_reference: expected {expected!r}, got {value!r}" - ) - - # ── template registry ref detection (issue #27) ──────────────────── - - def instantiate_config_with_ref_and_resolver( - self, template_ref: str, params_json: str = "{}" - ) -> None: - params = json.loads(params_json) - self._ref_resolver = PackageContentResolver() - ctx = InstantiationContext(reference_resolver=self._ref_resolver) - config = {"template": template_ref, "params": params} - self._reg_instantiated = self._registry.instantiate_from_config(config, ctx) - - def instantiated_has_key(self, key: str) -> None: - if key not in self._reg_instantiated: - raise AssertionError( - f"Instantiated result missing key {key!r}: {sorted(self._reg_instantiated.keys())}" - ) - - def instantiated_key_value_equals(self, key: str, expected: str) -> None: - value = str(self._reg_instantiated.get(key)) - if value != expected: - raise AssertionError( - f"Instantiated[{key}]: expected {expected!r}, got {value!r}" - ) - - # ── LocalPackageStore integration (issue #46) ─────────────────────── - - def create_local_package_store_with_test_package(self) -> None: - - self._lps_tmpdir = tempfile.TemporaryDirectory() - base_dir = Path(self._lps_tmpdir.name) - (base_dir / "test").mkdir(parents=True, exist_ok=True) - (base_dir / "test" / "package.yaml").write_text( - "name: test-package\ndescription: A test package\n", encoding="utf-8" - ) - (base_dir / "test" / "file.yaml").write_text( - "name: test-file\ndescription: A test file\n", encoding="utf-8" - ) - self._local_store = LocalPackageStore(base_dir) - - def create_lenient_local_package_store_with_test_package(self) -> None: - self._lps_tmpdir = tempfile.TemporaryDirectory() - base_dir = Path(self._lps_tmpdir.name) - (base_dir / "test").mkdir(parents=True, exist_ok=True) - (base_dir / "test" / "package.yaml").write_text( - "name: test-package\ndescription: A test package\n", encoding="utf-8" - ) - (base_dir / "test" / "file.yaml").write_text( - "name: test-file\ndescription: A test file\n", encoding="utf-8" - ) - self._local_store = LocalPackageStore(base_dir, fail_on_unresolvable_refs=False) - - def write_local_package_file(self, relative_path: str, *content_parts: str) -> None: - - content = "\n".join(p.rstrip("\n") for p in content_parts) - base_dir = Path(self._lps_tmpdir.name) - file_path = base_dir / relative_path - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(content, encoding="utf-8") - - def resolve_local_package(self, relative_path: str) -> None: - self._local_pkg = self._local_store.resolve_package(relative_path) - - def resolve_local_package_should_raise( - self, relative_path: str, error_type: str, phrase: str = "" - ) -> None: - try: - self._local_store.resolve_package(relative_path) - raise AssertionError( - f"Expected {error_type} for {relative_path!r} but no error was raised" - ) - except Exception as exc: - if error_type not in type(exc).__name__: - raise AssertionError( - f"Expected {error_type}, got {type(exc).__name__}: {exc}" - ) from None - if phrase and phrase not in str(exc): - raise AssertionError( - f"Expected {phrase!r} in error message, got: {exc}" - ) from None - - def create_local_package_store_should_raise( - self, base_dir_path: str, error_type: str, phrase: str = "" - ) -> None: - try: - LocalPackageStore(base_dir_path) - raise AssertionError( - f"Expected {error_type} for base_dir={base_dir_path!r} but no error was raised" - ) - except Exception as exc: - if error_type not in type(exc).__name__: - raise AssertionError( - f"Expected {error_type}, got {type(exc).__name__}: {exc}" - ) from None - if phrase and phrase not in str(exc): - raise AssertionError( - f"Expected {phrase!r} in error message, got: {exc}" - ) from None - - def create_local_package_store_file_as_dir_should_raise( - self, error_type: str, phrase: str = "" - ) -> None: - tmpdir = tempfile.TemporaryDirectory() - self._cps_tmpdir = tmpdir - file_path = Path(tmpdir.name) / "a-file.txt" - file_path.write_text("not a directory") - try: - LocalPackageStore(file_path) - raise AssertionError( - f"Expected {error_type} for file-as-dir but no error was raised" - ) - except Exception as exc: - if error_type not in type(exc).__name__: - raise AssertionError( - f"Expected {error_type}, got {type(exc).__name__}: {exc}" - ) from None - if phrase and phrase not in str(exc): - raise AssertionError( - f"Expected {phrase!r} in error message, got: {exc}" - ) from None - - def create_local_package_store_empty_base_dir_should_raise( - self, error_type: str, phrase: str = "" - ) -> None: - try: - LocalPackageStore("") - raise AssertionError( - f"Expected {error_type} for empty base_dir but no error was raised" - ) - except Exception as exc: - if error_type not in type(exc).__name__: - raise AssertionError( - f"Expected {error_type}, got {type(exc).__name__}: {exc}" - ) from None - if phrase and phrase not in str(exc): - raise AssertionError( - f"Expected {phrase!r} in error message, got: {exc}" - ) from None - - def resolved_local_package_has_package_id(self) -> None: - if self._local_pkg is None or self._local_pkg.package_id is None: - raise AssertionError("LocalPackage has no package_id") - if not self._local_pkg.package_id.id_string.startswith("pkg_"): - raise AssertionError( - f"PackageId does not start with pkg_: {self._local_pkg.package_id.id_string}" - ) - - def resolved_local_package_has_content_key(self, key: str) -> None: - if key not in self._local_pkg.content: - raise AssertionError( - f"Content missing key {key!r}: {sorted(self._local_pkg.content.keys())}" - ) - - def resolved_local_package_content_equals(self, key: str, expected: str) -> None: - value = str(self._local_pkg.content.get(key)) - if value != expected: - raise AssertionError( - f"Content[{key}]: expected {expected!r}, got {value!r}" - ) - - def resolved_local_package_has_file_path(self) -> None: - if self._local_pkg.file_path is None: - raise AssertionError("LocalPackage has no file_path") - if not self._local_pkg.file_path.is_file(): - raise AssertionError( - f"file_path does not exist: {self._local_pkg.file_path}" - ) - - def resolved_local_package_original_reference_equals(self, expected: str) -> None: - if str(self._local_pkg.original_reference) != expected: - raise AssertionError( - f"original_reference: expected {expected!r}, " - f"got {self._local_pkg.original_reference!r}" - ) - - def resolved_local_package_type_equals(self, type_code: str) -> None: - actual = self._local_pkg.package_id.package_type.value - if actual != type_code: - raise AssertionError(f"PackageType: expected {type_code!r}, got {actual!r}") - - def get_resolved_local_package_id_string(self) -> str: - return self._local_pkg.package_id.id_string - - def get_resolved_local_package_content(self): - return self._local_pkg.content - - # ── PackageContentResolver + ReferenceResolver for local (issue #46) ─ - - def create_package_content_resolver_without_store(self) -> None: - - self._ref_resolver = PackageContentResolver() - - def create_package_content_resolver_with_store(self) -> None: - - self._lps_tmpdir = tempfile.TemporaryDirectory() - base_dir = Path(self._lps_tmpdir.name) - (base_dir / "test").mkdir(parents=True, exist_ok=True) - (base_dir / "test" / "file.yaml").write_text( - "name: test-file\ndescription: A test file\n", encoding="utf-8" - ) - local_store = LocalPackageStore(base_dir) - self._ref_resolver = PackageContentResolver(local_store=local_store) - - def resolve_reference_should_raise( - self, ref_string: str, error_type: str, phrase: str - ) -> None: - try: - pr = PackageReference.from_string(ref_string) - self._ref_resolver.resolve(pr) - raise AssertionError( - f"Expected {error_type} for {ref_string!r} but no error was raised" - ) - except Exception as exc: - if error_type not in type(exc).__name__: - raise AssertionError( - f"Expected {error_type}, got {type(exc).__name__}: {exc}" - ) from None - if phrase not in str(exc): - raise AssertionError( - f"Expected {phrase!r} in error message, got: {exc}" - ) from None - - def create_reference_resolver_without_store(self) -> None: - self._ref_resolver_spec = ReferenceResolver() - - def create_reference_resolver_with_store(self) -> None: - - self._rrs_tmpdir = tempfile.TemporaryDirectory() - base_dir = Path(self._rrs_tmpdir.name) - (base_dir / "test").mkdir(parents=True, exist_ok=True) - (base_dir / "test" / "file.yaml").write_text( - "name: test-file\ndescription: test\n", encoding="utf-8" - ) - local_store = LocalPackageStore(base_dir) - self._ref_resolver_spec = ReferenceResolver(local_store=local_store) - - def resolve_ref_string_should_raise( - self, ref_string: str, error_type: str, phrase: str - ) -> None: - - async def _resolve(): - return await self._ref_resolver_spec.resolve(ref_string) - - try: - asyncio.run(_resolve()) - raise AssertionError( - f"Expected {error_type} for {ref_string!r} but no error was raised" - ) - except InvalidPackageReferenceError as exc: - if error_type not in type(exc).__name__: - raise AssertionError( - f"Expected {error_type}, got {type(exc).__name__}: {exc}" - ) from exc - if phrase not in str(exc): - raise AssertionError( - f"Expected {phrase!r} in error message, got: {exc}" - ) from None - - def resolve_ref_string(self, ref_string: str) -> None: - - async def _resolve(): - return await self._ref_resolver_spec.resolve(ref_string) - - self._ref_resolved_id = asyncio.run(_resolve()) - - def resolved_package_id_should_not_be_none(self) -> None: - if self._ref_resolved_id is None: - raise AssertionError("Resolved PackageId is None") - if not self._ref_resolved_id.id_string.startswith("pkg_"): - raise AssertionError( - f"PackageId does not start with pkg_: {self._ref_resolved_id.id_string}" - ) diff --git a/robot/EmailGraphLib.py b/robot/EmailGraphLib.py new file mode 100644 index 0000000..9d5fd72 --- /dev/null +++ b/robot/EmailGraphLib.py @@ -0,0 +1,506 @@ +"""Robot Framework keyword library for email categorization actor graph tests. + +Provides keywords for loading YAML configs, creating executors, +executing graph actors, and validating results against the email +categorization use case. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +import yaml + +from cleveractors.config_utils import merge_configs +from cleveractors.core.exceptions import ( + AgentCreationError, + ConfigurationError, + ExecutionError, +) +from cleveractors.result import ActorResult +from cleveractors.runtime import create_executor + +_FIXTURES = Path(__file__).resolve().parent.parent / "tests" / "fixtures" + + +class EmailGraphLib: + """Keyword library for email categorization graph integration tests. + + Wraps the cleveractors runtime API (create_executor, execute) with + keywords specific to the email actor graph scenario. + """ + + ROBOT_LIBRARY_SCOPE = "TEST SUITE" + + def __init__(self) -> None: + self._executor: Any = None + self._last_result: ActorResult | None = None + + # -- config loading ------------------------------------------------------- + + def load_email_graph_config(self, graph_name: str) -> Any: + """Load an email graph YAML config fixture by name. + + Valid names: ``local_graph``, ``remote_graph``, ``mixed_graph``. + Returns the parsed YAML as a dict. + """ + path = _FIXTURES / "email_graph" / f"{graph_name}.yaml" + with open(path, encoding="utf-8") as fh: + return yaml.safe_load(fh) + + def load_config_file(self, config_path: str) -> Any: + """Load a YAML config file from ``tests/fixtures/``.""" + path = _FIXTURES / config_path + with open(path, encoding="utf-8") as fh: + return yaml.safe_load(fh) + + # -- executor creation ---------------------------------------------------- + + def create_executor_for_email_graph(self, graph_name: str) -> None: + """Create an Executor from the named email graph config.""" + config = self.load_email_graph_config(graph_name) + self._executor = create_executor( + config_dict=config, + credentials=None, + limits={}, + pricing={}, + ) + + def create_executor_from_config_file(self, config_path: str) -> None: + """Create an Executor from a fixture config file.""" + config = self.load_config_file(config_path) + self._executor = create_executor( + config_dict=config, + credentials=None, + limits={}, + pricing={}, + ) + + def create_executor_from_merged_configs( + self, graph_name: str, overlay_path: str + ) -> None: + """Create an Executor from a merged graph + overlay config.""" + graph = self.load_email_graph_config(graph_name) + overlay = self.load_config_file(overlay_path) + merged = merge_configs(graph, overlay) + self._executor = create_executor( + config_dict=merged, + credentials=None, + limits={}, + pricing={}, + ) + + def create_executor_with_limits( + self, graph_name: str, max_depth: int | None = None + ) -> None: + """Create an Executor with execution limits.""" + config = self.load_email_graph_config(graph_name) + limits: dict[str, Any] = {} + if max_depth is not None: + limits["max_depth"] = max_depth + self._executor = create_executor( + config_dict=config, + credentials=None, + limits=limits, + pricing={}, + ) + + def create_executor_from_dict(self, config_dict_str: str) -> None: + """Create an Executor from a JSON string dict.""" + config = json.loads(config_dict_str) + self._executor = create_executor( + config_dict=config, + credentials=None, + limits={}, + pricing={}, + ) + + def create_executor_from_config_dict(self, config_dict: dict[str, Any]) -> None: + """Create an Executor directly from a Python dict.""" + self._executor = create_executor( + config_dict=config_dict, + credentials=None, + limits={}, + pricing={}, + ) + + # -- execution ------------------------------------------------------------ + + async def _execute_async(self, message: str) -> ActorResult: + result = await self._executor.execute(message) + self._last_result = result + return result + + def execute_email_graph(self, email_json: str) -> None: + """Execute the email graph with an email JSON input.""" + self._last_result = asyncio.run(self._execute_async(email_json)) + + async def _execute_stream_async(self, message: str) -> str: + chunks: list[str] = [] + async for token in self._executor.execute_stream(message): + chunks.append(token) + return "".join(chunks) + + def execute_email_graph_stream(self, email_json: str) -> None: + """Execute via streaming path and collect tokens.""" + response = asyncio.run(self._execute_stream_async(email_json)) + self._last_result = self._executor.last_result + + # -- result validation ---------------------------------------------------- + + def result_is_valid_actor_result(self) -> None: + """Assert last_result is an ActorResult instance.""" + assert isinstance(self._last_result, ActorResult), ( + f"Expected ActorResult, got {type(self._last_result)}" + ) + + def result_response_contains(self, text: str) -> None: + """Assert the response string contains the given text.""" + assert self._last_result is not None + assert text in self._last_result.response, ( + f"Expected '{text}' in response, got: {self._last_result.response[:200]}" + ) + + def result_response_contains_json_key(self, key: str) -> None: + """Assert the response contains JSON with the given top-level key.""" + assert self._last_result is not None + try: + parsed = json.loads(self._last_result.response) + except json.JSONDecodeError: + raise AssertionError( + f"Response is not valid JSON: {self._last_result.response[:200]}" + ) + assert key in parsed, ( + f"Key '{key}' not found in response JSON. Keys: {list(parsed.keys())}" + ) + + def result_json_key_equals(self, key: str, expected: str) -> None: + """Assert a JSON key in the response has an expected value.""" + assert self._last_result is not None + parsed = json.loads(self._last_result.response) + actual = parsed.get(key) + assert str(actual) == expected, ( + f"Key '{key}': expected '{expected}', got '{actual}'" + ) + + def result_has_nodes(self) -> None: + """Assert the result has at least one NodeUsage entry.""" + assert self._last_result is not None + assert len(self._last_result.nodes) > 0, "Expected at least one node in result" + + def result_node_count_equals(self, count: int) -> None: + """Assert result has exactly count NodeUsage entries.""" + assert self._last_result is not None + actual = len(self._last_result.nodes) + assert actual == count, f"Expected {count} nodes, got {actual}" + + def result_has_state(self) -> None: + """Assert the result has a non-None state field.""" + assert self._last_result is not None + assert self._last_result.state is not None, "Expected non-None state" + + def result_state_contains_key(self, key: str) -> None: + """Assert the result state dict contains the given key.""" + assert self._last_result is not None + assert self._last_result.state is not None + assert key in self._last_result.state, ( + f"Key '{key}' not in state: {list(self._last_result.state.keys())}" + ) + + # -- error validation (negative tests) ------------------------------------ + + def executor_creation_should_fail(self, graph_name: str) -> None: + """Assert creating an executor fails with ConfigurationError.""" + try: + self.create_executor_for_email_graph(graph_name) + raise AssertionError("Expected ConfigurationError but no exception raised") + except (ConfigurationError, AgentCreationError): + pass + + def execution_should_raise(self, email_json: str, error_type: str) -> None: + """Assert executing with the given input raises a specific error.""" + try: + asyncio.run(self._execute_async(email_json)) + raise AssertionError(f"Expected {error_type} but no exception raised") + except (ConfigurationError, AgentCreationError, ExecutionError) as e: + actual_type = type(e).__name__ + if error_type not in actual_type: + raise AssertionError( + f"Expected error type containing '{error_type}', got '{actual_type}: {e}'" + ) + + def executor_from_dict_should_fail(self, config_dict_str: str) -> None: + """Assert creating executor from dict raises ConfigurationError.""" + try: + self.create_executor_from_dict(config_dict_str) + raise AssertionError("Expected ConfigurationError but no exception raised") + except (ConfigurationError, AgentCreationError): + pass + + def executor_from_python_dict_should_fail( + self, config_dict: dict[str, Any] + ) -> None: + """Assert creating executor from a Python dict raises ConfigurationError.""" + try: + self.create_executor_from_config_dict(config_dict) + raise AssertionError("Expected ConfigurationError but no exception raised") + except (ConfigurationError, AgentCreationError): + pass + + def executor_non_dict_should_fail(self, non_dict_value: str) -> None: + """Assert non-dict value raises ConfigurationError.""" + try: + create_executor( + config_dict=non_dict_value, + credentials=None, + limits={}, + pricing={}, + ) + raise AssertionError("Expected ConfigurationError but no exception raised") + except (ConfigurationError, AgentCreationError): + pass + + # -- negative test configs ------------------------------------------------ + + def load_invalid_graph_no_routes(self) -> dict[str, Any]: + """Return a config dict missing the routes key.""" + return {"actors": {}} + + def load_invalid_graph_no_actors(self) -> dict[str, Any]: + """Return a config dict missing the actors key.""" + return {"routes": {"main": {"nodes": {}, "edges": []}}} + + def load_invalid_graph_duplicate_nodes(self) -> dict[str, Any]: + """Return a graph config with duplicate node IDs.""" + return { + "routes": { + "main": { + "entry_point": "start", + "nodes": { + "n1": {"id": "start", "type": "agent", "agent": "echo"}, + "n2": {"id": "start", "type": "agent", "agent": "echo"}, + }, + "edges": [], + } + }, + "actors": {}, + } + + def load_invalid_graph_missing_agent_ref(self) -> dict[str, Any]: + """Return a graph config referencing a nonexistent agent.""" + return { + "routes": { + "main": { + "entry_point": "start", + "nodes": { + "n1": { + "id": "start", + "type": "agent", + "agent": "nonexistent_agent", + } + }, + "edges": [], + } + }, + "actors": {"echo": {"type": "tool", "config": {}}}, + } + + def load_invalid_config_nondict(self) -> str: + """Return a non-dict config for type validation.""" + return "not-a-dict" + + # -- merge_configs keywords ----------------------------------------------- + + def merge_email_graph_configs(self, graph_name: str, overlay_name: str) -> Any: + """Merge two email graph configs and return the result.""" + base = self.load_email_graph_config(graph_name) + overlay = self.load_email_graph_config(overlay_name) + return merge_configs(base, overlay) + + def merge_email_graph_configs_invalid(self, graph_name: str) -> Any: + """Attempt merge_configs with one valid and one non-existent name. + + This tests that passing an invalid (non-existant) file to the + file loader raises FileNotFoundError before merge_configs is called.""" + base = self.load_email_graph_config(graph_name) + return merge_configs(base, "not_a_valid_dict") + + def merged_config_has_nested_key(self, merged: Any, *keys: str) -> None: + """Assert nested keys exist in a merged config dict.""" + current = merged + for key in keys: + assert isinstance(current, dict), ( + f"Expected dict at key path, got {type(current)}" + ) + assert key in current, ( + f"Key '{key}' not found. Available: {list(current.keys())[:10]}" + ) + current = current[key] + + def merged_config_key_count( + self, merged: Any, section: str, minimum: int + ) -> None: + """Assert a config section has at least minimum keys.""" + assert section in merged, f"Section '{section}' not in merged config" + actual = len(merged[section]) + assert actual >= minimum, ( + f"Expected >= {minimum} keys in '{section}', got {actual}" + ) + + # -- component-based graph assembly --------------------------------------- + + def load_all_email_components(self) -> Any: + """Load all 7 component YAML files and merge them into one config. + + Returns the fully assembled graph config ready for Executor. + """ + base = _FIXTURES / "email_graph" / "components" + component_files = [ + "email_categorizer.yaml", + "component_orders.yaml", + "personal_summarizer.yaml", + "business_email.yaml", + "supplier.yaml", + "client.yaml", + "graph_routes.yaml", + ] + configs = [] + for fname in component_files: + with open(base / fname, encoding="utf-8") as fh: + configs.append(yaml.safe_load(fh)) + result: dict[str, Any] = {} + for cfg in configs: + result = merge_configs(result, cfg) + return result + + def create_executor_from_components(self) -> None: + """Create an Executor from the assembled component graph.""" + config = self.load_all_email_components() + self._executor = create_executor( + config_dict=config, + credentials=None, + limits={}, + pricing={}, + ) + + def component_graph_actor_count_equals(self, expected: int) -> None: + """Assert the assembled component graph has exactly expected actors.""" + config = self.load_all_email_components() + actual = len(config.get("actors", {})) + assert actual == expected, ( + f"Expected {expected} actors, got {actual}" + ) + + # -- namespaced component graph with reference resolver ------------------- + + def assemble_namespaced_graph(self) -> None: + """Assemble the namespaced email graph by resolving all local: refs. + + Uses LocalPackageStore + ReferenceResolver to resolve each + ``agent_ref: local:...`` in the graph routes into fully inlined + actor configs, then creates an Executor from the assembled config. + """ + from cleveractors.registry.local_store import LocalPackageStore + from cleveractors.registry.resolver import ReferenceResolver + + base_dir = _FIXTURES / "email_graph" / "namespaced" + store = LocalPackageStore(base_dir) + + # Resolve each local: reference via the store + graph_path = base_dir / "email_graph.yaml" + with open(graph_path, encoding="utf-8") as fh: + graph_config = yaml.safe_load(fh) + + resolver = ReferenceResolver(local_store=store) + + actors: dict[str, Any] = {} + nodes = graph_config.get("routes", {}).get("main", {}).get("nodes", {}) + for _node_id, node_def in nodes.items(): + agent_ref = node_def.get("agent_ref", "") + if agent_ref.startswith("local:"): + ref_name = agent_ref[len("local:"):] + pkg = store.resolve_package(ref_name) + content = pkg.content + actor_name = content.get("name", ref_name) + actors[actor_name] = { + "type": content.get("type", "tool"), + "config": content.get("config", {}), + } + + graph_config["actors"] = actors + for node_def in graph_config.get("routes", {}).get("main", {}).get( + "nodes", {} + ).values(): + if "agent_ref" in node_def and node_def["agent_ref"].startswith("local:"): + ref_name = node_def["agent_ref"][len("local:"):] + pkg = store.resolve_package(ref_name) + node_def["agent"] = pkg.content.get("name", ref_name) + del node_def["agent_ref"] + + self._executor = create_executor( + config_dict=graph_config, + credentials=None, + limits={}, + pricing={}, + ) + + def namespaced_graph_actor_count_equals(self, expected: int) -> None: + """Assert the namespaced graph resolves to exactly expected actors.""" + base = _FIXTURES / "email_graph" / "namespaced" + with open(base / "email_graph.yaml", encoding="utf-8") as fh: + graph_config = yaml.safe_load(fh) + ref_count = 0 + nodes = graph_config.get("routes", {}).get("main", {}).get("nodes", {}) + for _nid, ndef in nodes.items(): + if ndef.get("agent_ref", "").startswith("local:"): + ref_count += 1 + assert ref_count == expected, ( + f"Expected {expected} agent_ref references in graph, got {ref_count}" + ) + + # -- agent factory keywords ----------------------------------------------- + + def create_agents_from_config(self, graph_name: str) -> int: + """Create all agents from the named graph config and return count.""" + from cleveractors.agents.factory import AgentFactory + from cleveractors.templates.renderer import TemplateRenderer + + config = self.load_email_graph_config(graph_name) + config.setdefault("agents", {}) + config["agents"].update(config.get("actors", {})) + renderer = TemplateRenderer() + factory = AgentFactory(config=config, template_renderer=renderer) + agents = factory.create_agents_from_config() + return len(agents) + + def factory_validates_configuration(self, graph_name: str) -> None: + """Assert AgentFactory.validate_configuration succeeds.""" + from cleveractors.agents.factory import AgentFactory + from cleveractors.templates.renderer import TemplateRenderer + + config = self.load_email_graph_config(graph_name) + config.setdefault("agents", {}) + config["agents"].update(config.get("actors", {})) + renderer = TemplateRenderer() + factory = AgentFactory(config=config, template_renderer=renderer) + factory.validate_configuration() + + def factory_agent_has_metadata_key( + self, graph_name: str, agent_name: str, key: str + ) -> None: + """Assert an agent's metadata contains a key.""" + from cleveractors.agents.factory import AgentFactory + from cleveractors.templates.renderer import TemplateRenderer + + config = self.load_email_graph_config(graph_name) + config.setdefault("agents", {}) + config["agents"].update(config.get("actors", {})) + renderer = TemplateRenderer() + factory = AgentFactory(config=config, template_renderer=renderer) + meta = factory.get_agent_metadata(agent_name) + assert key in meta, ( + f"Key '{key}' not in metadata. Keys: {list(meta.keys())}" + ) \ No newline at end of file diff --git a/robot/business_email_agent.robot b/robot/business_email_agent.robot new file mode 100644 index 0000000..c9ddb8f --- /dev/null +++ b/robot/business_email_agent.robot @@ -0,0 +1,49 @@ +*** Settings *** +Documentation Business email agent integration tests. +... Tests graph execution with business email inputs +... (meetings, urgency, strategy) across all config variants. +Library EmailGraphLib.py +Resource resources/email_testdata.resource + +*** Test Cases *** +Business Email Through Local Graph + [Documentation] Q3 revenue meeting email with urgency flag. + ... Exercises the full 6-agent graph including business_email node. + Create Executor For Email Graph local_graph + Execute Email Graph ${BUSINESS_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Business Email Through Remote Graph + [Documentation] Business email through registry-retrieved agent configs. + Create Executor For Email Graph remote_graph + Execute Email Graph ${BUSINESS_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Business Email Through Mixed Graph + [Documentation] Business email through mixed local+registry config. + Create Executor For Email Graph mixed_graph + Execute Email Graph ${BUSINESS_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Strategy Sync Email Through Local Graph + [Documentation] Strategy sync with one meeting. + Create Executor For Email Graph local_graph + Execute Email Graph ${BUSINESS_EMAIL_2} + Result Is Valid Actor Result + Result Has Nodes + +Business Email Agent Metadata Valid + [Documentation] Agent metadata for business_email agent. + Factory Agent Has Metadata Key local_graph business_email name + Factory Agent Has Metadata Key local_graph business_email type + +Supplier And Client Cascade From Business + [Documentation] After business_email, supplier and client execute sequentially. + ... All three agents participate in the pipeline. + Create Executor For Email Graph local_graph + Execute Email Graph ${BUSINESS_EMAIL} + Result Is Valid Actor Result + Result Has Nodes diff --git a/robot/client_agent.robot b/robot/client_agent.robot new file mode 100644 index 0000000..614f2b0 --- /dev/null +++ b/robot/client_agent.robot @@ -0,0 +1,41 @@ +*** Settings *** +Documentation Client communication agent integration tests. +... Tests graph execution with client email inputs +... (proposals, support requests, follow-ups) across all config variants. +Library EmailGraphLib.py +Resource resources/email_testdata.resource + +*** Test Cases *** +Client Email Through Local Graph + [Documentation] Client proposal follow-up with support request. + ... Exercises full graph including client node. + Create Executor For Email Graph local_graph + Execute Email Graph ${CLIENT_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Client Email Through Remote Graph + [Documentation] Client email through registry-retrieved agent configs. + Create Executor For Email Graph remote_graph + Execute Email Graph ${CLIENT_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Client Email Through Mixed Graph + [Documentation] Client email through mixed local+registry config. + Create Executor For Email Graph mixed_graph + Execute Email Graph ${CLIENT_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Client Bid Request Through Local Graph + [Documentation] Project bid request with follow-up. + Create Executor For Email Graph local_graph + Execute Email Graph ${CLIENT_EMAIL_2} + Result Is Valid Actor Result + Result Has Nodes + +Client Agent Metadata Valid + [Documentation] Agent metadata for client agent. + Factory Agent Has Metadata Key local_graph client name + Factory Agent Has Metadata Key local_graph client type diff --git a/robot/component_orders_agent.robot b/robot/component_orders_agent.robot new file mode 100644 index 0000000..057d803 --- /dev/null +++ b/robot/component_orders_agent.robot @@ -0,0 +1,52 @@ +*** Settings *** +Documentation Electronic component order agent integration tests. +... Tests graph execution with component order email inputs +... across all three configuration variants. +Library EmailGraphLib.py +Resource resources/email_testdata.resource + +*** Test Cases *** +Component Order Email Through Local Graph + [Documentation] Email with IC-12345 and RES-67890 part numbers. + ... Exercises the full 6-agent graph including component_orders node. + Create Executor For Email Graph local_graph + Execute Email Graph ${COMPONENT_ORDER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Component Order Email Through Remote Graph + [Documentation] Same email through registry-retrieved agent configs. + Create Executor For Email Graph remote_graph + Execute Email Graph ${COMPONENT_ORDER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Component Order Email Through Mixed Graph + [Documentation] Component order through mixed local+registry config. + Create Executor For Email Graph mixed_graph + Execute Email Graph ${COMPONENT_ORDER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Second Component Order Email Through Local Graph + [Documentation] Urgent capacitor order with different part number format. + Create Executor For Email Graph local_graph + Execute Email Graph ${COMPONENT_ORDER_EMAIL_2} + Result Is Valid Actor Result + Result Has Nodes + +Component Orders Agent Metadata Valid + [Documentation] AgentFactory.get_agent_metadata returns name and type. + Factory Agent Has Metadata Key local_graph component_orders name + Factory Agent Has Metadata Key local_graph component_orders type + +All Six Agents Created From Config + [Documentation] AgentFactory creates all 6 agents without error. + ${count}= Create Agents From Config local_graph + Should Be True ${count} >= 6 Expected >=6 agents, got ${count} + +Configuration Validates Successfully + [Documentation] AgentFactory.validate_configuration passes for all variants. + Factory Validates Configuration local_graph + Factory Validates Configuration remote_graph + Factory Validates Configuration mixed_graph diff --git a/robot/email_categorization_graph.robot b/robot/email_categorization_graph.robot new file mode 100644 index 0000000..27f286b --- /dev/null +++ b/robot/email_categorization_graph.robot @@ -0,0 +1,134 @@ +*** Settings *** +Documentation End-to-end integration tests for the 6-agent email categorization +... actor graph. Exercises graph compilation, sequential execution +... through all 6 agents, state management, and result validation +... across local, remote, and mixed configurations. +... +... Graph topology: categorizer -> component_orders | personal_summarizer +... | business_email -> supplier | client. +... All edges have no conditions → all nodes execute sequentially. +Library EmailGraphLib.py +Library OperatingSystem +Resource resources/email_testdata.resource + +*** Test Cases *** +Email Graph Compiles And Executes All Six Agents + [Documentation] Full local tool-agent graph compiles and executes without error. + ... All 6 agents are created from their tool configs and run sequentially. + ... The final output is from the last node (client). + Create Executor For Email Graph local_graph + Execute Email Graph ${COMPONENT_ORDER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + Result Response Contains client + +Email Graph With Personal Email Input + [Documentation] Personal email input flows through all agents and completes. + Create Executor For Email Graph local_graph + Execute Email Graph ${PERSONAL_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Email Graph With Business Email Input + [Documentation] Business email with meetings and urgency flows through graph. + Create Executor For Email Graph local_graph + Execute Email Graph ${BUSINESS_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Email Graph With Unknown Email Input + [Documentation] Email with no recognizable keywords still executes all agents. + Create Executor For Email Graph local_graph + Execute Email Graph ${UNKNOWN_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Email Graph With Empty Email Input + [Documentation] Empty subject/body should not crash the graph pipeline. + Create Executor For Email Graph local_graph + Execute Email Graph ${EMPTY_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Email Graph Streaming Execution + [Documentation] Streaming execution path produces same result structure as sync. + Create Executor For Email Graph local_graph + Execute Email Graph Stream ${COMPONENT_ORDER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Email Graph Remote Registry Configuration + [Documentation] Remote registry variant: all agents use registry-retrieved configs. + ... Graph topology and execution are identical to local variant. + Create Executor For Email Graph remote_graph + Execute Email Graph ${COMPONENT_ORDER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + Result Response Contains registry + +Email Graph Mixed Configuration + [Documentation] Mixed: categorizer is local, domain agents from registry. + ... Validates hybrid local+remote config resolution. + Create Executor For Email Graph mixed_graph + Execute Email Graph ${BUSINESS_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Email Graph Returns State For Stateless Resumption + [Documentation] Graph execution returns opaque state dict for ADR-2026. + ... State enables callers to resume execution from the same point. + Create Executor For Email Graph local_graph + Execute Email Graph ${COMPONENT_ORDER_EMAIL} + Result Is Valid Actor Result + Result Has State + Result State Contains Key current_message + Result State Contains Key last_output + +Email Graph With Second Component Order Email + [Documentation] Another component order email with different part numbers. + Create Executor For Email Graph local_graph + Execute Email Graph ${COMPONENT_ORDER_EMAIL_2} + Result Is Valid Actor Result + Result Has Nodes + +Email Graph With Second Personal Email + [Documentation] Shorter personal reply flows through all agents. + Create Executor For Email Graph local_graph + Execute Email Graph ${PERSONAL_EMAIL_2} + Result Is Valid Actor Result + Result Has Nodes + +Email Graph With Second Business Email + [Documentation] Strategy sync email flows through the entire graph. + Create Executor For Email Graph local_graph + Execute Email Graph ${BUSINESS_EMAIL_2} + Result Is Valid Actor Result + Result Has Nodes + +Email Graph With Client Email + [Documentation] Client proposal+support email exercises the full pipeline. + Create Executor For Email Graph local_graph + Execute Email Graph ${CLIENT_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Email Graph With Supplier Email + [Documentation] Supplier RFQ+delivery+invoice email through full pipeline. + Create Executor For Email Graph local_graph + Execute Email Graph ${SUPPLIER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Email Graph With Client Bid Request + [Documentation] Client project bid with follow-up. + Create Executor For Email Graph local_graph + Execute Email Graph ${CLIENT_EMAIL_2} + Result Is Valid Actor Result + Result Has Nodes + +Email Graph With Delivery Update + [Documentation] Supplier delivery update email. + Create Executor For Email Graph local_graph + Execute Email Graph ${SUPPLIER_EMAIL_2} + Result Is Valid Actor Result + Result Has Nodes diff --git a/robot/email_graph_components.robot b/robot/email_graph_components.robot new file mode 100644 index 0000000..1e14ec2 --- /dev/null +++ b/robot/email_graph_components.robot @@ -0,0 +1,100 @@ +*** Settings *** +Documentation Component-based integration tests for the email categorization graph. +... Each agent is defined in its own YAML file. The main graph +... routes file references them. All components are assembled via +... merge_configs() at test time, validating the decomposed +... component pattern end-to-end. +Library EmailGraphLib.py +Resource resources/email_testdata.resource + +*** Test Cases *** +Component Graph Assembles Six Actors + [Documentation] Loading all 7 component files (6 agents + 1 routes) and + ... merging produces a config with exactly 6 actors. + Component Graph Actor Count Equals 6 + +Component Graph Executes Component Order Email + [Documentation] Full graph assembled from components processes a + ... component order email end-to-end. + Create Executor From Components + Execute Email Graph ${COMPONENT_ORDER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Component Graph Executes Personal Email + [Documentation] Personal email through component-assembled graph. + Create Executor From Components + Execute Email Graph ${PERSONAL_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Component Graph Executes Business Email + [Documentation] Business email through component-assembled graph. + Create Executor From Components + Execute Email Graph ${BUSINESS_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Component Graph Executes Supplier Email + [Documentation] Supplier email through component-assembled graph. + Create Executor From Components + Execute Email Graph ${SUPPLIER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Component Graph Executes Client Email + [Documentation] Client email through component-assembled graph. + Create Executor From Components + Execute Email Graph ${CLIENT_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Component Graph Handles Unknown Email + [Documentation] Email with no recognizable category still executes. + Create Executor From Components + Execute Email Graph ${UNKNOWN_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Component Graph Handles Empty Input + [Documentation] Empty email handled gracefully in component graph. + Create Executor From Components + Execute Email Graph ${EMPTY_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Component Graph Returns State + [Documentation] Component-assembled graph returns state for ADR-2026. + Create Executor From Components + Execute Email Graph ${COMPONENT_ORDER_EMAIL} + Result Has State + Result State Contains Key current_message + Result State Contains Key last_output + +Component Graph Streaming Execution + [Documentation] Streaming path works with component-assembled graph. + Create Executor From Components + Execute Email Graph Stream ${BUSINESS_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Component Graph With Supplier Delivery Email + [Documentation] Delivery update email through assembled components. + Create Executor From Components + Execute Email Graph ${SUPPLIER_EMAIL_2} + Result Is Valid Actor Result + Result Has Nodes + +Component Graph With Client Bid Request + [Documentation] Client bid request through assembled components. + Create Executor From Components + Execute Email Graph ${CLIENT_EMAIL_2} + Result Is Valid Actor Result + Result Has Nodes + +Component Graph Merge Preserves Routes + [Documentation] The merged component config preserves graph topology. + ${merged}= Load All Email Components + Merged Config Has Nested Key ${merged} routes main + Merged Config Has Nested Key ${merged} routes main nodes + Merged Config Has Nested Key ${merged} routes main edges diff --git a/robot/email_graph_merge_configs.robot b/robot/email_graph_merge_configs.robot new file mode 100644 index 0000000..40bbdae --- /dev/null +++ b/robot/email_graph_merge_configs.robot @@ -0,0 +1,47 @@ +*** Settings *** +Documentation merge_configs integration tests for email graph configurations. +... Validates §3.1 deep-merge semantics with the 6-agent graph by +... directly testing the merge_configs public API. +Library EmailGraphLib.py + +*** Test Cases *** +Merge Two Email Graph Configs Preserves Routes + [Documentation] Merging two graph configs should preserve all routes. + ${merged}= Merge Email Graph Configs local_graph remote_graph + Merged Config Has Nested Key ${merged} routes + Merged Config Has Nested Key ${merged} actors + +Merge Email Graph Configs Preserves All Six Actors + [Documentation] Merging should keep all 6 actor definitions. + ${merged}= Merge Email Graph Configs local_graph remote_graph + Merged Config Key Count ${merged} actors 6 + +Merge Email Graph Configs Preserves Nodes + [Documentation] All nodes from the local graph should be preserved after merge. + ${merged}= Merge Email Graph Configs local_graph mixed_graph + Merged Config Has Nested Key ${merged} routes + Merged Config Has Nested Key ${merged} routes main nodes + +Merge Email Graph Configs Appends Edges + [Documentation] Merging two configs with edge lists should append edges per §3.1. + ${merged}= Merge Email Graph Configs local_graph local_graph + Merged Config Has Nested Key ${merged} routes main edges + +Merge Email Graph Configs Preserves Actor Config Details + [Documentation] Deep merge preserves nested actor configuration (tools, code). + ${merged}= Merge Email Graph Configs local_graph remote_graph + Merged Config Has Nested Key ${merged} actors email_categorizer + Merged Config Has Nested Key ${merged} actors email_categorizer config + +Merge Configs Invalid Arg Raises TypeError + [Documentation] Passing a non-dict to merge_configs raises TypeError. + Run Keyword And Expect Error TypeError* + ... Merge Email Graph Configs Invalid local_graph + +Merged Config Creates Working Graph Locally + [Documentation] Merge two local_graph configs and verify the executor works. + ${merged}= Merge Email Graph Configs local_graph local_graph + Create Executor From Config Dict ${merged} + Execute Email Graph {"subject": "Order for parts", "body": "Need IC-12345 components urgently. Purchase order attached."} + Result Is Valid Actor Result + Result Has Nodes diff --git a/robot/email_graph_namespaced.robot b/robot/email_graph_namespaced.robot new file mode 100644 index 0000000..c923f60 --- /dev/null +++ b/robot/email_graph_namespaced.robot @@ -0,0 +1,71 @@ +*** Settings *** +Documentation Namespaced component graph integration tests. +... Each agent is a standalone YAML file under a local namespace. +... The main graph references components via ``local:`` references +... resolved by the ReferenceResolver + LocalPackageStore. +... The graph is assembled by resolving all references at test time. +Library EmailGraphLib.py +Resource resources/email_testdata.resource + +*** Test Cases *** +Namespaced Graph Has Six Agent References + [Documentation] The main graph YAML should reference all 6 agents via local: refs. + Namespaced Graph Actor Count Equals 6 + +Namespaced Graph Assembles And Executes Component Order Email + [Documentation] All 6 namespaced actors are resolved from local: refs, + ... assembled into the graph, and executed with a component order email. + Assemble Namespaced Graph + Execute Email Graph ${COMPONENT_ORDER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Namespaced Graph Executes Personal Email + [Documentation] Personal email through namespaced component graph. + Assemble Namespaced Graph + Execute Email Graph ${PERSONAL_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Namespaced Graph Executes Business Email + [Documentation] Business email through namespaced component graph. + Assemble Namespaced Graph + Execute Email Graph ${BUSINESS_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Namespaced Graph Executes Supplier Email + [Documentation] Supplier email through namespaced component graph. + Assemble Namespaced Graph + Execute Email Graph ${SUPPLIER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Namespaced Graph Executes Client Email + [Documentation] Client email through namespaced component graph. + Assemble Namespaced Graph + Execute Email Graph ${CLIENT_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Namespaced Graph Handles Unknown Email + [Documentation] Unrecognizable email handled gracefully. + Assemble Namespaced Graph + Execute Email Graph ${UNKNOWN_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Namespaced Graph Returns State + [Documentation] Namespaced graph returns state for ADR-2026 resumption. + Assemble Namespaced Graph + Execute Email Graph ${COMPONENT_ORDER_EMAIL} + Result Has State + Result State Contains Key current_message + Result State Contains Key last_output + +Namespaced Graph Streaming Execution + [Documentation] Streaming path works with namespaced component graph. + Assemble Namespaced Graph + Execute Email Graph Stream ${BUSINESS_EMAIL} + Result Is Valid Actor Result + Result Has Nodes diff --git a/robot/email_graph_negative_tests.robot b/robot/email_graph_negative_tests.robot new file mode 100644 index 0000000..80ea84c --- /dev/null +++ b/robot/email_graph_negative_tests.robot @@ -0,0 +1,52 @@ +*** Settings *** +Documentation Negative integration tests for the email categorization actor graph. +... Tests error handling: invalid configs, missing agents, empty +... inputs, and corrupt data. +Library EmailGraphLib.py +Resource resources/email_testdata.resource + +*** Test Cases *** +Graph Without Routes Key Fails At Execution + [Documentation] Config missing 'routes' key: executor construction succeeds, + ... but execution fails because multi_actor type cannot dispatch. + Create Executor From Config Dict {"actors": {}} + Execution Should Raise test Error + +Graph With Missing Agent Reference Fails At Execution + [Documentation] A node referencing an agent not in actors/agents must fail. + Create Executor From Config Dict {"routes": {"main": {"entry_point": "start", "nodes": {"n1": {"id": "start", "agent": "nonexistent"}}, "edges": []}}, "actors": {"echo": {"type": "tool", "config": {"tools": [{"name": "t", "code": "result = 'ok'"}]}}}} + Execution Should Raise test Error + +Invalid Edge Definition Fails At Execution + [Documentation] An edge missing the required 'source' key fails validation. + Create Executor From Config Dict {"routes": {"main": {"entry_point": "n1", "nodes": {"n1": {"id": "n1", "type": "function"}, "n2": {"id": "n2", "type": "function"}}, "edges": [{"target": "n2"}]}}, "actors": {}} + Execution Should Raise test Error + +Merge Configs Rejects Non Dict Argument + [Documentation] merge_configs() with a non-dict arg raises TypeError. + Run Keyword And Expect Error TypeError* + ... Merge Email Graph Configs Invalid local_graph + +Non Dict Config Raises ConfigurationError + [Documentation] Passing a non-dict value to create_executor raises ConfigurationError. + Executor Non Dict Should Fail not-a-dict + +Invalid Json Input Does Not Crash Graph + [Documentation] Non-JSON input should be handled gracefully without crashing. + ... Tool errors are logged but graph execution completes. + Create Executor For Email Graph local_graph + Execute Email Graph ${INVALID_JSON_INPUT} + Result Is Valid Actor Result + Result Has Nodes + +Empty Email Input Does Not Crash + [Documentation] Empty email input should be handled gracefully. + Create Executor For Email Graph local_graph + Execute Email Graph ${EMPTY_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Agent Factory Rejects Nonexistent Agent + [Documentation] Requesting metadata for a nonexistent agent should raise. + Run Keyword And Expect Error AgentCreationError* + ... Factory Agent Has Metadata Key local_graph nonexistent_agent name diff --git a/robot/lib/__init__.py b/robot/lib/__init__.py new file mode 100644 index 0000000..32eed49 --- /dev/null +++ b/robot/lib/__init__.py @@ -0,0 +1 @@ +"""Per-module keyword libraries for cleveractors Robot Framework integration tests.""" diff --git a/robot/lib/app_lib.py b/robot/lib/app_lib.py new file mode 100644 index 0000000..dea3a4f --- /dev/null +++ b/robot/lib/app_lib.py @@ -0,0 +1,274 @@ +"""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") diff --git a/robot/lib/config_lib.py b/robot/lib/config_lib.py new file mode 100644 index 0000000..ff0907f --- /dev/null +++ b/robot/lib/config_lib.py @@ -0,0 +1,239 @@ +"""Robot Framework keyword library for configuration and template rendering. + +Covers: package metadata, configuration manager, merge_configs, +schema validation, and template rendering/registry. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import yaml + +from cleveractors import __version__, merge_configs +from cleveractors.core.config import ConfigurationManager, SchemaValidator +from cleveractors.templates.enhanced_registry import EnhancedTemplateRegistry +from cleveractors.templates.registry import TemplateRegistry +from cleveractors.templates.renderer import TemplateEngine, TemplateRenderer + + +class ConfigLib: # pragma: no cover - integration test library + """Keyword library for configuration and templates.""" + + ROBOT_LIBRARY_SCOPE = "TEST SUITE" + + # ── package metadata ────────────────────────────────────────────── + + def package_version(self) -> str: + return __version__ + + def version_matches(self, expected: str) -> None: + actual = self.package_version() + if actual != expected: + raise AssertionError( + f"Version mismatch: expected {expected!r}, got {actual!r}" + ) + + # ── configuration ───────────────────────────────────────────────── + + def create_config_manager(self) -> None: + self._cm = ConfigurationManager() + + def load_config_files(self, *paths: str) -> None: + self._cm.load_files([Path(p) for p in paths]) + + def config_value_equals(self, path: str, expected: str) -> None: + value = self._cm.get(path) + if str(value) != str(expected): + raise AssertionError(f"Config {path}: expected {expected!r}, got {value!r}") + + def config_path_exists(self, path: str) -> None: + value = self._cm.get(path) + if value is None: + raise AssertionError(f"Config path {path!r} not found or is None") + + def load_config_should_raise(self, *paths: str) -> None: + try: + self._cm = ConfigurationManager() + self._cm.load_files([Path(p) for p in paths]) + except Exception: + return + raise AssertionError("Expected an exception but none was raised") + + def interpolate_env_var(self, var_name: str, var_value: str) -> None: + os.environ[var_name] = var_value + data = {"key": f"${{{var_name}}}"} + result = self._cm.interpolate_env_vars(data) + if result.get("key") != var_value: + raise AssertionError( + f"Interpolation failed: expected {var_value!r}, got {result.get('key')!r}" + ) + + # ── merge_configs public API ────────────────────────────────────── + + def merge_configs_returns_empty_for_no_args(self) -> None: + result = merge_configs() + if result != {}: + raise AssertionError(f"Expected {{}}, got {result!r}") + + def merge_configs_two_dicts( + self, + base_key: str, + base_val: str, + overlay_key: str, + overlay_val: str, + ) -> None: + base = {base_key: base_val} + overlay = {overlay_key: overlay_val} + result = merge_configs(base, overlay) + if len(result) != 2: + raise AssertionError( + f"Expected result length 2, got {len(result)}: {result!r}" + ) + if result.get(base_key) != base_val: + raise AssertionError(f"Expected {base_key}={base_val!r}, got {result!r}") + if result.get(overlay_key) != overlay_val: + raise AssertionError( + f"Expected {overlay_key}={overlay_val!r}, got {result!r}" + ) + if base != {base_key: base_val}: + raise AssertionError(f"Base dict was mutated: {base!r}") + if overlay != {overlay_key: overlay_val}: + raise AssertionError(f"Overlay dict was mutated: {overlay!r}") + + def merge_configs_deep_merge(self) -> None: + base = {"config": {"a": 1, "b": 2}} + overlay = {"config": {"b": 99, "c": 3}} + result = merge_configs(base, overlay) + expected = {"config": {"a": 1, "b": 99, "c": 3}} + if result != expected: + raise AssertionError(f"Expected {expected!r}, got {result!r}") + + def merge_configs_sequence_append(self) -> None: + base = {"items": [1, 2]} + overlay = {"items": [3, 4]} + result = merge_configs(base, overlay) + if result != {"items": [1, 2, 3, 4]}: + raise AssertionError(f"Expected items=[1,2,3,4], got {result!r}") + + def merge_configs_through_config_pipeline( + self, base_key: str, overlay_val: str + ) -> None: + base = Path(__file__).resolve().parent.parent.parent / "tests" / "fixtures" + + cm_base = ConfigurationManager() + cm_base.load_files([base / "test_config.yaml"]) + base_dict = cm_base.to_dict() + + overlay: dict[str, Any] = {base_key: overlay_val, "meta": {"merged": True}} + result = merge_configs(base_dict, overlay) + + if result.get(base_key) != overlay_val: + raise AssertionError( + f"Overlay key {base_key!r} expected {overlay_val!r}," + f" got {result.get(base_key)!r}" + ) + if result.get("meta", {}).get("merged") is not True: + raise AssertionError( + f"Deep-merged overlay meta missing: {result.get('meta')!r}" + ) + if "agents" not in result: + raise AssertionError( + f"Base fixture key 'agents' lost during merge: {sorted(result.keys())}" + ) + if "cleveragents" not in result: + raise AssertionError("Base fixture key 'cleveragents' lost during merge") + + def schema_validator_accepts_minimum_config(self) -> None: + config = { + "cleveragents": {"default_router": "main"}, + "agents": { + "echo": { + "type": "tool", + "config": {"tools": ["echo"]}, + } + }, + "routes": { + "main": { + "type": "stream", + "operators": [{"type": "map", "params": {"agent": "echo"}}], + "publications": ["__output__"], + } + }, + } + sv = SchemaValidator() + sv.validate(config) + + def schema_validator_rejects(self, yaml_text: str) -> None: + sv = SchemaValidator() + config = yaml.safe_load(yaml_text) + try: + sv.validate(config) + except Exception: + return + raise AssertionError( + "Expected schema validation to reject config, but it passed" + ) + + # ── templates ───────────────────────────────────────────────────── + + def create_template_renderer(self, engine: str = "SIMPLE") -> None: + eng = TemplateEngine[engine.upper()] + self._renderer = TemplateRenderer(eng) + + def template_engine_is_string(self, engine: str) -> None: + assert engine.lower() in ("simple", "jinja2", "mustache"), f"Unknown: {engine}" + + def register_template(self, name: str, content: str) -> None: + self._renderer.register_template(name, content) + + def render_template_equals( + self, name: str, context_json: str, expected: str + ) -> None: + ctx = json.loads(context_json) + result = self._renderer.render(name, ctx) + if str(result) != expected: + raise AssertionError( + f"Render mismatch: expected {expected!r}, got {result!r}" + ) + + def render_string_equals( + self, template: str, context_json: str, expected: str + ) -> None: + ctx = json.loads(context_json) + result = self._renderer.render_string(template, ctx) + if str(result) != expected: + raise AssertionError( + f"Render mismatch: expected {expected!r}, got {result!r}" + ) + + def render_string_contains( + self, template: str, context_json: str, expected: str + ) -> None: + ctx = json.loads(context_json) + result = self._renderer.render_string(template, ctx) + if expected not in str(result): + raise AssertionError(f"Expected {expected!r} in result, got {result!r}") + + def render_unknown_template_should_raise(self, name: str) -> None: + try: + self._renderer.render(name, {}) + except Exception: + return + raise AssertionError("Expected exception for unknown template, but none raised") + + def list_templates_count(self, expected_count: str) -> None: + templates = self._renderer.list_templates() + if len(templates) != int(expected_count): + raise AssertionError( + f"Expected {expected_count} templates, got {len(templates)}: {templates}" + ) + + def create_template_registry(self) -> None: + self._registry = TemplateRegistry() + + def create_enhanced_registry(self) -> None: + self._reg_enhanced = EnhancedTemplateRegistry() diff --git a/robot/lib/registry_lib.py b/robot/lib/registry_lib.py new file mode 100644 index 0000000..a6ca985 --- /dev/null +++ b/robot/lib/registry_lib.py @@ -0,0 +1,602 @@ +"""Robot Framework keyword library for registry and template type coverage. + +Covers: template engine coverage (mustache/jinja2), template type enumeration, +component reference, template registry, enhanced registry, reference resolver, +cache factory, LocalPackageStore, and PackageContentResolver. +""" + +from __future__ import annotations + +import asyncio +import json +import tempfile +from pathlib import Path + +from cleveractors.registry.cache import CacheFactory +from cleveractors.registry.exceptions import InvalidPackageReferenceError +from cleveractors.registry.local_store import LocalPackageStore +from cleveractors.registry.reference_resolver import PackageContentResolver +from cleveractors.registry.resolver import ReferenceResolver +from cleveractors.registry.types import PackageReference +from cleveractors.templates.base import ComponentReference, InstantiationContext, TemplateType +from cleveractors.templates.enhanced_registry import EnhancedTemplateRegistry +from cleveractors.templates.renderer import TemplateEngine, TemplateRenderer + + +class RegistryLib: # pragma: no cover - integration test library + """Keyword library for registry and template type coverage.""" + + ROBOT_LIBRARY_SCOPE = "TEST SUITE" + + # ── template engine coverage (MUSTACHE) ─────────────────────────── + + def create_mustache_renderer(self) -> None: + self._mustache_renderer = TemplateRenderer(TemplateEngine.MUSTACHE) + + def mustache_render_equals(self, template: str, expected: str) -> None: + self._mustache_renderer.register_template("test", template) + result = self._mustache_renderer.render("test", {"name": "World"}) + if expected not in str(result): + raise AssertionError( + f"Mustache render: expected containing {expected!r}, got {result!r}" + ) + + def create_jinja2_renderer(self) -> None: + self._jinja_renderer = TemplateRenderer(TemplateEngine.JINJA2) + + def jinja2_render_contains(self, template: str, expected: str) -> None: + self._jinja_renderer.register_template("test", template) + result = self._jinja_renderer.render("test", {"name": "World"}) + if expected not in str(result): + raise AssertionError( + f"Jinja2 render: expected containing {expected!r}, got {result!r}" + ) + + # ── template type enumeration (issue #27) ────────────────────────── + + def template_type_count_equals(self, expected: str) -> None: + count = len(TemplateType) + if count != int(expected): + raise AssertionError( + f"Expected {expected} TemplateType values, got {count}" + ) + + def template_type_value_equals(self, name: str, expected: str) -> None: + value = TemplateType[name].value + if value != expected: + raise AssertionError( + f"TemplateType.{name}: expected {expected!r}, got {value!r}" + ) + + def template_type_has_member(self, name: str) -> None: + if name not in TemplateType.__members__: + raise AssertionError( + f"TemplateType missing member {name!r}: {list(TemplateType.__members__)}" + ) + + # ── component reference with package_ref (issue #27) ─────────────── + + def create_component_reference( + self, ref_type: str, ref_name: str, ref_params_json: str = "{}" + ) -> None: + params = json.loads(ref_params_json) + self._comp_ref = ComponentReference( + ref_type=ref_type, ref_name=ref_name, ref_params=params + ) + + def component_ref_field_equals(self, field: str, expected: str) -> None: + value = getattr(self._comp_ref, field) + if str(value) != expected: + raise AssertionError( + f"ComponentReference.{field}: expected {expected!r}, got {value!r}" + ) + + def component_ref_package_ref_is_none(self) -> None: + if self._comp_ref.package_ref is not None: + raise AssertionError( + f"Expected package_ref to be None, got {self._comp_ref.package_ref}" + ) + + def set_component_ref_package_ref(self, ref_string: str) -> None: + self._comp_ref.package_ref = PackageReference.from_string(ref_string) + + def component_ref_package_ref_has_attr(self, attr: str, expected: str) -> None: + pr = self._comp_ref.package_ref + if pr is None: + raise AssertionError("package_ref is None") + value = getattr(pr, attr) + if str(value) != expected: + raise AssertionError( + f"package_ref.{attr}: expected {expected!r}, got {value!r}" + ) + + # ── template registry with 8 types (issue #27) ───────────────────── + + def register_template_by_type_name( + self, type_name: str, template_name: str, definition_json: str + ) -> None: + ttype = TemplateType[type_name] + definition = json.loads(definition_json) + self._registry.register_template(ttype, template_name, definition) + + def template_registry_has_template( + self, type_name: str, template_name: str + ) -> None: + ttype = TemplateType[type_name] + if not self._registry.has_template(ttype, template_name): + raise AssertionError(f"Registry does not have {type_name}/{template_name}") + + def template_registry_count_for_type(self, type_name: str, expected: str) -> None: + ttype = TemplateType[type_name] + count = len(self._registry.templates[ttype]) + if count != int(expected): + raise AssertionError( + f"Expected {expected} templates for {type_name}, got {count}" + ) + + def template_registry_list_all_count(self, expected: str) -> None: + result = self._registry.list_templates() + if len(result) != int(expected): + raise AssertionError( + f"Expected {expected} template types in list, got {len(result)}: {sorted(result.keys())}" + ) + + # ── enhanced registry with 8 types (issue #27) ───────────────────── + + def register_enhanced_template_by_type_name( + self, type_name: str, template_name: str, definition_json: str + ) -> None: + ttype = TemplateType[type_name] + definition = json.loads(definition_json) + self._reg_enhanced.register_template_dict(ttype, template_name, definition) + + def enhanced_registry_has_template( + self, type_name: str, template_name: str + ) -> None: + ttype = TemplateType[type_name] + if not self._reg_enhanced.has_template(ttype, template_name): + raise AssertionError( + f"Enhanced registry does not have {type_name}/{template_name}" + ) + + # ── reference resolver (issue #27) ───────────────────────────────── + + def create_reference_resolver(self) -> None: + + self._tmpdir = tempfile.TemporaryDirectory() + base_dir = Path(self._tmpdir.name) + (base_dir / "test").mkdir(parents=True, exist_ok=True) + (base_dir / "test" / "file.yaml").write_text( + "name: test-package\ndescription: test\n", encoding="utf-8" + ) + (base_dir / "cache_test.yaml").write_text( + "name: cache-test\ndescription: test\n", encoding="utf-8" + ) + (base_dir / "to_clear.yaml").write_text( + "name: to-clear\ndescription: test\n", encoding="utf-8" + ) + self._local_store = LocalPackageStore(base_dir) + self._ref_resolver = PackageContentResolver(local_store=self._local_store) + + def resolve_reference(self, ref_string: str) -> None: + pr = PackageReference.from_string(ref_string) + self._resolved = self._ref_resolver.resolve(pr) + + def resolved_has_key(self, key: str) -> None: + if key not in self._resolved: + raise AssertionError( + f"Resolved result missing key {key!r}: {sorted(self._resolved.keys())}" + ) + + def resolved_key_value_equals(self, key: str, expected: str) -> None: + value = str(self._resolved.get(key)) + if value != expected: + raise AssertionError( + f"Resolved[{key}]: expected {expected!r}, got {value!r}" + ) + + def resolve_should_return_none(self, ref_string: str) -> None: + try: + pr = PackageReference.from_string(ref_string) + result = self._ref_resolver.resolve(pr) + if result is not None: + raise AssertionError( + f"Expected None for {ref_string!r}, got {result!r}" + ) + except (ValueError, Exception): + return + + def resolver_cache_contains(self, ref_string: str) -> None: + if ref_string not in self._ref_resolver.cache: + raise AssertionError( + f"Cache missing {ref_string!r}: {sorted(self._ref_resolver.cache.keys())}" + ) + + def resolver_client_pool_has_server(self, server: str) -> None: + if server not in self._ref_resolver.clients: + raise AssertionError( + f"Client pool missing {server!r}: {sorted(self._ref_resolver.clients.keys())}" + ) + + def create_ref_resolver_client_for_server(self, server: str) -> None: + asyncio.run(self._ref_resolver._get_client(server)) + + def resolver_clear_cache(self) -> None: + self._ref_resolver.clear_cache() + + def resolver_cache_is_empty(self) -> None: + if len(self._ref_resolver.cache) != 0: + raise AssertionError( + f"Expected empty cache, got {len(self._ref_resolver.cache)} entries" + ) + + # ── cache factory + transparent caching (issue #28) ─────────────── + + def create_reference_resolver_with_cache_factory( + self, max_size: str = "256", ttl: str = "300.0" + ) -> None: + factory = CacheFactory( + max_size=int(max_size), ttl=float(ttl), validate_content=False + ) + self._ref_resolver = PackageContentResolver(cache_factory=factory) + + def set_resolver_server_alias(self, alias: str, resolved_url: str) -> None: + self._ref_resolver.set_server_alias(alias, resolved_url) + + def resolve_reference_and_collect_stats( + self, ref_string: str, package_type: str = "actor" + ) -> None: + pr = PackageReference.from_string(ref_string) + self._resolved = self._ref_resolver.resolve(pr, package_type=package_type) + + def total_stats_hits_greater_than(self, expected: str) -> None: + actual = self._ref_resolver.total_stats.hits + if actual <= int(expected): + raise AssertionError( + f"Expected total_stats.hits > {expected}, got {actual}" + ) + + def resolver_clear_all_caches(self) -> None: + self._ref_resolver.clear_cache() + + def resolver_close_all_resources(self) -> None: + asyncio.run(self._ref_resolver.close_all()) + if len(self._ref_resolver.clients) != 0: + raise AssertionError( + f"Expected 0 clients after close_all, " + f"got {len(self._ref_resolver.clients)}" + ) + if len(self._ref_resolver._content_caches) != 0: + raise AssertionError( + f"Expected 0 content caches after close_all, " + f"got {len(self._ref_resolver._content_caches)}" + ) + + def resolver_resolution_cache_is_empty(self) -> None: + if len(self._ref_resolver.cache) != 0: + raise AssertionError( + f"Expected empty resolution cache, " + f"got {len(self._ref_resolver.cache)} entries" + ) + + def resolver_total_stats_misses_equals(self, expected: str) -> None: + actual = self._ref_resolver.total_stats.misses + if actual != int(expected): + raise AssertionError(f"Expected {expected} misses, got {actual}") + + # ── LocalPackageStore integration (issue #46) ─────────────────────── + + def create_local_package_store_with_test_package(self) -> None: + + self._lps_tmpdir = tempfile.TemporaryDirectory() + base_dir = Path(self._lps_tmpdir.name) + (base_dir / "test").mkdir(parents=True, exist_ok=True) + (base_dir / "test" / "package.yaml").write_text( + "name: test-package\ndescription: A test package\n", encoding="utf-8" + ) + (base_dir / "test" / "file.yaml").write_text( + "name: test-file\ndescription: A test file\n", encoding="utf-8" + ) + self._local_store = LocalPackageStore(base_dir) + + def create_lenient_local_package_store_with_test_package(self) -> None: + self._lps_tmpdir = tempfile.TemporaryDirectory() + base_dir = Path(self._lps_tmpdir.name) + (base_dir / "test").mkdir(parents=True, exist_ok=True) + (base_dir / "test" / "package.yaml").write_text( + "name: test-package\ndescription: A test package\n", encoding="utf-8" + ) + (base_dir / "test" / "file.yaml").write_text( + "name: test-file\ndescription: A test file\n", encoding="utf-8" + ) + self._local_store = LocalPackageStore(base_dir, fail_on_unresolvable_refs=False) + + def write_local_package_file(self, relative_path: str, *content_parts: str) -> None: + + content = "\n".join(p.rstrip("\n") for p in content_parts) + base_dir = Path(self._lps_tmpdir.name) + file_path = base_dir / relative_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content, encoding="utf-8") + + def resolve_local_package(self, relative_path: str) -> None: + self._local_pkg = self._local_store.resolve_package(relative_path) + + def resolve_local_package_should_raise( + self, relative_path: str, error_type: str, phrase: str = "" + ) -> None: + try: + self._local_store.resolve_package(relative_path) + raise AssertionError( + f"Expected {error_type} for {relative_path!r} but no error was raised" + ) + except Exception as exc: + if error_type not in type(exc).__name__: + raise AssertionError( + f"Expected {error_type}, got {type(exc).__name__}: {exc}" + ) from None + if phrase and phrase not in str(exc): + raise AssertionError( + f"Expected {phrase!r} in error message, got: {exc}" + ) from None + + def create_local_package_store_should_raise( + self, base_dir_path: str, error_type: str, phrase: str = "" + ) -> None: + try: + LocalPackageStore(base_dir_path) + raise AssertionError( + f"Expected {error_type} for base_dir={base_dir_path!r} but no error was raised" + ) + except Exception as exc: + if error_type not in type(exc).__name__: + raise AssertionError( + f"Expected {error_type}, got {type(exc).__name__}: {exc}" + ) from None + if phrase and phrase not in str(exc): + raise AssertionError( + f"Expected {phrase!r} in error message, got: {exc}" + ) from None + + def create_local_package_store_file_as_dir_should_raise( + self, error_type: str, phrase: str = "" + ) -> None: + tmpdir = tempfile.TemporaryDirectory() + self._cps_tmpdir = tmpdir + file_path = Path(tmpdir.name) / "a-file.txt" + file_path.write_text("not a directory") + try: + LocalPackageStore(file_path) + raise AssertionError( + f"Expected {error_type} for file-as-dir but no error was raised" + ) + except Exception as exc: + if error_type not in type(exc).__name__: + raise AssertionError( + f"Expected {error_type}, got {type(exc).__name__}: {exc}" + ) from None + if phrase and phrase not in str(exc): + raise AssertionError( + f"Expected {phrase!r} in error message, got: {exc}" + ) from None + + def create_local_package_store_empty_base_dir_should_raise( + self, error_type: str, phrase: str = "" + ) -> None: + try: + LocalPackageStore("") + raise AssertionError( + f"Expected {error_type} for empty base_dir but no error was raised" + ) + except Exception as exc: + if error_type not in type(exc).__name__: + raise AssertionError( + f"Expected {error_type}, got {type(exc).__name__}: {exc}" + ) from None + if phrase and phrase not in str(exc): + raise AssertionError( + f"Expected {phrase!r} in error message, got: {exc}" + ) from None + + def resolved_local_package_has_package_id(self) -> None: + if self._local_pkg is None or self._local_pkg.package_id is None: + raise AssertionError("LocalPackage has no package_id") + if not self._local_pkg.package_id.id_string.startswith("pkg_"): + raise AssertionError( + f"PackageId does not start with pkg_: {self._local_pkg.package_id.id_string}" + ) + + def resolved_local_package_has_content_key(self, key: str) -> None: + if key not in self._local_pkg.content: + raise AssertionError( + f"Content missing key {key!r}: {sorted(self._local_pkg.content.keys())}" + ) + + def resolved_local_package_content_equals(self, key: str, expected: str) -> None: + value = str(self._local_pkg.content.get(key)) + if value != expected: + raise AssertionError( + f"Content[{key}]: expected {expected!r}, got {value!r}" + ) + + def resolved_local_package_has_file_path(self) -> None: + if self._local_pkg.file_path is None: + raise AssertionError("LocalPackage has no file_path") + if not self._local_pkg.file_path.is_file(): + raise AssertionError( + f"file_path does not exist: {self._local_pkg.file_path}" + ) + + def resolved_local_package_original_reference_equals(self, expected: str) -> None: + if str(self._local_pkg.original_reference) != expected: + raise AssertionError( + f"original_reference: expected {expected!r}, " + f"got {self._local_pkg.original_reference!r}" + ) + + def resolved_local_package_type_equals(self, type_code: str) -> None: + actual = self._local_pkg.package_id.package_type.value + if actual != type_code: + raise AssertionError(f"PackageType: expected {type_code!r}, got {actual!r}") + + def get_resolved_local_package_id_string(self) -> str: + return self._local_pkg.package_id.id_string + + def get_resolved_local_package_content(self): + return self._local_pkg.content + + # ── PackageContentResolver + ReferenceResolver for local (issue #46) ─ + + def create_package_content_resolver_without_store(self) -> None: + + self._ref_resolver = PackageContentResolver() + + def create_package_content_resolver_with_store(self) -> None: + + self._lps_tmpdir = tempfile.TemporaryDirectory() + base_dir = Path(self._lps_tmpdir.name) + (base_dir / "test").mkdir(parents=True, exist_ok=True) + (base_dir / "test" / "file.yaml").write_text( + "name: test-file\ndescription: A test file\n", encoding="utf-8" + ) + local_store = LocalPackageStore(base_dir) + self._ref_resolver = PackageContentResolver(local_store=local_store) + + def resolve_reference_should_raise( + self, ref_string: str, error_type: str, phrase: str + ) -> None: + try: + pr = PackageReference.from_string(ref_string) + self._ref_resolver.resolve(pr) + raise AssertionError( + f"Expected {error_type} for {ref_string!r} but no error was raised" + ) + except Exception as exc: + if error_type not in type(exc).__name__: + raise AssertionError( + f"Expected {error_type}, got {type(exc).__name__}: {exc}" + ) from None + if phrase not in str(exc): + raise AssertionError( + f"Expected {phrase!r} in error message, got: {exc}" + ) from None + + def create_reference_resolver_without_store(self) -> None: + self._ref_resolver_spec = ReferenceResolver() + + def create_reference_resolver_with_store(self) -> None: + + self._rrs_tmpdir = tempfile.TemporaryDirectory() + base_dir = Path(self._rrs_tmpdir.name) + (base_dir / "test").mkdir(parents=True, exist_ok=True) + (base_dir / "test" / "file.yaml").write_text( + "name: test-file\ndescription: test\n", encoding="utf-8" + ) + local_store = LocalPackageStore(base_dir) + self._ref_resolver_spec = ReferenceResolver(local_store=local_store) + + def resolve_ref_string_should_raise( + self, ref_string: str, error_type: str, phrase: str + ) -> None: + + async def _resolve(): + return await self._ref_resolver_spec.resolve(ref_string) + + try: + asyncio.run(_resolve()) + raise AssertionError( + f"Expected {error_type} for {ref_string!r} but no error was raised" + ) + except InvalidPackageReferenceError as exc: + if error_type not in type(exc).__name__: + raise AssertionError( + f"Expected {error_type}, got {type(exc).__name__}: {exc}" + ) from exc + if phrase not in str(exc): + raise AssertionError( + f"Expected {phrase!r} in error message, got: {exc}" + ) from None + + def resolve_ref_string(self, ref_string: str) -> None: + + async def _resolve(): + return await self._ref_resolver_spec.resolve(ref_string) + + self._ref_resolved_id = asyncio.run(_resolve()) + + def resolved_package_id_should_not_be_none(self) -> None: + if self._ref_resolved_id is None: + raise AssertionError("Resolved PackageId is None") + if not self._ref_resolved_id.id_string.startswith("pkg_"): + raise AssertionError( + f"PackageId does not start with pkg_: {self._ref_resolved_id.id_string}" + ) + + # ── instantiation context with registry awareness (issue #27) ────── + + def create_context_with_resolver(self) -> None: + self._ref_resolver = PackageContentResolver() + self._reg_ctx = InstantiationContext(reference_resolver=self._ref_resolver) + + def context_has_component_type(self, comp_type: str) -> None: + plural = f"{comp_type}s" + if plural not in self._reg_ctx.components: + raise AssertionError( + f"Context missing component type {plural!r}: " + f"{sorted(self._reg_ctx.components.keys())}" + ) + + def context_component_count(self, comp_type: str, expected: str) -> None: + plural = f"{comp_type}s" + count = len(self._reg_ctx.components.get(plural, {})) + if count != int(expected): + raise AssertionError( + f"Expected {expected} components for {plural}, got {count}" + ) + + # ── _original_reference propagation (issue #27) ──────────────────── + + def resolve_local_ref_with_original_reference(self, ref_string: str) -> None: + pr = PackageReference.from_string(ref_string) + tmpdir = tempfile.TemporaryDirectory() + base_dir = Path(tmpdir.name) + (base_dir / "my").mkdir(parents=True, exist_ok=True) + (base_dir / "my" / "package.yaml").write_text( + "name: test\ndescription: test package\n", encoding="utf-8" + ) + local_store = LocalPackageStore(base_dir) + self._ref_resolver = PackageContentResolver(local_store=local_store) + result = self._ref_resolver.resolve(pr) + self._resolved = result + + def resolved_original_reference_equals(self, expected: str) -> None: + value = self._resolved.get("_original_reference") + if str(value) != expected: + raise AssertionError( + f"_original_reference: expected {expected!r}, got {value!r}" + ) + + # ── template registry ref detection (issue #27) ──────────────────── + + def instantiate_config_with_ref_and_resolver( + self, template_ref: str, params_json: str = "{}" + ) -> None: + params = json.loads(params_json) + self._ref_resolver = PackageContentResolver() + ctx = InstantiationContext(reference_resolver=self._ref_resolver) + config = {"template": template_ref, "params": params} + self._reg_instantiated = self._registry.instantiate_from_config(config, ctx) + + def instantiated_has_key(self, key: str) -> None: + if key not in self._reg_instantiated: + raise AssertionError( + f"Instantiated result missing key {key!r}: " + f"{sorted(self._reg_instantiated.keys())}" + ) + + def instantiated_key_value_equals(self, key: str, expected: str) -> None: + value = str(self._reg_instantiated.get(key)) + if value != expected: + raise AssertionError( + f"Instantiated[{key}]: expected {expected!r}, got {value!r}" + ) diff --git a/robot/personal_email_summarization_agent.robot b/robot/personal_email_summarization_agent.robot new file mode 100644 index 0000000..13245e9 --- /dev/null +++ b/robot/personal_email_summarization_agent.robot @@ -0,0 +1,41 @@ +*** Settings *** +Documentation Personal email summarization agent integration tests. +... Tests graph execution with personal email inputs +... (party reminders, RSVPs, action items) across all variants. +Library EmailGraphLib.py +Resource resources/email_testdata.resource + +*** Test Cases *** +Personal Email Through Local Graph + [Documentation] Personal email with birthday party reminder and RSVP. + ... Exercises the full 6-agent graph including personal_summarizer. + Create Executor For Email Graph local_graph + Execute Email Graph ${PERSONAL_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Personal Email Through Remote Graph + [Documentation] Same email through registry-retrieved agent configs. + Create Executor For Email Graph remote_graph + Execute Email Graph ${PERSONAL_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Personal Email Through Mixed Graph + [Documentation] Personal email through mixed local+registry config. + Create Executor For Email Graph mixed_graph + Execute Email Graph ${PERSONAL_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Short Personal Reply Through Local Graph + [Documentation] Shorter personal email reply without action items. + Create Executor For Email Graph local_graph + Execute Email Graph ${PERSONAL_EMAIL_2} + Result Is Valid Actor Result + Result Has Nodes + +Personal Summarizer Agent Metadata Valid + [Documentation] Agent metadata includes required fields. + Factory Agent Has Metadata Key local_graph personal_summarizer name + Factory Agent Has Metadata Key local_graph personal_summarizer type diff --git a/robot/resources/email_testdata.resource b/robot/resources/email_testdata.resource new file mode 100644 index 0000000..35666a5 --- /dev/null +++ b/robot/resources/email_testdata.resource @@ -0,0 +1,14 @@ +*** Variables *** +${COMPONENT_ORDER_EMAIL} {"subject": "Order for IC-Packages", "body": "Please process order for component IC-12345 and RES-67890. Qty: 500. This is for the BOM of Project Alpha. Regards, Engineering"} +${COMPONENT_ORDER_EMAIL_2} {"subject": "Urgent capacitor order", "body": "We need CAP-44556 resistors immediately. Purchase order PO-99887 attached. Qty: 1000"} +${PERSONAL_EMAIL} {"subject": "Weekend plans", "body": "Hi! Hope you're doing well. Don't forget about the birthday party this Saturday at 7pm. Please bring snacks! Also, we need to RSVP for the family vacation next month. Best, Mom"} +${PERSONAL_EMAIL_2} {"subject": "Re: Weekend plans", "body": "Thanks for the reminder! I'll be there. Looking forward to seeing everyone at the party."} +${BUSINESS_EMAIL} {"subject": "Q3 Revenue Meeting", "body": "Team, we need to schedule the Q3 revenue meeting on Monday at 10am. Please review the quarterly report attached. This is urgent - the board wants the strategy document by Wednesday. We'll also have a conference call with the investors on Thursday at 2pm."} +${BUSINESS_EMAIL_2} {"subject": "Strategy sync", "body": "Let's have a sync meeting on Friday at 3pm to discuss the new market strategy. Please bring your department reports."} +${SUPPLIER_EMAIL} {"subject": "RFQ for electronic components", "body": "Dear Supplier, please provide a quote for the attached BOM. We need delivery by end of month. Tracking number for the last shipment: TRK-88991. Invoice #INV-2026-045 is due for payment."} +${SUPPLIER_EMAIL_2} {"subject": "Delivery update", "body": "Your shipment of 500 units has been dispatched. ETA is June 20th. Please confirm receipt upon delivery."} +${CLIENT_EMAIL} {"subject": "Proposal follow-up", "body": "Hi, I wanted to follow up on the proposal we sent last week. Also, we're having an issue with the dashboard - it's not loading properly. Can you provide support? The error says 'timeout connecting to server'."} +${CLIENT_EMAIL_2} {"subject": "New project bid", "body": "We'd like to request a bid for our new project. Please provide an estimate by Friday. This is a follow-up to our conversation last month."} +${UNKNOWN_EMAIL} {"subject": "Hello", "body": "Just saying hi! Hope all is well."} +${INVALID_JSON_INPUT} not-valid-json +${EMPTY_EMAIL} {"subject": "", "body": ""} diff --git a/robot/supplier_agent.robot b/robot/supplier_agent.robot new file mode 100644 index 0000000..d8dc901 --- /dev/null +++ b/robot/supplier_agent.robot @@ -0,0 +1,41 @@ +*** Settings *** +Documentation Supplier communication agent integration tests. +... Tests graph execution with supplier email inputs +... (RFQs, delivery updates, invoices) across all config variants. +Library EmailGraphLib.py +Resource resources/email_testdata.resource + +*** Test Cases *** +Supplier Email Through Local Graph + [Documentation] Supplier email with RFQ, delivery tracking, and invoice. + ... Exercises full graph including supplier node. + Create Executor For Email Graph local_graph + Execute Email Graph ${SUPPLIER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Supplier Email Through Remote Graph + [Documentation] Supplier email through registry-retrieved agent configs. + Create Executor For Email Graph remote_graph + Execute Email Graph ${SUPPLIER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Supplier Email Through Mixed Graph + [Documentation] Supplier email through mixed local+registry config. + Create Executor For Email Graph mixed_graph + Execute Email Graph ${SUPPLIER_EMAIL} + Result Is Valid Actor Result + Result Has Nodes + +Delivery Update Email Through Local Graph + [Documentation] Pure delivery update notification. + Create Executor For Email Graph local_graph + Execute Email Graph ${SUPPLIER_EMAIL_2} + Result Is Valid Actor Result + Result Has Nodes + +Supplier Agent Metadata Valid + [Documentation] Agent metadata for supplier agent. + Factory Agent Has Metadata Key local_graph supplier name + Factory Agent Has Metadata Key local_graph supplier type diff --git a/tests/fixtures/email_graph/components/business_email.yaml b/tests/fixtures/email_graph/components/business_email.yaml new file mode 100644 index 0000000..d767bd6 --- /dev/null +++ b/tests/fixtures/email_graph/components/business_email.yaml @@ -0,0 +1,17 @@ +actors: + business_email: + type: tool + config: + tools: + - name: process_business_email + code: | + import json, re + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = data.get("body", "") + meetings = re.findall(r'(?:meeting|call|conference|sync)\s*(?:on|at|:)\s*([^\n,]{5,40})', body, re.I) + is_urgent = "urgent" in body.lower() or "asap" in body.lower() + result = json.dumps({ + "agent": "business_email", "action": "processed", + "meeting_count": len(meetings), "is_urgent": is_urgent, + "component": "business_email" + }) diff --git a/tests/fixtures/email_graph/components/client.yaml b/tests/fixtures/email_graph/components/client.yaml new file mode 100644 index 0000000..aff105a --- /dev/null +++ b/tests/fixtures/email_graph/components/client.yaml @@ -0,0 +1,19 @@ +actors: + client: + type: tool + config: + tools: + - name: process_client_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + body_lower = body.lower() + is_proposal = any(kw in body_lower for kw in ["proposal", "bid", "quote", "estimate"]) + is_support = any(kw in body_lower for kw in ["support", "issue", "problem", "bug", "error", "not working"]) + is_followup = "follow" in body_lower and "up" in body_lower + result = json.dumps({ + "agent": "client", "action": "processed", + "is_proposal": is_proposal, "is_support": is_support, + "is_followup": is_followup, "component": "client" + }) diff --git a/tests/fixtures/email_graph/components/component_orders.yaml b/tests/fixtures/email_graph/components/component_orders.yaml new file mode 100644 index 0000000..a499c88 --- /dev/null +++ b/tests/fixtures/email_graph/components/component_orders.yaml @@ -0,0 +1,19 @@ +actors: + component_orders: + type: tool + config: + tools: + - name: process_component_order + code: | + import json, re + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + parts = re.findall(r'[A-Z]{2,4}-\d{3,6}', body) + qty_match = re.search(r'qty[:\s]*(\d+)', body, re.I) + qty = int(qty_match.group(1)) if qty_match else 1 + bom = "BOM" in body or "bill of materials" in body.lower() + result = json.dumps({ + "agent": "component_orders", "action": "order_processed", + "part_count": len(parts), "quantity": qty, "has_bom": bom, + "component": "component_orders" + }) diff --git a/tests/fixtures/email_graph/components/email_categorizer.yaml b/tests/fixtures/email_graph/components/email_categorizer.yaml new file mode 100644 index 0000000..029b816 --- /dev/null +++ b/tests/fixtures/email_graph/components/email_categorizer.yaml @@ -0,0 +1,21 @@ +actors: + email_categorizer: + type: tool + config: + tools: + - name: categorize_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + subject = data.get("subject", "") + body = data.get("body", "") + body_lower = body.lower() + if any(kw in body_lower for kw in ["order", "component", "part number", "purchase", "bom", "resistor", "capacitor"]): + category = "component_order" + elif any(kw in body_lower for kw in ["personal", "family", "vacation", "weekend", "party", "birthday"]): + category = "personal" + elif any(kw in body_lower for kw in ["business", "meeting", "quarterly", "report", "strategy", "revenue"]): + category = "business" + else: + category = "unknown" + result = json.dumps({"category": category, "subject": subject, "component": "categorizer"}) diff --git a/tests/fixtures/email_graph/components/graph_routes.yaml b/tests/fixtures/email_graph/components/graph_routes.yaml new file mode 100644 index 0000000..6dd5ddc --- /dev/null +++ b/tests/fixtures/email_graph/components/graph_routes.yaml @@ -0,0 +1,34 @@ +routes: + main: + entry_point: "email_categorizer" + parallel_execution: false + nodes: + email_categorizer: + id: "email_categorizer" + agent: "email_categorizer" + component_orders: + id: "component_orders" + agent: "component_orders" + personal_summarizer: + id: "personal_summarizer" + agent: "personal_summarizer" + business_email: + id: "business_email" + agent: "business_email" + supplier: + id: "supplier" + agent: "supplier" + client: + id: "client" + agent: "client" + edges: + - source: "email_categorizer" + target: "component_orders" + - source: "email_categorizer" + target: "personal_summarizer" + - source: "email_categorizer" + target: "business_email" + - source: "business_email" + target: "supplier" + - source: "business_email" + target: "client" diff --git a/tests/fixtures/email_graph/components/personal_summarizer.yaml b/tests/fixtures/email_graph/components/personal_summarizer.yaml new file mode 100644 index 0000000..5f06efd --- /dev/null +++ b/tests/fixtures/email_graph/components/personal_summarizer.yaml @@ -0,0 +1,18 @@ +actors: + personal_summarizer: + type: tool + config: + tools: + - name: summarize_personal_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = data.get("body", "") + words = body.split() + lines = body.strip().split("\n") + has_action_items = any(kw in body.lower() for kw in ["don't forget", "reminder", "rsvp", "please bring"]) + result = json.dumps({ + "agent": "personal_summarizer", "action": "summarized", + "word_count": len(words), "line_count": len(lines), + "has_action_items": has_action_items, "component": "personal_summarizer" + }) diff --git a/tests/fixtures/email_graph/components/supplier.yaml b/tests/fixtures/email_graph/components/supplier.yaml new file mode 100644 index 0000000..7330100 --- /dev/null +++ b/tests/fixtures/email_graph/components/supplier.yaml @@ -0,0 +1,19 @@ +actors: + supplier: + type: tool + config: + tools: + - name: process_supplier_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + body_lower = body.lower() + rfq = "rfq" in body_lower or "request for quote" in body_lower + delivery = any(kw in body_lower for kw in ["delivery", "shipment", "eta", "tracking"]) + invoice = "invoice" in body_lower or "payment" in body_lower + result = json.dumps({ + "agent": "supplier", "action": "processed", + "is_rfq": rfq, "is_delivery": delivery, "is_invoice": invoice, + "component": "supplier" + }) diff --git a/tests/fixtures/email_graph/local_graph.yaml b/tests/fixtures/email_graph/local_graph.yaml new file mode 100644 index 0000000..39560fd --- /dev/null +++ b/tests/fixtures/email_graph/local_graph.yaml @@ -0,0 +1,168 @@ +routes: + main: + entry_point: "email_categorizer" + parallel_execution: false + nodes: + email_categorizer: + id: "email_categorizer" + agent: "email_categorizer" + component_orders: + id: "component_orders" + agent: "component_orders" + personal_summarizer: + id: "personal_summarizer" + agent: "personal_summarizer" + business_email: + id: "business_email" + agent: "business_email" + supplier: + id: "supplier" + agent: "supplier" + client: + id: "client" + agent: "client" + edges: + - source: "email_categorizer" + target: "component_orders" + - source: "email_categorizer" + target: "personal_summarizer" + - source: "email_categorizer" + target: "business_email" + - source: "business_email" + target: "supplier" + - source: "business_email" + target: "client" + +actors: + email_categorizer: + type: tool + config: + tools: + - name: categorize_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + subject = data.get("subject", "") + body = data.get("body", "") + body_lower = body.lower() + if any(kw in body_lower for kw in ["order", "component", "part number", "purchase", "bom", "resistor", "capacitor"]): + category = "component_order" + elif any(kw in body_lower for kw in ["personal", "family", "vacation", "weekend", "party", "birthday"]): + category = "personal" + elif any(kw in body_lower for kw in ["business", "meeting", "quarterly", "report", "strategy", "revenue"]): + category = "business" + else: + category = "unknown" + result = json.dumps({"category": category, "subject": subject, "confidence": 0.95}) + + component_orders: + type: tool + config: + tools: + - name: process_component_order + code: | + import json, re + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + parts = re.findall(r'[A-Z]{2,4}-\d{3,6}', body) + qty_match = re.search(r'qty[:\s]*(\d+)', body, re.I) + qty = int(qty_match.group(1)) if qty_match else 1 + bom = "BOM" in body or "bill of materials" in body.lower() + result = json.dumps({ + "agent": "component_orders", + "action": "order_processed", + "parts_found": parts, + "part_count": len(parts), + "quantity": qty, + "has_bom": bom, + "total_line_items": 1 + len(parts) + }) + + personal_summarizer: + type: tool + config: + tools: + - name: summarize_personal_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = data.get("body", "") + words = body.split() + word_count = len(words) + lines = body.strip().split("\n") + first_line = lines[0] if lines else "" + has_action_items = any(kw in body.lower() for kw in ["don't forget", "reminder", "rsvp", "please bring"]) + result = json.dumps({ + "agent": "personal_summarizer", + "action": "summarized", + "word_count": word_count, + "line_count": len(lines), + "preview": first_line[:120], + "has_action_items": has_action_items, + "tone": "friendly" if "!" in body else "neutral" + }) + + business_email: + type: tool + config: + tools: + - name: process_business_email + code: | + import json, re + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = data.get("body", "") + meetings = re.findall(r'(?:meeting|call|conference|sync)\s*(?:on|at|:)\s*([^\n,]{5,40})', body, re.I) + is_urgent = "urgent" in body.lower() or "asap" in body.lower() + result = json.dumps({ + "agent": "business_email", + "action": "processed", + "meetings_detected": meetings, + "meeting_count": len(meetings), + "is_urgent": is_urgent, + "word_count": len(body.split()), + "summary": "Processed business communication with " + str(len(meetings)) + " meeting(s)" + }) + + supplier: + type: tool + config: + tools: + - name: process_supplier_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + body_lower = body.lower() + rfq = "rfq" in body_lower or "request for quote" in body_lower + delivery = any(kw in body_lower for kw in ["delivery", "shipment", "eta", "tracking"]) + invoice = "invoice" in body_lower or "payment" in body_lower + result = json.dumps({ + "agent": "supplier", + "action": "processed", + "is_rfq": rfq, + "is_delivery_update": delivery, + "is_invoice": invoice, + "summary": "Processed supplier communication" + }) + + client: + type: tool + config: + tools: + - name: process_client_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + body_lower = body.lower() + is_proposal = any(kw in body_lower for kw in ["proposal", "bid", "quote", "estimate"]) + is_support = any(kw in body_lower for kw in ["support", "issue", "problem", "bug", "error", "not working"]) + is_followup = "follow" in body_lower and "up" in body_lower + result = json.dumps({ + "agent": "client", + "action": "processed", + "is_proposal": is_proposal, + "is_support": is_support, + "is_followup": is_followup, + "summary": "Processed client communication" + }) diff --git a/tests/fixtures/email_graph/mixed_graph.yaml b/tests/fixtures/email_graph/mixed_graph.yaml new file mode 100644 index 0000000..377371d --- /dev/null +++ b/tests/fixtures/email_graph/mixed_graph.yaml @@ -0,0 +1,135 @@ +# Mixed graph -- core agents defined locally, domain-specific agents +# retrieved from registry (simulated with inline tool configs). + +routes: + main: + entry_point: "email_categorizer" + parallel_execution: false + nodes: + email_categorizer: + id: "email_categorizer" + agent: "email_categorizer" + component_orders: + id: "component_orders" + agent: "component_orders" + personal_summarizer: + id: "personal_summarizer" + agent: "personal_summarizer" + business_email: + id: "business_email" + agent: "business_email" + supplier: + id: "supplier" + agent: "supplier" + client: + id: "client" + agent: "client" + edges: + - source: "email_categorizer" + target: "component_orders" + - source: "email_categorizer" + target: "personal_summarizer" + - source: "email_categorizer" + target: "business_email" + - source: "business_email" + target: "supplier" + - source: "business_email" + target: "client" + +# Mix: categorizer is local, domain agents are registry-retrieved. +# Metadata tags track origin. +actors: + email_categorizer: + type: tool + config: + tools: + - name: categorize_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + subject = data.get("subject", "") + body = data.get("body", "") + body_lower = body.lower() + if any(kw in body_lower for kw in ["order", "component", "part number"]): + category = "component_order" + elif any(kw in body_lower for kw in ["personal", "family", "vacation"]): + category = "personal" + elif any(kw in body_lower for kw in ["business", "meeting", "quarterly"]): + category = "business" + else: + category = "unknown" + result = json.dumps({"category": category, "subject": subject, "source": "local"}) + + component_orders: + type: tool + config: + tools: + - name: process_component_order + code: | + import json, re + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + parts = re.findall(r'[A-Z]{2,4}-\d{3,6}', body) + result = json.dumps({ + "agent": "component_orders", "action": "order_processed", + "part_count": len(parts), "source": "registry" + }) + + personal_summarizer: + type: tool + config: + tools: + - name: summarize_personal_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = data.get("body", "") + result = json.dumps({ + "agent": "personal_summarizer", "action": "summarized", + "word_count": len(body.split()), "source": "registry" + }) + + business_email: + type: tool + config: + tools: + - name: process_business_email + code: | + import json, re + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = data.get("body", "") + meetings = re.findall(r'(?:meeting|call|sync)\s*(?:on|at|:)\s*([^\n,]{5,40})', body, re.I) + result = json.dumps({ + "agent": "business_email", "action": "processed", + "meeting_count": len(meetings), "source": "registry" + }) + + supplier: + type: tool + config: + tools: + - name: process_supplier_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + result = json.dumps({ + "agent": "supplier", "action": "processed", + "is_rfq": "rfq" in body.lower(), + "source": "registry" + }) + + client: + type: tool + config: + tools: + - name: process_client_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + result = json.dumps({ + "agent": "client", "action": "processed", + "is_support": "support" in body.lower() or "issue" in body.lower(), + "source": "registry" + }) diff --git a/tests/fixtures/email_graph/namespaced/business_email.yaml b/tests/fixtures/email_graph/namespaced/business_email.yaml new file mode 100644 index 0000000..7e31254 --- /dev/null +++ b/tests/fixtures/email_graph/namespaced/business_email.yaml @@ -0,0 +1,17 @@ +name: business_email +type: tool +namespace: cleveractors.business +config: + tools: + - name: process_business_email + code: | + import json, re + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = data.get("body", "") + meetings = re.findall(r'(?:meeting|call|conference|sync)\s*(?:on|at|:)\s*([^\n,]{5,40})', body, re.I) + is_urgent = "urgent" in body.lower() or "asap" in body.lower() + result = json.dumps({ + "agent": "business_email", "action": "processed", + "meeting_count": len(meetings), "is_urgent": is_urgent, + "namespace": "cleveractors.business" + }) diff --git a/tests/fixtures/email_graph/namespaced/client.yaml b/tests/fixtures/email_graph/namespaced/client.yaml new file mode 100644 index 0000000..4cf77c4 --- /dev/null +++ b/tests/fixtures/email_graph/namespaced/client.yaml @@ -0,0 +1,19 @@ +name: client +type: tool +namespace: cleveractors.clients +config: + tools: + - name: process_client_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + body_lower = body.lower() + is_proposal = any(kw in body_lower for kw in ["proposal", "bid", "quote", "estimate"]) + is_support = any(kw in body_lower for kw in ["support", "issue", "problem", "bug", "error", "not working"]) + is_followup = "follow" in body_lower and "up" in body_lower + result = json.dumps({ + "agent": "client", "action": "processed", + "is_proposal": is_proposal, "is_support": is_support, + "is_followup": is_followup, "namespace": "cleveractors.clients" + }) diff --git a/tests/fixtures/email_graph/namespaced/component_orders.yaml b/tests/fixtures/email_graph/namespaced/component_orders.yaml new file mode 100644 index 0000000..7075bbb --- /dev/null +++ b/tests/fixtures/email_graph/namespaced/component_orders.yaml @@ -0,0 +1,19 @@ +name: component_orders +type: tool +namespace: cleveractors.orders +config: + tools: + - name: process_component_order + code: | + import json, re + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + parts = re.findall(r'[A-Z]{2,4}-\d{3,6}', body) + qty_match = re.search(r'qty[:\s]*(\d+)', body, re.I) + qty = int(qty_match.group(1)) if qty_match else 1 + bom = "BOM" in body or "bill of materials" in body.lower() + result = json.dumps({ + "agent": "component_orders", "action": "order_processed", + "part_count": len(parts), "quantity": qty, "has_bom": bom, + "namespace": "cleveractors.orders" + }) diff --git a/tests/fixtures/email_graph/namespaced/email_categorizer.yaml b/tests/fixtures/email_graph/namespaced/email_categorizer.yaml new file mode 100644 index 0000000..d905454 --- /dev/null +++ b/tests/fixtures/email_graph/namespaced/email_categorizer.yaml @@ -0,0 +1,21 @@ +name: email_categorizer +type: tool +namespace: cleveractors.email +config: + tools: + - name: categorize_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + subject = data.get("subject", "") + body = data.get("body", "") + body_lower = body.lower() + if any(kw in body_lower for kw in ["order", "component", "part number", "purchase", "bom", "resistor", "capacitor"]): + category = "component_order" + elif any(kw in body_lower for kw in ["personal", "family", "vacation", "weekend", "party", "birthday"]): + category = "personal" + elif any(kw in body_lower for kw in ["business", "meeting", "quarterly", "report", "strategy", "revenue"]): + category = "business" + else: + category = "unknown" + result = json.dumps({"category": category, "subject": subject, "namespace": "cleveractors.email"}) diff --git a/tests/fixtures/email_graph/namespaced/email_graph.yaml b/tests/fixtures/email_graph/namespaced/email_graph.yaml new file mode 100644 index 0000000..abaf669 --- /dev/null +++ b/tests/fixtures/email_graph/namespaced/email_graph.yaml @@ -0,0 +1,40 @@ +name: email_categorization_graph +namespace: cleveractors.email_graph +description: "6-agent email categorization graph assembled from namespaced components" + +routes: + main: + entry_point: "email_categorizer" + parallel_execution: false + nodes: + email_categorizer: + id: "email_categorizer" + agent_ref: "local:email_categorizer.yaml" + component_orders: + id: "component_orders" + agent_ref: "local:component_orders.yaml" + personal_summarizer: + id: "personal_summarizer" + agent_ref: "local:personal_summarizer.yaml" + business_email: + id: "business_email" + agent_ref: "local:business_email.yaml" + supplier: + id: "supplier" + agent_ref: "local:supplier.yaml" + client: + id: "client" + agent_ref: "local:client.yaml" + edges: + - source: "email_categorizer" + target: "component_orders" + - source: "email_categorizer" + target: "personal_summarizer" + - source: "email_categorizer" + target: "business_email" + - source: "business_email" + target: "supplier" + - source: "business_email" + target: "client" + +actors: {} diff --git a/tests/fixtures/email_graph/namespaced/personal_summarizer.yaml b/tests/fixtures/email_graph/namespaced/personal_summarizer.yaml new file mode 100644 index 0000000..8729ab7 --- /dev/null +++ b/tests/fixtures/email_graph/namespaced/personal_summarizer.yaml @@ -0,0 +1,18 @@ +name: personal_summarizer +type: tool +namespace: cleveractors.personal +config: + tools: + - name: summarize_personal_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = data.get("body", "") + words = body.split() + lines = body.strip().split("\n") + has_action_items = any(kw in body.lower() for kw in ["don't forget", "reminder", "rsvp", "please bring"]) + result = json.dumps({ + "agent": "personal_summarizer", "action": "summarized", + "word_count": len(words), "line_count": len(lines), + "has_action_items": has_action_items, "namespace": "cleveractors.personal" + }) diff --git a/tests/fixtures/email_graph/namespaced/supplier.yaml b/tests/fixtures/email_graph/namespaced/supplier.yaml new file mode 100644 index 0000000..21e35b5 --- /dev/null +++ b/tests/fixtures/email_graph/namespaced/supplier.yaml @@ -0,0 +1,19 @@ +name: supplier +type: tool +namespace: cleveractors.supplier +config: + tools: + - name: process_supplier_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + body_lower = body.lower() + rfq = "rfq" in body_lower or "request for quote" in body_lower + delivery = any(kw in body_lower for kw in ["delivery", "shipment", "eta", "tracking"]) + invoice = "invoice" in body_lower or "payment" in body_lower + result = json.dumps({ + "agent": "supplier", "action": "processed", + "is_rfq": rfq, "is_delivery": delivery, "is_invoice": invoice, + "namespace": "cleveractors.supplier" + }) diff --git a/tests/fixtures/email_graph/remote_graph.yaml b/tests/fixtures/email_graph/remote_graph.yaml new file mode 100644 index 0000000..784264d --- /dev/null +++ b/tests/fixtures/email_graph/remote_graph.yaml @@ -0,0 +1,150 @@ +# Remote registry graph -- actor configs referenced via package IDs +# simulating retrieval from a remote package registry. + +routes: + main: + entry_point: "email_categorizer" + parallel_execution: false + nodes: + email_categorizer: + id: "email_categorizer" + agent: "email_categorizer" + component_orders: + id: "component_orders" + agent: "component_orders" + personal_summarizer: + id: "personal_summarizer" + agent: "personal_summarizer" + business_email: + id: "business_email" + agent: "business_email" + supplier: + id: "supplier" + agent: "supplier" + client: + id: "client" + agent: "client" + edges: + - source: "email_categorizer" + target: "component_orders" + - source: "email_categorizer" + target: "personal_summarizer" + - source: "email_categorizer" + target: "business_email" + - source: "business_email" + target: "supplier" + - source: "business_email" + target: "client" + +# All actors defined as tool agents -- simulating registry-retrieved configs. +# In production these would be resolved from a remote registry via +# ReferenceResolver; for integration tests the resolved configs are inlined. +actors: + email_categorizer: + type: tool + config: + tools: + - name: categorize_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + subject = data.get("subject", "") + body = data.get("body", "") + body_lower = body.lower() + if any(kw in body_lower for kw in ["order", "component", "part number", "purchase"]): + category = "component_order" + elif any(kw in body_lower for kw in ["personal", "family", "vacation"]): + category = "personal" + elif any(kw in body_lower for kw in ["business", "meeting", "quarterly"]): + category = "business" + else: + category = "unknown" + result = json.dumps({"category": category, "subject": subject, "source": "registry"}) + + component_orders: + type: tool + config: + tools: + - name: process_component_order + code: | + import json, re + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + parts = re.findall(r'[A-Z]{2,4}-\d{3,6}', body) + result = json.dumps({ + "agent": "component_orders", + "action": "order_processed", + "parts_found": parts, + "part_count": len(parts), + "source": "registry" + }) + + personal_summarizer: + type: tool + config: + tools: + - name: summarize_personal_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = data.get("body", "") + words = body.split() + result = json.dumps({ + "agent": "personal_summarizer", + "action": "summarized", + "word_count": len(words), + "source": "registry" + }) + + business_email: + type: tool + config: + tools: + - name: process_business_email + code: | + import json, re + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = data.get("body", "") + meetings = re.findall(r'(?:meeting|call|conference|sync)\s*(?:on|at|:)\s*([^\n,]{5,40})', body, re.I) + result = json.dumps({ + "agent": "business_email", + "action": "processed", + "meeting_count": len(meetings), + "source": "registry" + }) + + supplier: + type: tool + config: + tools: + - name: process_supplier_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + body_lower = body.lower() + result = json.dumps({ + "agent": "supplier", + "action": "processed", + "is_rfq": "rfq" in body_lower, + "is_delivery": "delivery" in body_lower or "shipment" in body_lower, + "source": "registry" + }) + + client: + type: tool + config: + tools: + - name: process_client_email + code: | + import json + data = json.loads(input_data) if isinstance(input_data, str) else input_data + body = str(data) + body_lower = body.lower() + result = json.dumps({ + "agent": "client", + "action": "processed", + "is_support": "support" in body_lower or "issue" in body_lower, + "is_proposal": "proposal" in body_lower or "bid" in body_lower, + "source": "registry" + }) -- 2.52.0 From 57b1a2e70ee68286835acf16613a2f866f3a2946 Mon Sep 17 00:00:00 2001 From: CoreRasurae Date: Fri, 12 Jun 2026 20:19:18 +0000 Subject: [PATCH 2/3] test(unit): increase BDD coverage to 97% for registry, LLM, YAML template error branches Adds 4 Behave BDD feature files with 15 scenarios covering previously untested error-path branches: - registry_resolver_errors.feature (7 scenarios): Invalid semver, empty version lists, alias mismatches, ID/local/unknown reference error handling - llm_agent_config_validation.feature (4 scenarios): Empty name, non-dict config, non-TemplateRenderer, non-str credential key validation - llm_provider_url_canonicalization.feature (2 scenarios): URL canonicalization with explicit port and scheme lowercasing - yaml_template_non_dict_errors.feature (2 scenarios): Non-dict YAML parse results in template engine and inline Jinja handler Coverage: 96.8% -> 97.1% --- features/llm_agent_config_validation.feature | 23 +++ .../llm_provider_url_canonicalization.feature | 15 ++ features/registry_resolver_errors.feature | 38 +++++ .../llm_agent_config_validation_steps.py | 85 ++++++++++++ ...llm_provider_url_canonicalization_steps.py | 39 ++++++ .../steps/registry_resolver_errors_steps.py | 131 ++++++++++++++++++ .../yaml_template_non_dict_errors_steps.py | 61 ++++++++ .../yaml_template_non_dict_errors.feature | 17 +++ 8 files changed, 409 insertions(+) create mode 100644 features/llm_agent_config_validation.feature create mode 100644 features/llm_provider_url_canonicalization.feature create mode 100644 features/registry_resolver_errors.feature create mode 100644 features/steps/llm_agent_config_validation_steps.py create mode 100644 features/steps/llm_provider_url_canonicalization_steps.py create mode 100644 features/steps/registry_resolver_errors_steps.py create mode 100644 features/steps/yaml_template_non_dict_errors_steps.py create mode 100644 features/yaml_template_non_dict_errors.feature diff --git a/features/llm_agent_config_validation.feature b/features/llm_agent_config_validation.feature new file mode 100644 index 0000000..f6d5b65 --- /dev/null +++ b/features/llm_agent_config_validation.feature @@ -0,0 +1,23 @@ +Feature: LLM Agent Configuration Validation + As a developer creating LLM agents + I want the agent constructor to validate all required arguments + so that misconfiguration is caught at creation time with clear error messages. + + Background: + Given the llm agent test environment is set up + + Scenario: Creating an LLM agent with an empty name raises ConfigurationError + When an LLM agent is created with empty name + Then LLMConfig: ConfigurationError raised containing "name must be a non-empty string" + + Scenario: Creating an LLM agent with a non-dict config raises ConfigurationError + When an LLM agent is created with config type "list" + Then LLMConfig: ConfigurationError raised containing "config must be a dict" + + Scenario: Creating an LLM agent with a non-TemplateRenderer raises ConfigurationError + When an LLM agent is created with a non-TemplateRenderer renderer + Then LLMConfig: ConfigurationError raised containing "template_renderer" + + Scenario: Creating an LLM agent with non-string credential key raises ConfigurationError + When an LLM agent is created with credentials containing an integer key + Then LLMConfig: ConfigurationError raised containing "credentials key must be a str" diff --git a/features/llm_provider_url_canonicalization.feature b/features/llm_provider_url_canonicalization.feature new file mode 100644 index 0000000..cc49070 --- /dev/null +++ b/features/llm_provider_url_canonicalization.feature @@ -0,0 +1,15 @@ +Feature: URL Canonicalization and Provider Validation + As a developer integrating LLM providers + I want base URLs to be canonicalized with consistent casing and port handling + so that duplicate provider registrations are detected regardless of URL formatting. + + Background: + Given the url canonicalization module is imported + + Scenario: URL with explicit port is preserved in canonical form + When _validate_base_url is called with "https://api.example.com:8080/v1/resource?q=1#section" + Then the canonical form should have the port and the hostname lowercased + + Scenario: URL scheme is lowercased in canonical form + When _validate_base_url is called with "HTTPS://api.example.com:9090/v1/resource?query=value#fragment" + Then the canonical scheme should be lowercased diff --git a/features/registry_resolver_errors.feature b/features/registry_resolver_errors.feature new file mode 100644 index 0000000..3d87ac1 --- /dev/null +++ b/features/registry_resolver_errors.feature @@ -0,0 +1,38 @@ +Feature: Package Registry Version Resolution Error Branches + As a developer using the Package Registry Standard v1.0.0 + I want version resolution to raise clear errors for invalid inputs + so that callers receive structured diagnostics for all edge cases. + + Background: + Given the registry resolver module is imported + + Scenario: Invalid semver string raises InvalidPackageReferenceError + When the resolver parses semver "not-valid-semver" + Then ResErr: InvalidPackageReferenceError raised containing "Not a valid semver" + + Scenario: Alias resolution against empty version list raises error + When resolve_version is called with alias "latest" against empty available versions + Then ResErr: InvalidPackageReferenceError raised containing "No available versions to resolve" + + Scenario: Major alias with no matching versions raises error + When resolve_version is called with alias "v5.x" against available versions "v1.0.0,v2.0.0" + Then ResErr: InvalidPackageReferenceError raised containing "No versions match" + + Scenario: Minor alias with no matching versions raises error + When resolve_version is called with alias "v1.5.x" against available versions "v1.0.0,v1.1.0,v2.0.0" + Then ResErr: InvalidPackageReferenceError raised containing "No versions match" + + Scenario: ID reference without id_string raises error during resolution + Given a reference resolver is created without client and without local store + When resolving reference "ID:" with resolver + Then an InvalidPackageReferenceError should be raised + + Scenario: Registry reference without namespace raises error during resolution + Given a reference resolver is created without client and without local store + When resolving registry reference "server.com:@v1.0.0" with resolver + Then an InvalidPackageReferenceError should be raised + + Scenario: Unknown reference type raises InvalidPackageReferenceError + Given a reference resolver is created without client and without local store + When resolving an unknown reference type with resolver + Then ResErr: InvalidPackageReferenceError raised containing "Unknown" diff --git a/features/steps/llm_agent_config_validation_steps.py b/features/steps/llm_agent_config_validation_steps.py new file mode 100644 index 0000000..71df6fe --- /dev/null +++ b/features/steps/llm_agent_config_validation_steps.py @@ -0,0 +1,85 @@ +"""Step definitions for LLM agent configuration validation. Covers +``src/cleveractors/agents/llm.py`` constructor argument validation branches. +""" + +from __future__ import annotations + +from behave import given, then, when + +from cleveractors.core.exceptions import ConfigurationError +from cleveractors.templates.renderer import TemplateRenderer + + +@given("the llm agent test environment is set up") +def step_setup_llm(context: object) -> None: + pass + + +@when("an LLM agent is created with empty name") +def step_create_llm_agent_empty_name(context: object) -> None: + import json + + from cleveractors.agents.llm import LLMAgent + + renderer = TemplateRenderer() + try: + LLMAgent(name="", config={}, template_renderer=renderer) + context._last_exc = None + except Exception as exc: + context._last_exc = exc + + +@when("an LLM agent is created with config type {config_type}") +def step_create_llm_agent_invalid_config(context: object, config_type: str) -> None: + from cleveractors.agents.llm import LLMAgent + + renderer = TemplateRenderer() + if config_type == "list": + bad_config: object = [] + else: + bad_config = config_type + try: + LLMAgent(name="test", config=bad_config, template_renderer=renderer) # type: ignore[arg-type] + context._last_exc = None + except Exception as exc: + context._last_exc = exc + + +@when("an LLM agent is created with a non-TemplateRenderer renderer") +def step_create_llm_agent_invalid_renderer(context: object) -> None: + from cleveractors.agents.llm import LLMAgent + + try: + LLMAgent(name="test", config={}, template_renderer="not-renderer") # type: ignore[arg-type] + context._last_exc = None + except Exception as exc: + context._last_exc = exc + + +@when("an LLM agent is created with credentials containing an integer key") +def step_create_llm_agent_int_cred_key(context: object) -> None: + from cleveractors.agents.llm import LLMAgent + + renderer = TemplateRenderer() + try: + LLMAgent( + name="test", + config={}, + template_renderer=renderer, + credentials={1: "value"}, # type: ignore[dict-item] + ) + context._last_exc = None + except Exception as exc: + context._last_exc = exc + + +@then('LLMConfig: ConfigurationError raised containing "{phrase}"') +def step_assert_llm_config_error(context: object, phrase: str) -> None: + assert context._last_exc is not None, "Expected an exception but none was raised" + assert isinstance(context._last_exc, ConfigurationError), ( + f"Expected ConfigurationError, got {type(context._last_exc)}: " + f"{context._last_exc}" + ) + assert phrase in str(context._last_exc), ( + f"Expected '{phrase}' in error message, got: {context._last_exc}" + ) diff --git a/features/steps/llm_provider_url_canonicalization_steps.py b/features/steps/llm_provider_url_canonicalization_steps.py new file mode 100644 index 0000000..d78076f --- /dev/null +++ b/features/steps/llm_provider_url_canonicalization_steps.py @@ -0,0 +1,39 @@ +"""Step definitions for URL canonicalization coverage. Covers +``src/cleveractors/agents/llm_providers.py`` port handling and +scheme lowercasing paths. +""" + +from __future__ import annotations + +from behave import given, then, when + + +@given("the url canonicalization module is imported") +def step_import_canonicalizer(context: object) -> None: + pass + + +@when('_validate_base_url is called with "{url}"') +def step_validate_base_url(context: object, url: str) -> None: + from cleveractors.agents.llm_providers import _validate_base_url + + canonical_url, hostname = _validate_base_url(url) + context._canonical_url = canonical_url + context._hostname = hostname + + +@then("the canonical form should have the port and the hostname lowercased") +def step_assert_port_and_hostname(context: object) -> None: + assert ":8080" in context._canonical_url, ( + f"Expected port :8080 in canonical URL, got: {context._canonical_url}" + ) + assert context._hostname == context._hostname.lower(), ( + f"Expected hostname to be lowercased, got: {context._hostname}" + ) + + +@then("the canonical scheme should be lowercased") +def step_assert_scheme_lowercased(context: object) -> None: + assert context._canonical_url.startswith("https://"), ( + f"Expected scheme to be lowercased, got: {context._canonical_url}" + ) diff --git a/features/steps/registry_resolver_errors_steps.py b/features/steps/registry_resolver_errors_steps.py new file mode 100644 index 0000000..16222ef --- /dev/null +++ b/features/steps/registry_resolver_errors_steps.py @@ -0,0 +1,131 @@ +"""Step definitions for registry resolver error branches. Covers +``src/cleveractors/registry/resolver.py`` lines missed by existing tests. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from behave import given, then, when + +from cleveractors.registry.exceptions import InvalidPackageReferenceError +from cleveractors.registry.resolver import ( + ReferenceResolver, + _parse_semver, + resolve_version, +) + + +@given("the registry resolver module is imported") +def step_import_resolver(context: object) -> None: + pass + + +@when('the resolver parses semver "{version}"') +def step_parse_semver(context: object, version: str) -> None: + try: + _parse_semver(version) + context._last_exc = None + except InvalidPackageReferenceError as exc: + context._last_exc = exc + + +@when('resolve_version is called with alias "{alias}" against empty available versions') +def step_resolve_empty_versions(context: object, alias: str) -> None: + try: + resolve_version(alias, []) + context._last_exc = None + except InvalidPackageReferenceError as exc: + context._last_exc = exc + + +@when( + 'resolve_version is called with alias "{alias}" against available versions "{versions}"' +) +def step_resolve_mismatched_versions( + context: object, alias: str, versions: str +) -> None: + version_list = [v.strip() for v in versions.split(",")] + try: + resolve_version(alias, version_list) + context._last_exc = None + except InvalidPackageReferenceError as exc: + context._last_exc = exc + + +@given("a reference resolver is created without client and without local store") +def step_resolver_no_client_no_store(context: object) -> None: + context._resolver = ReferenceResolver(client=None, local_store=None) + + +@when('resolving reference "{ref_str}" with resolver') +def step_resolve_ref(context: object, ref_str: str) -> None: + import asyncio + + async def _resolve() -> None: + try: + await context._resolver.resolve(ref_str) + context._last_exc = None + except InvalidPackageReferenceError as exc: + context._last_exc = exc + + asyncio.run(_resolve()) + + +@when("resolving an unknown reference type with resolver") +def step_resolve_unknown_ref_type(context: object) -> None: + import asyncio + + from cleveractors.registry.types import PackageReference + + ref_obj = PackageReference( + original_reference="test:ref", + reference_type="custom_unknown_type", + ) # type: ignore[arg-type] + + async def _resolve() -> None: + original_parse = ReferenceResolver.parse + + def _mock_parse(_self: object, _ref_str: str) -> PackageReference: + return ref_obj + + ReferenceResolver.parse = _mock_parse # type: ignore[method-assign] + try: + await context._resolver.resolve("any") + except InvalidPackageReferenceError as exc: + context._last_exc = exc + finally: + ReferenceResolver.parse = original_parse # type: ignore[method-assign] + + asyncio.run(_resolve()) + + +@when('resolving registry reference "{ref_str}" with resolver') +def step_resolve_registry_ref(context: object, ref_str: str) -> None: + import asyncio + + async def _resolve() -> None: + try: + await context._resolver.resolve(ref_str) + context._last_exc = None + except InvalidPackageReferenceError as exc: + context._last_exc = exc + + asyncio.run(_resolve()) + + +@then('ResErr: InvalidPackageReferenceError raised containing "{phrase}"') +def step_assert_reserr_error(context: object, phrase: str) -> None: + assert context._last_exc is not None, "Expected an exception but none was raised" + assert phrase in str(context._last_exc), ( + f"Expected '{phrase}' in error message, got: {context._last_exc}" + ) + + +@then("an InvalidPackageReferenceError should be raised") +def step_assert_error_raised(context: object) -> None: + assert context._last_exc is not None, "Expected an exception but none was raised" + assert isinstance(context._last_exc, InvalidPackageReferenceError), ( + f"Expected InvalidPackageReferenceError, got {type(context._last_exc)}: " + f"{context._last_exc}" + ) diff --git a/features/steps/yaml_template_non_dict_errors_steps.py b/features/steps/yaml_template_non_dict_errors_steps.py new file mode 100644 index 0000000..c03b73b --- /dev/null +++ b/features/steps/yaml_template_non_dict_errors_steps.py @@ -0,0 +1,61 @@ +"""Step definitions for YAML template non-dict error paths. Covers +``src/cleveractors/templates/yaml_template_engine.py`` and +``src/cleveractors/templates/inline_yaml_jinja.py`` ValueError branches +for template output that parses to non-dict YAML. +""" + +from __future__ import annotations + +from behave import given, then, when + + +@given("the yaml template engine is set up") +def step_setup_yaml(context: object) -> None: + pass + + +@given("the yaml_template_engine renderer is created") +def step_create_engine(context: object) -> None: + from cleveractors.templates.yaml_template_engine import YAMLTemplateEngine + + context._engine = YAMLTemplateEngine() + + +@given("the inline yaml jinja handler is created") +def step_create_inline_handler(context: object) -> None: + from cleveractors.templates.inline_yaml_jinja import InlineYAMLJinja + + context._handler = InlineYAMLJinja() + + +@when("rendering a template that produces a YAML list instead of a dict") +def step_render_list_template(context: object) -> None: + template_config = {"_raw_template": "{{ items }}"} + render_context = {"items": "[1, 2, 3]"} + try: + context._engine.render_template(template_config, render_context) + context._last_exc = None + except ValueError as exc: + context._last_exc = exc + + +@when("processing inline YAML Jinja that renders to a non-dict value") +def step_process_inline_list(context: object) -> None: + yaml_content = "{{ value }}" + render_ctx = {"value": "- item_one\n- item_two"} + try: + context._handler.process_string(yaml_content, render_ctx) + context._last_exc = None + except ValueError as exc: + context._last_exc = exc + + +@then('YTpl: ValueError raised containing "{phrase}"') +def step_assert_ytpl_value_error(context: object, phrase: str) -> None: + assert context._last_exc is not None, "Expected an exception but none was raised" + assert isinstance(context._last_exc, ValueError), ( + f"Expected ValueError, got {type(context._last_exc)}: {context._last_exc}" + ) + assert phrase in str(context._last_exc), ( + f"Expected '{phrase}' in error message, got: {context._last_exc}" + ) diff --git a/features/yaml_template_non_dict_errors.feature b/features/yaml_template_non_dict_errors.feature new file mode 100644 index 0000000..036a9ac --- /dev/null +++ b/features/yaml_template_non_dict_errors.feature @@ -0,0 +1,17 @@ +Feature: YAML Template Engine Non-Dict Error Handling + As a developer using Jinja2 YAML templates + I want template rendering to raise clear errors when the rendered YAML is not a dict + so that structural misconfiguration is caught early. + + Background: + Given the yaml template engine is set up + + Scenario: Rendering a template that produces a YAML list raises ValueError + Given the yaml_template_engine renderer is created + When rendering a template that produces a YAML list instead of a dict + Then YTpl: ValueError raised containing "Expected YAML to parse as dict" + + Scenario: Inline YAML Jinja handler rejects non-dict rendered output + Given the inline yaml jinja handler is created + When processing inline YAML Jinja that renders to a non-dict value + Then YTpl: ValueError raised containing "Expected rendered YAML to parse as dict" -- 2.52.0 From 06063a9602f8f08c3e298461d7351aafa2111c5f Mon Sep 17 00:00:00 2001 From: CoreRasurae Date: Fri, 12 Jun 2026 21:22:46 +0000 Subject: [PATCH 3/3] fix(build): cap robotframework<7.0 for pabot 5.3 compatibility The CI server has pabot up to 5.3.0b1 only. Pabot 5.x does not support Robot Framework 7.x which removed the robot.errors module. Capping robotframework at <7.0 ensures pabot can import robot.errors. --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 21253d9..e7cd226 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,8 +59,8 @@ tests = [ "behave==1.3.3", "slipcover>=1.0.17", "asv>=0.6.5", - "robotframework>=7.3.2", - "robotframework-pabot>=4.0.0", + "robotframework>=6.0,<7.0", + "robotframework-pabot>=5.3.0b1", "faker>=20.0.0", "pystache>=0.6.0", ] -- 2.52.0