test(robot): add end-to-end integration tests for email categorization actor graph #50
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"
|
||||
+6
-2
@@ -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", "")
|
||||
|
||||
+2
-2
@@ -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",
|
||||
]
|
||||
|
||||
+23
-1083
File diff suppressed because it is too large
Load Diff
@@ -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())}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Per-module keyword libraries for cleveractors Robot Framework integration tests."""
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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": ""}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
})
|
||||
@@ -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"
|
||||
})
|
||||
@@ -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"
|
||||
})
|
||||
@@ -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"})
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
})
|
||||
@@ -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"
|
||||
})
|
||||
+168
@@ -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"
|
||||
})
|
||||
+135
@@ -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"
|
||||
})
|
||||
@@ -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"
|
||||
})
|
||||
@@ -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"
|
||||
})
|
||||
@@ -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"
|
||||
})
|
||||
@@ -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"})
|
||||
@@ -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: {}
|
||||
@@ -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"
|
||||
})
|
||||
@@ -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"
|
||||
})
|
||||
+150
@@ -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"
|
||||
})
|
||||
Reference in New Issue
Block a user