From 149feae15f8e601c5e78d2e811bfdbc89f644b85 Mon Sep 17 00:00:00 2001 From: Rui Hu Date: Wed, 3 Jun 2026 07:55:51 +0000 Subject: [PATCH] feat(validate_dict): expose public validate_dict(config_dict, platform_limits) API Implement validate_dict() in cleveractors/validation.py and export it from cleveractors/__init__.py as the first router-facing API (ADR-2024, Wave 1). validate_dict(config_dict, platform_limits) -> dict[str, Any]: - Validates that both 'agents' and 'routes' top-level keys are present. - Validates each agent's type against the spec-defined vocabulary: {llm, tool, composite, chain, template_instance}. - Validates LLM agent 'provider' values against platform_limits['allowed_providers'] when present, or the default allowlist {openai, anthropic, google} when absent. - Validates route/edge field names: 'from'/'to' are rejected in favour of 'source'/'target' as required by the spec-conformant format (ADR-2025). - Validates graph node types against the spec vocabulary: {start, end, agent, function, tool, conditional, subgraph, message_router}. - Validates stream operator types against the spec vocabulary (all defined types). - Enforces structural platform limits from platform_limits when present: max_graph_depth (DFS longest-path check), max_subgraph_depth (recursive subgraph-reference depth), max_total_nodes (total nodes across all graph routes). - Returns config_dict unchanged when all checks pass (identity contract). - Pure static validator: no file I/O, no env-var reads, no app construction. Review fixes (Cycle 1): - M3: Added isinstance(node_name, str) guard for graph node keys in _validate_graph_route, with BDD scenario for non-string node key. - m1: Added # pragma: no branch to _count_total_nodes defensive guard in _limits.py (matching the identical guards in _subgraph_children). - m2: Added # pragma: no branch to dead defensive continue branches in _validate_graph_route edge validation loop. - m3: Added dedicated BDD scenario and step for dangling source node (src not in nodes_raw branch), plus renamed existing scenario from "source node" to "target node" for accuracy. - m4: Deleted 4 lines of commented-out InlineYAMLJinja setup code in features/environment.py. - n1: Added depth <= 0 ValueError guard in build_subgraph_chain helper. - n2: Renamed scenario "Edge referencing a non-existent source node" to "Edge referencing a non-existent target node" (the step tested a target). - n3: Changed > to >= in both _MAX_DFS_STEPS and _MAX_SUBGRAPH_STEPS guards to prevent off-by-one allowing one extra iteration beyond documented ceiling. - n4: Added adjacency target deduplication in _compute_graph_depth to avoid wasting DFS step budget on parallel edges. - Spec review: M1 (entry_point optional) and M2 (start/end implicit injection) were verified against the authoritative spec at docs/specification.md in the cleveragents-webapp repo (develop branch). The spec does NOT support either claim; entry_point is required and start/end must be explicitly declared. No changes needed for M1/M2. Review fixes (Cycle 2): - M1: Deduplicated _subgraph_children yields with a seen set to prevent duplicate subgraph references from consuming DFS steps redundantly and causing false "too complex" errors. - M2: Changed adjacency value type from list[str] to set[str] in _compute_graph_depth for O(1) deduplication, reducing worst case from O(N^3) to O(N^2) for dense graphs. - m1: Added BDD scenario for LLM agent without provider field failing when default "openai" is not in custom allowlist. - m2: Added positive BDD scenarios for valid node types start, tool, conditional, message_router, function. - m3: Added positive BDD scenarios for additional stream operator types filter, transform, reduce. - m5: Added depth_cache memoization to _compute_subgraph_depth to avoid recomputing depths across graph routes sharing subgraph trees. - m6: Moved cleveractors.validation._limits import from module level into after_scenario, guarded by hasattr. - n1: Split validate_dict_routes_steps.py (was 473 lines) into validate_dict_routes_steps.py (generic route steps) and validate_dict_graph_route_steps.py (graph-specific steps). - n2: Updated _MAX_DFS_STEPS module comment to accurately state O(N+E) complexity and that dense graphs can exhaust the ceiling quickly. BDD Behave scenarios cover all requirements. Robot Framework integration tests (robot/validate_dict.robot) cover API usage. ISSUES CLOSED: #10 --- .gitignore | 1 + CHANGELOG.md | 1 + features/environment.py | 18 +- features/steps/validate_dict_agents_steps.py | 341 +++++++ features/steps/validate_dict_export_steps.py | 45 + .../steps/validate_dict_graph_route_steps.py | 447 +++++++++ features/steps/validate_dict_helpers.py | 165 ++++ features/steps/validate_dict_limits_steps.py | 364 ++++++++ features/steps/validate_dict_routes_steps.py | 83 ++ features/steps/validate_dict_steps.py | 385 ++++++++ features/steps/validate_dict_stream_steps.py | 96 ++ .../steps/validate_dict_subgraph_steps.py | 110 +++ features/validate_dict.feature | 878 ++++++++++++++++++ robot/CleverActorsLib.py | 27 +- robot/ValidateDictLib.py | 70 ++ robot/validate_dict.robot | 188 ++++ src/cleveractors/__init__.py | 15 +- src/cleveractors/runtime.py | 154 +-- src/cleveractors/validation/__init__.py | 89 ++ src/cleveractors/validation/_agents.py | 116 +++ src/cleveractors/validation/_limits.py | 239 +++++ src/cleveractors/validation/_routes.py | 247 +++++ src/cleveractors/validation/_vocabulary.py | 92 ++ 23 files changed, 4035 insertions(+), 136 deletions(-) create mode 100644 features/steps/validate_dict_agents_steps.py create mode 100644 features/steps/validate_dict_export_steps.py create mode 100644 features/steps/validate_dict_graph_route_steps.py create mode 100644 features/steps/validate_dict_helpers.py create mode 100644 features/steps/validate_dict_limits_steps.py create mode 100644 features/steps/validate_dict_routes_steps.py create mode 100644 features/steps/validate_dict_steps.py create mode 100644 features/steps/validate_dict_stream_steps.py create mode 100644 features/steps/validate_dict_subgraph_steps.py create mode 100644 features/validate_dict.feature create mode 100644 robot/ValidateDictLib.py create mode 100644 robot/validate_dict.robot create mode 100644 src/cleveractors/validation/__init__.py create mode 100644 src/cleveractors/validation/_agents.py create mode 100644 src/cleveractors/validation/_limits.py create mode 100644 src/cleveractors/validation/_routes.py create mode 100644 src/cleveractors/validation/_vocabulary.py diff --git a/.gitignore b/.gitignore index d9c06cf..ba9e5e3 100644 --- a/.gitignore +++ b/.gitignore @@ -184,3 +184,4 @@ agents-test # Generated test reports (CI artifacts) — build artifacts, not to be committed test_reports/ cleveractors-core-new2/ +pretty.output diff --git a/CHANGELOG.md b/CHANGELOG.md index c18b6cb..d9c4157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Clever Semantic Versioning](https://www.w3.org/subm ### Added - **`merge_configs()` public API** (`cleveractors.merge_configs`): New module-level function implementing the Actor Configuration Standard §3.1 deep-merge algorithm. Accepts an arbitrary number of `dict[str, Any]` arguments and returns a fresh merged dict without mutating any input. Merge semantics: absent key → add; both mappings → deep-merge recursively; both sequences → append; otherwise → replace. Zero-argument call returns `{}`. Exported from `cleveractors.__init__` and `__all__`. +- **`validate_dict()` public API** (`cleveractors.validate_dict`): New router-facing validation function that validates a spec-conformant Actor Configuration Standard v1.0.0 dict against the full schema and enforces platform-level structural constraints. Validates top-level key presence (`agents`, `routes`), agent types, LLM provider allowlists, route/edge field names (`source`/`target` only — legacy `from`/`to` rejected), node types, stream operator types, and structural limits (`max_graph_depth`, `max_subgraph_depth`, `max_total_nodes`) from the supplied `platform_limits` dict. Returns the dict unchanged when valid; raises `ConfigurationError` on any violation. Pure static validator with no file I/O, env-var reads, or app construction. Exported from `cleveractors.__init__` and listed in `__all__`. (ADR-2024, ADR-2025, ADR-2029) - **Core CleverActors Framework**: New agent-based LLM orchestration framework implementing the Actor Configuration Standard (§1-4). Includes agent base class, factory pattern for agent creation, configuration management, template rendering engine, and exception hierarchy. - **LLM Agent** (`type: llm`, §4.4): Agent backed by language models with support for OpenAI, Anthropic, and Google Gemini providers. Configurable temperature, max_tokens, system prompts, memory/history, and structured output (json_mode/response_format). - **Tool Agent** (`type: tool`, §4.5): Deterministic agent executing built-in tools (echo, math, json_parse, http_request, file_read, file_write, progress_bar) and custom inline code tools. Supports safe/unsafe execution modes, shell command filtering, and file operation sandboxing. diff --git a/features/environment.py b/features/environment.py index 4172c95..a0bd24c 100644 --- a/features/environment.py +++ b/features/environment.py @@ -30,7 +30,6 @@ def before_all(context): def after_all(context): """Clean up test environment after all tests.""" # Clean up temp directory - import shutil shutil.rmtree(context.temp_dir, ignore_errors=True) @@ -84,11 +83,6 @@ def before_scenario(context, scenario): os.environ.setdefault("ANTHROPIC_API_KEY", "test-key-anthropic") os.environ.setdefault("GOOGLE_API_KEY", "test-key-google") - # Set up test context for InlineYAMLJinja tests - not needed for simplified tests - # if "InlineYAMLJinja" in scenario.feature.name or "inline_yaml_jinja" in str(scenario.feature.filename): - # from tests.features.steps.inline_yaml_jinja_coverage_steps import TestContext - # context.test_context = TestContext() - def after_scenario(context, scenario): """Clean up after each scenario.""" @@ -165,8 +159,6 @@ def after_scenario(context, scenario): # Clean up files tracked for cleanup in tool_agent tests if hasattr(context, "__dict__") and "_cleanup_files" in context.__dict__: - import os - for filepath in context.__dict__["_cleanup_files"]: try: if os.path.exists(filepath): @@ -181,3 +173,13 @@ def after_scenario(context, scenario): shutil.rmtree(context.scenario_temp, ignore_errors=True) except Exception: pass + + # Restore monkey-patched validation constants + if hasattr(context, "_original_max_dfs"): + import cleveractors.validation._limits as _limits + + _limits._MAX_DFS_STEPS = context._original_max_dfs + if hasattr(context, "_original_max_subgraph"): + import cleveractors.validation._limits as _limits + + _limits._MAX_SUBGRAPH_STEPS = context._original_max_subgraph diff --git a/features/steps/validate_dict_agents_steps.py b/features/steps/validate_dict_agents_steps.py new file mode 100644 index 0000000..111406a --- /dev/null +++ b/features/steps/validate_dict_agents_steps.py @@ -0,0 +1,341 @@ +""" +Given/When steps for agent-related validate_dict scenarios. + +Tests agent type validation, LLM provider validation, and agent +definition edge cases. +""" + +from __future__ import annotations + +from typing import Any + +from behave import given, when +from behave.runner import Context +from features.steps.validate_dict_helpers import ( + call_and_capture, + minimal_config, +) + +from cleveractors import validate_dict +from cleveractors.core.exceptions import ConfigurationError + +# ── Background ────────────────────────────────────────────────────────── + + +@given("the validate_dict function is imported from cleveractors") +def step_import_validate_dict(context: Context) -> None: + """Initialize the test context with already-imported symbols.""" + context.validate_dict = validate_dict + context.ConfigurationError = ConfigurationError + context.raised_exception: Exception | None = None + context.result: dict[str, Any] | None = None + + +# ── Agent type Given steps ────────────────────────────────────────────── + + +@given("a minimal valid config dict with agents and routes") +def step_minimal_config(context: Context) -> None: + context.config_dict = minimal_config() + + +@given('a config dict without an "agents" key') +def step_config_without_agents(context: Context) -> None: + config = minimal_config() + del config["agents"] + context.config_dict = config + + +@given('a config dict without a "routes" key') +def step_config_without_routes(context: Context) -> None: + config = minimal_config() + del config["routes"] + context.config_dict = config + + +@given('a config dict with an agent of type "{agent_type}"') +def step_config_with_agent_type(context: Context, agent_type: str) -> None: + config = minimal_config() + config["agents"]["test_agent"] = {"type": agent_type, "config": {}} + context.config_dict = config + + +@given('a config dict with an llm agent using provider "{provider}"') +def step_config_with_llm_provider(context: Context, provider: str) -> None: + config = minimal_config() + config["agents"]["llm_agent"] = { + "type": "llm", + "config": {"provider": provider, "model": "test-model"}, + } + context.config_dict = config + + +@given('a config dict with an llm agent using provider "{provider}" with whitespace') +def step_config_with_llm_provider_whitespace(context: Context, provider: str) -> None: + config = minimal_config() + config["agents"]["llm_agent"] = { + "type": "llm", + "config": {"provider": provider, "model": "test-model"}, + } + context.config_dict = config + + +@given("a config dict with an llm agent without a provider field") +def step_config_llm_no_provider(context: Context) -> None: + config = minimal_config() + config["agents"]["llm_agent"] = { + "type": "llm", + "config": {"model": "gpt-3.5-turbo"}, + } + context.config_dict = config + + +@given("a config dict where agents is a list instead of a mapping") +def step_agents_is_list(context: Context) -> None: + context.config_dict = {"agents": ["llm", "tool"], "routes": {}} + + +@given("a config dict where an agent definition is a string instead of a mapping") +def step_agent_def_is_string(context: Context) -> None: + context.config_dict = { + "agents": {"bad_agent": "just_a_string"}, + "routes": {}, + } + + +@given("a config dict with an agent that has no type and no template key") +def step_agent_no_type_no_template(context: Context) -> None: + config = minimal_config() + config["agents"]["missing_type"] = {"config": {}} # no type, no template + context.config_dict = config + + +@given("a config dict with an agent that has a template key instead of type") +def step_agent_with_template_key(context: Context) -> None: + config = minimal_config() + config["agents"]["tmpl_agent"] = { + "template": "some_template", + "params": {"x": 1}, + } + context.config_dict = config + + +@given("a config dict with an llm agent that has a non-dict config") +def step_llm_agent_non_dict_config(context: Context) -> None: + config = minimal_config() + config["agents"]["llm_agent"] = { + "type": "llm", + "config": "not_a_dict", + } + context.config_dict = config + + +@given("a config dict with an agent that has an agent_template key instead of type") +def step_agent_with_agent_template_key(context: Context) -> None: + config = minimal_config() + config["agents"]["tmpl_agent"] = { + "agent_template": "some_template", + "params": {"x": 1}, + } + context.config_dict = config + + +@given('a config dict with an llm agent using provider "{provider}" in mixed case') +def step_config_llm_mixed_case_provider(context: Context, provider: str) -> None: + config = minimal_config() + config["agents"]["llm_agent"] = { + "type": "llm", + "config": {"provider": provider, "model": "test-model"}, + } + context.config_dict = config + + +@given("a config dict with an llm agent using a non-string provider value") +def step_llm_agent_non_string_provider(context: Context) -> None: + config = minimal_config() + config["agents"]["llm_agent"] = { + "type": "llm", + "config": {"provider": 123, "model": "test-model"}, + } + context.config_dict = config + + +@given("a config dict with an agent that has a non-string type") +def step_agent_non_string_type(context: Context) -> None: + config = minimal_config() + config["agents"]["bad_agent"] = {"type": ["llm"], "config": {}} + context.config_dict = config + + +@given('a config dict with platform_limits allowed_providers as a bare string "{raw}"') +def step_allowed_providers_bare_string(context: Context, raw: str) -> None: + config = minimal_config() + config["agents"]["llm_agent"] = { + "type": "llm", + "config": {"provider": "openai", "model": "test-model"}, + } + context.config_dict = config + context._allowed_providers_raw = raw + + +@given("a config dict with platform_limits containing a dict for allowed_providers") +def step_dict_allowed_providers(context: Context) -> None: + config = minimal_config() + config["agents"]["llm_agent"] = { + "type": "llm", + "config": {"provider": "openai", "model": "test-model"}, + } + context.config_dict = config + + +@given( + "a config dict with llm agent and allowed_providers containing a non-string element" +) +def step_mixed_type_allowed_providers(context: Context) -> None: + config = minimal_config() + config["agents"]["llm_agent"] = { + "type": "llm", + "config": {"provider": "openai", "model": "test-model"}, + } + context.config_dict = config + context._mixed_allowed_providers = ["openai", 123] + + +@given("a config dict with empty agents and valid routes") +def step_empty_agents(context: Context) -> None: + config = minimal_config() + config["agents"] = {} + context.config_dict = config + + +@given("a fully valid spec-conformant config dict") +def step_fully_valid_config(context: Context) -> None: + context.config_dict = minimal_config() + + +@given('a config dict with an agent named "{agent_name}"') +def step_config_with_agent_name(context: Context, agent_name: str) -> None: + config = minimal_config() + config["agents"][agent_name] = {"type": "llm", "config": {}} + context.config_dict = config + + +# ── Agent-related When steps ──────────────────────────────────────────── + + +@when("I call validate_dict with empty platform_limits") +def step_call_validate_empty_limits(context: Context) -> None: + call_and_capture(context, context.config_dict, {}) + + +@when( + "I call validate_dict with platform_limits containing allowed_providers {providers}" +) +def step_call_validate_with_providers(context: Context, providers: str) -> None: + providers = providers.strip().strip('"').strip("'") + allowed = [p.strip() for p in providers.split(",") if p.strip()] + call_and_capture(context, context.config_dict, {"allowed_providers": allowed}) + + +@when("I call validate_dict with platform_limits containing the bare string") +def step_call_validate_bare_string(context: Context) -> None: + call_and_capture( + context, + context.config_dict, + {"allowed_providers": context._allowed_providers_raw}, + ) + + +@when( + "I call validate_dict with platform_limits containing a dict as allowed_providers" +) +def step_call_validate_dict_allowed_providers(context: Context) -> None: + call_and_capture( + context, + context.config_dict, + {"allowed_providers": {"openai": True}}, + ) + + +@when("I call validate_dict with platform_limits containing the mixed-type list") +def step_call_validate_mixed_providers(context: Context) -> None: + call_and_capture( + context, + context.config_dict, + {"allowed_providers": context._mixed_allowed_providers}, + ) + + +@when("I call validate_dict with config_dict set to None and empty platform_limits") +def step_call_validate_none_config(context: Context) -> None: + call_and_capture(context, None, {}) + + +@when("I call validate_dict with platform_limits set to None") +def step_call_validate_none_platform_limits(context: Context) -> None: + call_and_capture(context, context.config_dict, None) + + +@when( + "I call validate_dict with config_dict set to an empty list and empty platform_limits" +) +def step_call_validate_list_config(context: Context) -> None: + call_and_capture(context, [], {}) + + +@when("I call validate_dict with platform_limits set to a string") +def step_call_validate_string_platform_limits(context: Context) -> None: + call_and_capture(context, context.config_dict, "not_a_dict") + + +@when( + "I call validate_dict with an allowed_providers set containing anthropic and openai" +) +def step_call_validate_set_providers(context: Context) -> None: + call_and_capture( + context, context.config_dict, {"allowed_providers": {"anthropic", "openai"}} + ) + + +@when( + "I call validate_dict with an allowed_providers frozenset containing anthropic and openai" +) +def step_call_validate_frozenset_providers(context: Context) -> None: + call_and_capture( + context, + context.config_dict, + {"allowed_providers": frozenset({"anthropic", "openai"})}, + ) + + +@when( + "I call validate_dict with an allowed_providers tuple containing anthropic and openai" +) +def step_call_validate_tuple_providers(context: Context) -> None: + call_and_capture( + context, + context.config_dict, + {"allowed_providers": ("anthropic", "openai")}, + ) + + +# ── Non-string key guard steps ───────────────────────────────────────── + + +@given("a config dict with a non-string agent key") +def step_non_string_agent_key(context: Context) -> None: + config = minimal_config() + # Add a non-string key (int) to the agents dict. + config["agents"][1] = {"type": "llm", "config": {}} + context.config_dict = config + + +@given("a config dict with a non-string route key") +def step_non_string_route_key(context: Context) -> None: + config = minimal_config() + # Add a non-string key (int) to the routes dict. + config["routes"][42] = { + "type": "stream", + "operators": [{"type": "map", "params": {"agent": "echo"}}], + } + context.config_dict = config diff --git a/features/steps/validate_dict_export_steps.py b/features/steps/validate_dict_export_steps.py new file mode 100644 index 0000000..2c0b2a1 --- /dev/null +++ b/features/steps/validate_dict_export_steps.py @@ -0,0 +1,45 @@ +""" +Steps for ConfigurationError export and import scenarios. +""" + +from __future__ import annotations + +import importlib + +from behave import then, when +from behave.runner import Context + +import cleveractors + + +@when("I import ConfigurationError from the cleveractors package") +def step_import_configuration_error(context: Context) -> None: + try: + module = importlib.import_module("cleveractors") + context.ce_class = module.ConfigurationError + context.ce_import_error = None + except (ImportError, AttributeError) as exc: + context.ce_class = None + context.ce_import_error = exc + + +@then("the import succeeds and ConfigurationError is a class") +def step_ce_import_succeeds(context: Context) -> None: + assert context.ce_import_error is None, ( + f"Expected import to succeed, got: {context.ce_import_error!r}" + ) + assert isinstance(context.ce_class, type), ( + f"Expected ConfigurationError to be a class, got: {context.ce_class!r}" + ) + + +@when("I check the cleveractors package __all__") +def step_check_all(context: Context) -> None: + context.all_list = cleveractors.__all__ + + +@then("ConfigurationError is listed in the __all__") +def step_ce_in_all(context: Context) -> None: + assert "ConfigurationError" in context.all_list, ( + f"Expected 'ConfigurationError' in __all__, got: {context.all_list}" + ) diff --git a/features/steps/validate_dict_graph_route_steps.py b/features/steps/validate_dict_graph_route_steps.py new file mode 100644 index 0000000..e621e56 --- /dev/null +++ b/features/steps/validate_dict_graph_route_steps.py @@ -0,0 +1,447 @@ +""" +Given steps for graph route validation scenarios. +""" + +from __future__ import annotations + +from behave import given +from behave.runner import Context +from features.steps.validate_dict_helpers import ( + minimal_config, +) + + +@given("a config dict with a graph route using source/target edges") +def step_config_graph_source_target(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "node_a", + "nodes": { + "node_a": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [ + {"source": "node_a", "target": "end"}, + ], + } + context.config_dict = config + + +@given('a config dict with a graph route edge using "from" instead of "source"') +def step_config_graph_edge_from(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "node_a", + "nodes": { + "node_a": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [ + {"from": "node_a", "target": "end"}, + ], + } + context.config_dict = config + + +@given('a config dict with a graph route edge using "to" instead of "target"') +def step_config_graph_edge_to(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "node_a", + "nodes": { + "node_a": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [ + {"source": "node_a", "to": "end"}, + ], + } + context.config_dict = config + + +@given('a config dict with a graph route containing a node of type "{node_type}"') +def step_config_graph_node_type(context: Context, node_type: str) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "my_node", + "nodes": { + "my_node": {"type": node_type, "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [ + {"source": "my_node", "target": "end"}, + ], + } + context.config_dict = config + + +@given( + 'a config dict with a graph route containing a node of type "{node_type}" in mixed case' +) +def step_config_graph_node_type_mixed(context: Context, node_type: str) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "my_node", + "nodes": { + "my_node": {"type": node_type, "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [ + {"source": "my_node", "target": "end"}, + ], + } + context.config_dict = config + + +# ── Defensive edge-case steps ────────────────────────────────────────── + + +@given("a config dict with a graph route where edges is not a list") +def step_graph_edges_not_list(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "n", + "nodes": {"n": {"type": "agent", "agent": "echo"}, "end": {"type": "end"}}, + "edges": "not_a_list", + } + context.config_dict = config + + +@given("a config dict with a graph route where one edge is a string") +def step_graph_edge_is_string(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "n", + "nodes": {"n": {"type": "agent", "agent": "echo"}, "end": {"type": "end"}}, + "edges": ["not_an_edge_dict", {"source": "n", "target": "end"}], + } + context.config_dict = config + + +@given("a config dict with a graph route where one node is a string") +def step_graph_node_is_string(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "agent", "agent": "echo"}, + "bad_node": "not_a_dict", + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + context.config_dict = config + + +@given("a config dict with a graph route where nodes is not a dict") +def step_graph_nodes_not_dict(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "n", + "nodes": ["not", "a", "dict"], + "edges": [], + } + context.config_dict = config + + +@given("a config dict with a graph route containing a non-string node type") +def step_graph_non_string_node_type(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "my_node", + "nodes": { + "my_node": {"type": 42, "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [ + {"source": "my_node", "target": "end"}, + ], + } + context.config_dict = config + + +@given("a config dict with a graph route where an edge lacks source and target") +def step_graph_edge_missing_source_target(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "node_a", + "nodes": { + "node_a": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [ + {"label": "missing fields"}, + ], + } + context.config_dict = config + + +@given("a config dict with a graph route missing the nodes key") +def step_graph_missing_nodes(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "start", + "edges": [{"source": "start", "target": "end"}], + } + context.config_dict = config + + +@given("a config dict with a graph route missing the edges key") +def step_graph_missing_edges(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "start", + "nodes": {"start": {"type": "agent", "agent": "echo"}, "end": {"type": "end"}}, + } + context.config_dict = config + + +@given("a config dict with a graph route whose entry_point is not a string") +def step_graph_non_string_entry_point(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": ["start"], + "nodes": { + "n": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + context.config_dict = config + + +@given("a config dict with a graph route that has no entry_point field") +def step_graph_no_entry_point(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "nodes": { + "n": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + context.config_dict = config + + +@given("a config dict with a graph route where an edge source is a list") +def step_graph_edge_source_list(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [ + {"source": ["a", "b"], "target": "end"}, + ], + } + context.config_dict = config + + +@given( + "a config dict with a graph route containing a subgraph node with empty reference" +) +def step_graph_subgraph_empty_ref(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "subgraph", "subgraph": ""}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + context.config_dict = config + + +@given( + "a config dict with a graph route containing a subgraph node with list reference" +) +def step_graph_subgraph_list_ref(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "subgraph", "subgraph": ["route_a", "route_b"]}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + context.config_dict = config + + +@given("a config dict with a graph route where an edge has an empty source") +def step_graph_edge_empty_source(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "node_a", + "nodes": { + "node_a": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [ + {"source": "", "target": "end"}, + ], + } + context.config_dict = config + + +@given("a config dict with a graph route where an edge has an empty target") +def step_graph_edge_empty_target(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "node_a", + "nodes": { + "node_a": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [ + {"source": "node_a", "target": ""}, + ], + } + context.config_dict = config + + +@given("a config dict with a graph route that has an empty entry_point string") +def step_graph_empty_entry_point(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "", + "nodes": { + "n": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + context.config_dict = config + + +@given("a config dict with a graph route where a node is missing the type key") +def step_graph_node_missing_type(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "a", + "nodes": { + "a": {"agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "a", "target": "end"}], + } + context.config_dict = config + + +@given("a config dict with a graph route where an edge references a non-existent node") +def step_graph_edge_dangling_target(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "real", + "nodes": { + "real": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [ + {"source": "real", "target": "ghost"}, + {"source": "ghost", "target": "end"}, + ], + } + context.config_dict = config + + +@given( + "a config dict with a graph route where an edge source references a non-existent node" +) +def step_graph_edge_dangling_source(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "real", + "nodes": { + "real": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [ + {"source": "ghost", "target": "real"}, + ], + } + context.config_dict = config + + +@given("a config dict with a graph route that has a non-string node key") +def step_graph_non_string_node_key(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "agent", "agent": "echo"}, + 42: {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + context.config_dict = config + + +# ── Review fix: duplicate subgraph references ────────────────────────── + + +@given( + "a config dict with a graph route containing two subgraph nodes referencing the same route" +) +def step_graph_duplicate_subgraph_refs(context: Context) -> None: + """Build a config with two nodes that reference the same subgraph route. + + This exercises the de-duplication logic in ``_subgraph_children``: + without it, the child route is yielded twice and explored redundantly, + wasting DFS step budget. + """ + config = minimal_config() + # A child graph route + config["routes"]["child_route"] = { + "type": "graph", + "entry_point": "w", + "nodes": { + "w": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "w", "target": "end"}], + } + # A parent graph route with two subgraph nodes both pointing to child_route + config["routes"]["parent_route"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "subgraph", "subgraph": "child_route"}, + "m": {"type": "subgraph", "subgraph": "child_route"}, + "end": {"type": "end"}, + }, + "edges": [ + {"source": "n", "target": "m"}, + {"source": "m", "target": "end"}, + ], + } + context.config_dict = config diff --git a/features/steps/validate_dict_helpers.py b/features/steps/validate_dict_helpers.py new file mode 100644 index 0000000..4ab610b --- /dev/null +++ b/features/steps/validate_dict_helpers.py @@ -0,0 +1,165 @@ +""" +Shared helper functions for validate_dict BDD step definitions. + +This module provides reusable config builders, the ``call_and_capture`` +invocation helper, and the ``minimal_config`` factory. It is imported +by the domain-specific step modules and is **not** itself a Behave step +file (it contains no @given/@when/@then decorators). +""" + +from __future__ import annotations + +import copy +from typing import Any + +from behave.runner import Context + +from cleveractors import validate_dict +from cleveractors.core.exceptions import ConfigurationError + +# ── Minimal test configs ─────────────────────────────────────────────────── + +MINIMAL_CONFIG: dict[str, Any] = { + "agents": { + "echo": { + "type": "tool", + "config": {"tools": ["echo"]}, + } + }, + "routes": { + "main": { + "type": "stream", + "operators": [{"type": "map", "params": {"agent": "echo"}}], + "publications": ["__output__"], + } + }, +} + + +def minimal_config() -> dict[str, Any]: + """Return a deep copy of the minimal valid config.""" + return copy.deepcopy(MINIMAL_CONFIG) + + +# ── Shared helper: call validate_dict and capture result/exception ────── + + +def call_and_capture( + context: Context, + config: Any, + limits: Any, +) -> None: + """Call validate_dict and store the result or exception on context. + + Accepts ``Any`` for *config* and *limits* so that test steps can + intentionally pass invalid types (e.g. ``None``) to exercise the + fail-fast argument guards in ``validate_dict``. + """ + context.validate_dict = validate_dict + context.ConfigurationError = ConfigurationError + try: + context.result = validate_dict(config, limits) + context.raised_exception = None + except Exception as exc: + context.raised_exception = exc + context.result = None + + +# ── Shared graph builders ──────────────────────────────────────────────── + + +def build_linear_graph(depth: int) -> dict[str, Any]: + """Build a config with a linear graph route of exactly 'depth' edges.""" + config = minimal_config() + if depth <= 0: + config["routes"]["depth_graph"] = { + "type": "graph", + "entry_point": "end_node", + "nodes": {"end_node": {"type": "end"}}, + "edges": [], + } + return config + nodes: dict[str, Any] = {} + edges: list[dict[str, Any]] = [] + node_names = [f"node_{i}" for i in range(depth)] + for name in node_names: + nodes[name] = {"type": "agent", "agent": "echo"} + nodes["end"] = {"type": "end"} + for i in range(len(node_names) - 1): + edges.append({"source": node_names[i], "target": node_names[i + 1]}) + if node_names: + edges.append({"source": node_names[-1], "target": "end"}) + config["routes"]["depth_graph"] = { + "type": "graph", + "entry_point": node_names[0], + "nodes": nodes, + "edges": edges, + } + return config + + +def build_node_count_graph(count: int) -> dict[str, Any]: + """Build a graph with exactly 'count' non-end nodes plus the end node.""" + config = minimal_config() + nodes: dict[str, Any] = {} + edges: list[dict[str, Any]] = [] + node_names = [f"node_{i}" for i in range(count)] + for name in node_names: + nodes[name] = {"type": "agent", "agent": "echo"} + nodes["end"] = {"type": "end"} + for i in range(len(node_names) - 1): + edges.append({"source": node_names[i], "target": node_names[i + 1]}) + if node_names: + edges.append({"source": node_names[-1], "target": "end"}) + config["routes"]["count_graph"] = { + "type": "graph", + "entry_point": node_names[0] if node_names else "end", + "nodes": nodes, + "edges": edges, + } + return config + + +def build_subgraph_chain(depth: int) -> dict[str, Any]: + """Build a config with nested subgraph routes of 'depth' levels.""" + if depth <= 0: + raise ValueError("depth must be >= 1") + config = minimal_config() + routes: dict[str, Any] = {} + for level in range(depth): + route_name = f"sub_level_{level}" + nodes: dict[str, Any] = { + "worker": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + } + edges_list: list[dict[str, Any]] = [{"source": "worker", "target": "end"}] + if level < depth - 1: + next_route = f"sub_level_{level + 1}" + nodes["sub_node"] = {"type": "subgraph", "subgraph": next_route} + edges_list = [ + {"source": "worker", "target": "sub_node"}, + {"source": "sub_node", "target": "end"}, + ] + routes[route_name] = { + "type": "graph", + "entry_point": "worker", + "nodes": nodes, + "edges": edges_list, + } + top_nodes: dict[str, Any] = { + "top_worker": {"type": "agent", "agent": "echo"}, + "sub_entry": {"type": "subgraph", "subgraph": "sub_level_0"}, + "end": {"type": "end"}, + } + top_edges: list[dict[str, Any]] = [ + {"source": "top_worker", "target": "sub_entry"}, + {"source": "sub_entry", "target": "end"}, + ] + routes["top_graph"] = { + "type": "graph", + "entry_point": "top_worker", + "nodes": top_nodes, + "edges": top_edges, + } + config["routes"].update(routes) + return config diff --git a/features/steps/validate_dict_limits_steps.py b/features/steps/validate_dict_limits_steps.py new file mode 100644 index 0000000..0360fc9 --- /dev/null +++ b/features/steps/validate_dict_limits_steps.py @@ -0,0 +1,364 @@ +""" +Given/When steps for structural-limit validation scenarios. + +Covers max_graph_depth, max_total_nodes, max_subgraph_depth, +and related edge-case scenarios. +""" + +from __future__ import annotations + +from typing import Any + +from behave import given, when +from behave.runner import Context +from features.steps.validate_dict_helpers import ( + build_linear_graph, + build_node_count_graph, + build_subgraph_chain, + call_and_capture, + minimal_config, +) + +import cleveractors.validation._limits as _limits + +# ── Structural limit Given steps ─────────────────────────────────────── + + +@given("a config dict with a graph route of {depth:d} edges") +def step_config_graph_depth(context: Context, depth: int) -> None: + context.config_dict = build_linear_graph(depth) + + +@given("a config dict with a graph route containing {count:d} non-end nodes") +def step_config_graph_node_count(context: Context, count: int) -> None: + context.config_dict = build_node_count_graph(count) + + +@given("a config dict with nested subgraphs of depth {depth:d}") +def step_config_subgraph_depth(context: Context, depth: int) -> None: + context.config_dict = build_subgraph_chain(depth) + + +@given("a config dict with a graph route of {depth:d} edge") +def step_config_graph_depth_1_edge(context: Context, depth: int) -> None: + context.config_dict = build_linear_graph(depth) + + +@given("a config dict with a graph route containing {count:d} non-end node") +def step_config_graph_node_count_1(context: Context, count: int) -> None: + context.config_dict = build_node_count_graph(count) + + +@given("a config dict with a graph route of exactly {depth:d} edges for boundary test") +def step_config_exact_depth(context: Context, depth: int) -> None: + context.config_dict = build_linear_graph(depth) + + +@given( + "a config dict with a graph route containing exactly {count:d} non-end nodes for boundary test" +) +def step_config_exact_nodes(context: Context, count: int) -> None: + context.config_dict = build_node_count_graph(count) + + +@given( + "a config dict with nested subgraphs of exactly {depth:d} levels for boundary test" +) +def step_config_exact_subgraph_depth(context: Context, depth: int) -> None: + context.config_dict = build_subgraph_chain(depth) + + +@given("a config dict where a route value is not a dict for structural limits") +def step_route_non_dict_structural(context: Context) -> None: + config = minimal_config() + config["routes"]["bad_route"] = None + context.config_dict = config + + +@given("a config dict with a non-dict route value for total nodes counting") +def step_route_non_dict_nodes_count(context: Context) -> None: + config = minimal_config() + config["routes"]["bad_route"] = 42 + context.config_dict = config + + +@given("a config dict with a graph route that has non-dict nodes for depth calculation") +def step_graph_non_dict_nodes_depth(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "start", + "nodes": "not_a_dict", + "edges": [], + } + context.config_dict = config + + +@given("a config dict with a graph route containing a cycle") +def step_graph_cyclic(context: Context) -> None: + config = minimal_config() + config["routes"]["cyclic_graph"] = { + "type": "graph", + "entry_point": "a", + "nodes": { + "a": {"type": "agent", "agent": "echo"}, + "b": {"type": "agent", "agent": "echo"}, + }, + "edges": [ + {"source": "a", "target": "b"}, + {"source": "b", "target": "a"}, + ], + } + context.config_dict = config + + +@given("a config dict with a graph route where edges list contains a non-dict entry") +def step_graph_edge_non_dict_in_depth(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "a", + "nodes": { + "a": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": ["not_a_dict", {"source": "a", "target": "end"}], + } + context.config_dict = config + + +@given("a config dict with two routes that reference each other as subgraphs") +def step_cyclic_subgraph_refs(context: Context) -> None: + config = minimal_config() + config["routes"]["route_a"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "subgraph", "subgraph": "route_b"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + config["routes"]["route_b"] = { + "type": "graph", + "entry_point": "m", + "nodes": { + "m": {"type": "subgraph", "subgraph": "route_a"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "m", "target": "end"}], + } + context.config_dict = config + + +@given("a config dict with a subgraph node referencing a missing route") +def step_subgraph_missing_route(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "subgraph", "subgraph": "nonexistent_route"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + context.config_dict = config + + +@given("a config dict with a graph route containing non-dict node values") +def step_graph_non_dict_node_values(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "agent", "agent": "echo"}, + "bad_node": "not_a_dict", + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + context.config_dict = config + + +@given( + "a config dict with a graph route that has a non-dict nodes field and subgraph limit" +) +def step_graph_non_dict_nodes_subgraph(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "start", + "nodes": ["not", "a", "dict"], + "edges": [], + } + context.config_dict = config + + +@given("a config dict with a graph route whose entry_point does not match any node") +def step_graph_disconnected_entry_point(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "nonexistent", + "nodes": { + "a": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "a", "target": "end"}], + } + context.config_dict = config + + +@given("a config dict with platform_limits containing a bool for max_graph_depth") +def step_bool_max_graph_depth(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + context.config_dict = config + + +@given('a config dict with platform_limits containing a non-int "{limit_key}" value') +def step_platform_limits_non_int(context: Context, limit_key: str) -> None: + config = minimal_config() + config["routes"]["graph_route"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "agent", "agent": "echo"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + context.config_dict = config + context._limit_key = limit_key + + +@given("a config dict with a subgraph node referencing a missing route via a list") +def step_subgraph_missing_route_list(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "subgraph", "subgraph": ["nonexistent"]}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + context.config_dict = config + + +@given("a config dict with valid agents and empty routes") +def step_empty_routes(context: Context) -> None: + config = minimal_config() + config["routes"] = {} + context.config_dict = config + + +# ── Structural limit When steps ──────────────────────────────────────── + + +@when("I call validate_dict with platform_limits max_graph_depth of {max_depth:d}") +def step_call_validate_max_depth(context: Context, max_depth: int) -> None: + call_and_capture(context, context.config_dict, {"max_graph_depth": max_depth}) + + +@when("I call validate_dict with platform_limits max_total_nodes of {max_nodes:d}") +def step_call_validate_max_nodes(context: Context, max_nodes: int) -> None: + call_and_capture(context, context.config_dict, {"max_total_nodes": max_nodes}) + + +@when("I call validate_dict with platform_limits max_subgraph_depth of {max_depth:d}") +def step_call_validate_max_subgraph(context: Context, max_depth: int) -> None: + call_and_capture(context, context.config_dict, {"max_subgraph_depth": max_depth}) + + +@when("I call validate_dict with platform_limits containing a non-int limit value") +def step_call_validate_non_int_limit(context: Context) -> None: + call_and_capture( + context, + context.config_dict, + {context._limit_key: "5"}, + ) + + +@when("I call validate_dict with platform_limits containing a bool as max_graph_depth") +def step_call_validate_bool_max_graph_depth(context: Context) -> None: + call_and_capture(context, context.config_dict, {"max_graph_depth": True}) + + +@when("I call validate_dict with platform_limits containing bool max_total_nodes") +def step_call_validate_bool_max_total_nodes(context: Context) -> None: + call_and_capture(context, context.config_dict, {"max_total_nodes": True}) + + +@when("I call validate_dict with platform_limits containing string max_total_nodes") +def step_call_validate_string_max_total_nodes(context: Context) -> None: + call_and_capture(context, context.config_dict, {"max_total_nodes": "5"}) + + +@when("I call validate_dict with platform_limits containing bool max_subgraph_depth") +def step_call_validate_bool_max_subgraph_depth(context: Context) -> None: + call_and_capture(context, context.config_dict, {"max_subgraph_depth": True}) + + +@when("I call validate_dict with platform_limits containing string max_subgraph_depth") +def step_call_validate_string_max_subgraph_depth(context: Context) -> None: + call_and_capture(context, context.config_dict, {"max_subgraph_depth": "3"}) + + +# ── Monkey-patch steps for DFS/subgraph step guards ───────────────── + + +@given("I monkey-patch _MAX_DFS_STEPS to {limit:d}") +def step_patch_max_dfs_steps(context: Context, limit: int) -> None: + context._original_max_dfs = _limits._MAX_DFS_STEPS + _limits._MAX_DFS_STEPS = limit + + +@given("I monkey-patch _MAX_SUBGRAPH_STEPS to {limit:d}") +def step_patch_max_subgraph_steps(context: Context, limit: int) -> None: + context._original_max_subgraph = _limits._MAX_SUBGRAPH_STEPS + _limits._MAX_SUBGRAPH_STEPS = limit + + +# ── Multi-route aggregation Given steps for max_total_nodes ──────────── + + +@given("a config dict with two graph routes of {count:d} nodes each") +def step_config_two_graph_routes(context: Context, count: int) -> None: + """Build a config with two distinct graph routes, each with {count} nodes total.""" + config = minimal_config() + + for route_idx in range(2): + route_name = f"graph_{chr(ord('a') + route_idx)}" + nodes: dict[str, Any] = {} + edges: list[dict[str, Any]] = [] + # Build count nodes total: if count=1 just an end node, otherwise + # (count-1) non-end nodes followed by the end node. + non_end_count = max(0, count - 1) + node_names = [f"n{i}" for i in range(non_end_count)] + for name in node_names: + nodes[name] = {"type": "agent", "agent": "echo"} + nodes["end"] = {"type": "end"} + for i in range(len(node_names) - 1): + edges.append({"source": node_names[i], "target": node_names[i + 1]}) + if node_names: + edges.append({"source": node_names[-1], "target": "end"}) + config["routes"][route_name] = { + "type": "graph", + "entry_point": node_names[0] if node_names else "end", + "nodes": nodes, + "edges": edges, + } + + context.config_dict = config diff --git a/features/steps/validate_dict_routes_steps.py b/features/steps/validate_dict_routes_steps.py new file mode 100644 index 0000000..71287cb --- /dev/null +++ b/features/steps/validate_dict_routes_steps.py @@ -0,0 +1,83 @@ +""" +Given steps for generic route validation scenarios. + +Graph-specific steps live in ``validate_dict_graph_route_steps.py``. +""" + +from __future__ import annotations + +from behave import given +from behave.runner import Context +from features.steps.validate_dict_helpers import ( + minimal_config, +) + + +@given("a config dict with a valid bridge route") +def step_config_bridge_route(context: Context) -> None: + config = minimal_config() + config["routes"]["bridge_route"] = { + "type": "bridge", + "destination": "external_system", + "config": {"url": "https://example.com/api"}, + } + context.config_dict = config + + +# ── Defensive edge-case steps ────────────────────────────────────────── + + +@given("a config dict where routes is a list instead of a mapping") +def step_routes_is_list(context: Context) -> None: + context.config_dict = { + "agents": {"echo": {"type": "tool"}}, + "routes": ["main"], + } + + +@given("a config dict where a route definition is a string instead of a mapping") +def step_route_def_is_string(context: Context) -> None: + config = minimal_config() + config["routes"]["bad_route"] = "not_a_dict" + context.config_dict = config + + +@given("a config dict with a route of unknown type") +def step_route_unknown_type(context: Context) -> None: + config = minimal_config() + config["routes"]["bad_route"] = {"type": "totally_unknown_route_type"} + context.config_dict = config + + +@given("a config dict with a route that has a non-string type") +def step_route_non_string_type(context: Context) -> None: + config = minimal_config() + config["routes"]["bad_route"] = {"type": ["stream"]} + context.config_dict = config + + +@given("a config dict with a route that has no type field") +def step_route_missing_type(context: Context) -> None: + config = minimal_config() + config["routes"]["bad_route"] = {"something": "else"} + context.config_dict = config + + +@given('a config dict with a route named "{route_name}"') +def step_config_with_route_name(context: Context, route_name: str) -> None: + config = minimal_config() + config["routes"][route_name] = { + "type": "stream", + "operators": [{"type": "map", "params": {"agent": "echo"}}], + } + context.config_dict = config + + +@given("a config dict with a route whose type is null") +def step_route_type_null(context: Context) -> None: + config = minimal_config() + config["routes"]["null_type_route"] = { + "type": None, + "operators": [{"type": "map", "params": {"agent": "echo"}}], + } + context.config_dict = config diff --git a/features/steps/validate_dict_steps.py b/features/steps/validate_dict_steps.py new file mode 100644 index 0000000..710a491 --- /dev/null +++ b/features/steps/validate_dict_steps.py @@ -0,0 +1,385 @@ +""" +Shared Then-step definitions for validate_dict BDD feature. + +These assertion steps are used across all scenarios in validate_dict.feature. +""" + +from __future__ import annotations + +from behave import then +from behave.runner import Context + + +@then("no exception is raised") +def step_no_exception(context: Context) -> None: + if context.raised_exception is not None: + raise AssertionError( + f"Expected no exception, but got: {context.raised_exception!r}" + ) + + +@then("a ConfigurationError is raised") +def step_configuration_error_raised(context: Context) -> None: + assert context.raised_exception is not None, ( + "Expected a ConfigurationError to be raised, but none was." + ) + assert isinstance(context.raised_exception, context.ConfigurationError), ( + f"Expected ConfigurationError, got {type(context.raised_exception).__name__}: " + f"{context.raised_exception!r}" + ) + + +@then('the error message mentions "agents"') +def step_error_mentions_agents(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "agents" in msg, ( + f"Expected error message to mention 'agents', got: {context.raised_exception!r}" + ) + + +@then('the error message mentions "routes"') +def step_error_mentions_routes(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "routes" in msg, ( + f"Expected error message to mention 'routes', got: {context.raised_exception!r}" + ) + + +@then("the error message mentions agent type validation") +def step_error_mentions_agent_type(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "unknown agent type" in msg, ( + f"Expected error message to mention unknown agent type, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions provider validation") +def step_error_mentions_provider(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "provider" in msg and "allowed-providers" in msg, ( + f"Expected error message to mention provider and allowed-providers, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions edge field names") +def step_error_mentions_edge_fields(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert ( + "'from' is not accepted" in msg + or "'to' is not accepted" in msg + or ("source" in msg and "target" in msg) + ), ( + f"Expected error message to mention edge field names, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions node type validation") +def step_error_mentions_node_type(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "unknown node type" in msg, ( + f"Expected error message to mention unknown node type, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions operator type validation") +def step_error_mentions_operator(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "unknown operator type" in msg, ( + f"Expected error message to mention unknown operator type, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions graph depth") +def step_error_mentions_depth(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "max_graph_depth" in msg, ( + f"Expected error message to mention max_graph_depth, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions total nodes") +def step_error_mentions_total_nodes(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "max_total_nodes" in msg, ( + f"Expected error message to mention max_total_nodes, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions subgraph depth") +def step_error_mentions_subgraph_depth(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "max_subgraph_depth" in msg, ( + f"Expected error message to mention max_subgraph_depth, got: {context.raised_exception!r}" + ) + + +@then("the function returns the config dict unchanged") +def step_returns_dict_unchanged(context: Context) -> None: + assert context.result == context.config_dict, ( + f"Expected the returned dict to equal the input dict.\n" + f"Input: {context.config_dict!r}\n" + f"Output: {context.result!r}" + ) + + +@then("the returned dict is the same object as the input dict") +def step_returns_same_object(context: Context) -> None: + assert context.result is context.config_dict, ( + "Expected the returned dict to be the same object (identity) as the input dict, " + "but validate_dict returned a different object." + ) + + +@then("the error message mentions source and target fields") +def step_error_mentions_source_target(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "source" in msg and "target" in msg, ( + f"Expected error message to mention source and target fields, got: {context.raised_exception!r}" + ) + + +@then('the error message mentions "nodes"') +def step_error_mentions_nodes(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "nodes" in msg, ( + f"Expected error message to mention 'nodes', got: {context.raised_exception!r}" + ) + + +@then('the error message mentions "edges"') +def step_error_mentions_edges(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "edges" in msg, ( + f"Expected error message to mention 'edges', got: {context.raised_exception!r}" + ) + + +@then("the error message mentions missing type") +def step_error_mentions_missing_type(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "missing" in msg and "type" in msg, ( + f"Expected error message to mention missing type, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions platform_limits type validation") +def step_error_mentions_limits_type(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "platform_limits" in msg, ( + f"Expected error message to mention platform_limits, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions allowed_providers type validation") +def step_error_mentions_allowed_providers(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "allowed_providers" in msg, ( + f"Expected error message to mention allowed_providers, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions non-string provider") +def step_error_mentions_non_string_provider(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "provider" in msg and "string" in msg, ( + f"Expected error message to mention non-string provider, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions non-string node type") +def step_error_mentions_non_string_node_type(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "node type" in msg and "string" in msg, ( + f"Expected error message to mention non-string node type, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions entry_point not found") +def step_error_mentions_entry_point(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "entry_point" in msg and "does not match any declared node" in msg, ( + f"Expected error message to mention entry_point not found, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions subgraph reference") +def step_error_mentions_subgraph_reference(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "subgraph" in msg, ( + f"Expected error message to mention subgraph, got: {context.raised_exception!r}" + ) + + +@then('the error message mentions "type"') +def step_error_mentions_type(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "type" in msg, ( + f"Expected error message to mention 'type', got: {context.raised_exception!r}" + ) + + +@then("the error message mentions non-string agent type") +def step_error_mentions_non_string_agent_type(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "type" in msg and "string" in msg and "agent" in msg, ( + f"Expected error message to mention non-string agent type, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions non-string route type") +def step_error_mentions_non_string_route_type(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "type" in msg and "string" in msg and "route" in msg, ( + f"Expected error message to mention non-string route type, got: {context.raised_exception!r}" + ) + + +@then('the error message mentions "entry_point"') +def step_error_mentions_entry_point_keyword(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "entry_point" in msg, ( + f"Expected error message to mention 'entry_point', got: {context.raised_exception!r}" + ) + + +@then("the error message mentions non-string operator type") +def step_error_mentions_non_string_op_type(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "operator type" in msg and "string" in msg, ( + f"Expected error message to mention non-string operator type, got: {context.raised_exception!r}" + ) + + +@then("the error message mentions config_dict and dict") +def step_error_mentions_config_dict_is_dict(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "config_dict" in msg and "dict" in msg, ( + f"Expected error message to mention config_dict and dict, " + f"got: {context.raised_exception!r}" + ) + + +@then("the error message mentions platform_limits and dict") +def step_error_mentions_platform_limits_is_dict(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "platform_limits" in msg and "dict" in msg, ( + f"Expected error message to mention platform_limits and dict, " + f"got: {context.raised_exception!r}" + ) + + +@then("the error message mentions edge must be a mapping") +def step_error_mentions_edge_mapping(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "edge" in msg and "mapping" in msg, ( + f"Expected error message to mention edge mapping, " + f"got: {context.raised_exception!r}" + ) + + +@then("the error message mentions operator must be a mapping") +def step_error_mentions_operator_mapping(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "operator" in msg and "mapping" in msg, ( + f"Expected error message to mention operator mapping, " + f"got: {context.raised_exception!r}" + ) + + +@then("the error message mentions too complex to validate depth") +def step_error_mentions_too_complex_depth(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "too complex to validate" in msg and "depth" in msg, ( + f"Expected error message to mention 'too complex to validate depth', " + f"got: {context.raised_exception!r}" + ) + + +@then("the error message mentions too complex to validate") +def step_error_mentions_too_complex(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "too complex to validate" in msg, ( + f"Expected error message to mention 'too complex to validate', " + f"got: {context.raised_exception!r}" + ) + + +@then('the error message mentions "must be >= 0"') +def step_error_mentions_must_be_ge_zero(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "must be >= 0" in msg, ( + f"Expected error message to mention 'must be >= 0', " + f"got: {context.raised_exception!r}" + ) + + +@then('the error message mentions "must be an integer"') +def step_error_mentions_must_be_integer(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "must be an integer" in msg, ( + f"Expected error message to mention 'must be an integer', " + f"got: {context.raised_exception!r}" + ) + + +@then("the error message mentions edge endpoint not declared") +def step_error_mentions_edge_endpoint_not_declared(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "not declared in 'nodes'" in msg, ( + f"Expected error message to mention edge endpoint not declared, " + f"got: {context.raised_exception!r}" + ) + + +@then("the error message mentions config must be a mapping") +def step_error_mentions_config_mapping(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "config" in msg and "mapping" in msg, ( + f"Expected error message to mention config mapping, " + f"got: {context.raised_exception!r}" + ) + + +@then("the error message mentions reserved agent name") +def step_error_mentions_reserved_agent(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "reserved" in msg and "agent" in msg, ( + f"Expected error message to mention reserved agent name, " + f"got: {context.raised_exception!r}" + ) + + +@then("the error message mentions reserved route name") +def step_error_mentions_reserved_route(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "reserved" in msg and "route" in msg, ( + f"Expected error message to mention reserved route name, " + f"got: {context.raised_exception!r}" + ) + + +@then("the error message mentions agent key must be a string") +def step_error_mentions_agent_key_string(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "agent key" in msg and "string" in msg, ( + f"Expected error message to mention agent key must be a string, " + f"got: {context.raised_exception!r}" + ) + + +@then("the error message mentions route key must be a string") +def step_error_mentions_route_key_string(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "route key" in msg and "string" in msg, ( + f"Expected error message to mention route key must be a string, " + f"got: {context.raised_exception!r}" + ) + + +@then("the error message mentions node key must be a string") +def step_error_mentions_node_key_string(context: Context) -> None: + msg = str(context.raised_exception).lower() + assert "node key" in msg and "string" in msg, ( + f"Expected error message to mention node key must be a string, " + f"got: {context.raised_exception!r}" + ) diff --git a/features/steps/validate_dict_stream_steps.py b/features/steps/validate_dict_stream_steps.py new file mode 100644 index 0000000..2a2be08 --- /dev/null +++ b/features/steps/validate_dict_stream_steps.py @@ -0,0 +1,96 @@ +""" +Given steps for stream route validation scenarios. +""" + +from __future__ import annotations + +from behave import given +from behave.runner import Context +from features.steps.validate_dict_helpers import ( + minimal_config, +) + + +@given('a config dict with a stream route containing a "{op_type}" operator') +def step_config_stream_operator_type(context: Context, op_type: str) -> None: + config = minimal_config() + config["routes"]["stream_route"] = { + "type": "stream", + "operators": [{"type": op_type, "params": {"agent": "echo"}}], + "publications": ["__output__"], + } + context.config_dict = config + + +@given("a config dict with a stream route containing an invalid operator type") +def step_config_stream_invalid_operator(context: Context) -> None: + config = minimal_config() + config["routes"]["stream_route"] = { + "type": "stream", + "operators": [{"type": "not_a_real_operator", "params": {}}], + "publications": ["__output__"], + } + context.config_dict = config + + +@given("a config dict with a stream route where operators is not a list") +def step_stream_operators_not_list(context: Context) -> None: + config = minimal_config() + config["routes"]["stream_r"] = { + "type": "stream", + "operators": "not_a_list", + } + context.config_dict = config + + +@given("a config dict with a stream route where one operator is a string") +def step_stream_operator_is_string(context: Context) -> None: + config = minimal_config() + config["routes"]["stream_r"] = { + "type": "stream", + "operators": ["not_an_op_dict", {"type": "map", "params": {"agent": "echo"}}], + } + context.config_dict = config + + +@given("a config dict with a stream route where one operator has an empty type") +def step_stream_operator_empty_type(context: Context) -> None: + config = minimal_config() + config["routes"]["stream_r"] = { + "type": "stream", + "operators": [{"type": "", "params": {}}], + } + context.config_dict = config + + +@given("a config dict with a stream route containing an operator missing type") +def step_stream_operator_missing_type(context: Context) -> None: + config = minimal_config() + config["routes"]["stream_route"] = { + "type": "stream", + "operators": [{"params": {"agent": "echo"}}], + "publications": ["__output__"], + } + context.config_dict = config + + +@given("a config dict with a stream route where one operator has a non-string type") +def step_stream_operator_non_string_type(context: Context) -> None: + config = minimal_config() + config["routes"]["stream_route"] = { + "type": "stream", + "operators": [{"type": ["map"], "params": {}}], + "publications": ["__output__"], + } + context.config_dict = config + + +@given("a config dict with a stream route containing no operators") +def step_stream_no_operators(context: Context) -> None: + config = minimal_config() + config["routes"]["stream_route"] = { + "type": "stream", + "operators": [], + "publications": ["__output__"], + } + context.config_dict = config diff --git a/features/steps/validate_dict_subgraph_steps.py b/features/steps/validate_dict_subgraph_steps.py new file mode 100644 index 0000000..9c58c5b --- /dev/null +++ b/features/steps/validate_dict_subgraph_steps.py @@ -0,0 +1,110 @@ +""" +Given steps for subgraph-reference route validation scenarios. +""" + +from __future__ import annotations + +from behave import given +from behave.runner import Context +from features.steps.validate_dict_helpers import ( + minimal_config, +) + + +@given("a config dict with a subgraph node referencing a stream route") +def step_subgraph_ref_stream_route(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "subgraph", "subgraph": "stream_ref"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + config["routes"]["stream_ref"] = { + "type": "stream", + "operators": [{"type": "map"}], + "publications": [], + } + context.config_dict = config + + +@given("a config dict with a subgraph node referencing a bridge route") +def step_subgraph_ref_bridge_route(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "subgraph", "subgraph": "bridge_ref"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + config["routes"]["bridge_ref"] = { + "type": "bridge", + "destination": "external", + } + context.config_dict = config + + +@given( + "a config dict with a subgraph node referencing a missing route without max_subgraph_depth" +) +def step_subgraph_missing_route_no_limit(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "subgraph", "subgraph": "nonexistent_route"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + context.config_dict = config + + +@given( + "a config dict with a subgraph node referencing a stream route without max_subgraph_depth" +) +def step_subgraph_ref_stream_no_limit(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "subgraph", "subgraph": "stream_ref"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + config["routes"]["stream_ref"] = { + "type": "stream", + "operators": [{"type": "map"}], + "publications": [], + } + context.config_dict = config + + +@given( + "a config dict with a subgraph node referencing a bridge route without max_subgraph_depth" +) +def step_subgraph_ref_bridge_no_limit(context: Context) -> None: + config = minimal_config() + config["routes"]["graph_r"] = { + "type": "graph", + "entry_point": "n", + "nodes": { + "n": {"type": "subgraph", "subgraph": "bridge_ref"}, + "end": {"type": "end"}, + }, + "edges": [{"source": "n", "target": "end"}], + } + config["routes"]["bridge_ref"] = { + "type": "bridge", + "destination": "external", + } + context.config_dict = config diff --git a/features/validate_dict.feature b/features/validate_dict.feature new file mode 100644 index 0000000..3882656 --- /dev/null +++ b/features/validate_dict.feature @@ -0,0 +1,878 @@ +Feature: validate_dict public API — spec-conformant Actor Configuration validation + As a router platform integrating cleveractors-core + I want to call validate_dict(config_dict, platform_limits) to validate actor YAML dicts + So that only spec-conformant configs are accepted before they are stored in the database + + Background: + Given the validate_dict function is imported from cleveractors + + # ── Top-level key presence ───────────────────────────────────────────────── + + Scenario: Valid minimal config returns the dict unchanged + Given a minimal valid config dict with agents and routes + When I call validate_dict with empty platform_limits + Then the function returns the config dict unchanged + And no exception is raised + + Scenario: Config missing "agents" key raises ConfigurationError + Given a config dict without an "agents" key + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions "agents" + + Scenario: Config missing "routes" key raises ConfigurationError + Given a config dict without a "routes" key + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions "routes" + + # ── Agent type validation ────────────────────────────────────────────────── + + Scenario: Config with a valid llm agent type passes validation + Given a config dict with an agent of type "llm" + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Config with a valid tool agent type passes validation + Given a config dict with an agent of type "tool" + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Config with a valid composite agent type passes validation + Given a config dict with an agent of type "composite" + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Config with a valid chain agent type passes validation + Given a config dict with an agent of type "chain" + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Config with a valid template_instance agent type passes validation + Given a config dict with an agent of type "template_instance" + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Config with an unknown agent type raises ConfigurationError + Given a config dict with an agent of type "unknown_type" + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions agent type validation + + # ── LLM provider validation ──────────────────────────────────────────────── + + Scenario: LLM agent with openai provider passes default allowlist validation + Given a config dict with an llm agent using provider "openai" + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: LLM agent with anthropic provider passes default allowlist validation + Given a config dict with an llm agent using provider "anthropic" + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: LLM agent with google provider passes default allowlist validation + Given a config dict with an llm agent using provider "google" + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: LLM agent with unknown provider and no allowlist raises ConfigurationError + Given a config dict with an llm agent using provider "unknown_provider" + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions provider validation + + Scenario: LLM agent provider matches custom allowed_providers list + Given a config dict with an llm agent using provider "groq" + When I call validate_dict with platform_limits containing allowed_providers "groq,openai" + Then no exception is raised + + Scenario: LLM agent provider not in custom allowed_providers raises ConfigurationError + Given a config dict with an llm agent using provider "anthropic" + When I call validate_dict with platform_limits containing allowed_providers "openai" + Then a ConfigurationError is raised + And the error message mentions provider validation + + Scenario: LLM agent without explicit provider uses default openai and passes + Given a config dict with an llm agent without a provider field + When I call validate_dict with empty platform_limits + Then no exception is raised + + # ── Route / edge / node / operator validation ────────────────────────────── + + Scenario: Graph route with source/target edge fields passes validation + Given a config dict with a graph route using source/target edges + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Graph route edge using "from" field raises ConfigurationError + Given a config dict with a graph route edge using "from" instead of "source" + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions edge field names + + Scenario: Graph route edge using "to" field raises ConfigurationError + Given a config dict with a graph route edge using "to" instead of "target" + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions edge field names + + Scenario: Graph route with valid node type "agent" passes validation + Given a config dict with a graph route containing a node of type "agent" + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Graph route with invalid node type raises ConfigurationError + Given a config dict with a graph route containing a node of type "invalid_node" + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions node type validation + + Scenario: Stream route with valid operator type "map" passes validation + Given a config dict with a stream route containing a "map" operator + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Stream route with invalid operator type raises ConfigurationError + Given a config dict with a stream route containing an invalid operator type + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions operator type validation + + # ── Structural limit enforcement ─────────────────────────────────────────── + + Scenario: Graph within max_graph_depth limit passes validation + Given a config dict with a graph route of 4 edges + When I call validate_dict with platform_limits max_graph_depth of 5 + Then no exception is raised + + Scenario: Graph exceeding max_graph_depth raises ConfigurationError + Given a config dict with a graph route of 4 edges + When I call validate_dict with platform_limits max_graph_depth of 3 + Then a ConfigurationError is raised + And the error message mentions graph depth + + Scenario: Actor within max_total_nodes limit passes validation + Given a config dict with a graph route containing 3 non-end nodes + When I call validate_dict with platform_limits max_total_nodes of 10 + Then no exception is raised + + Scenario: Actor exceeding max_total_nodes raises ConfigurationError + Given a config dict with a graph route containing 5 non-end nodes + When I call validate_dict with platform_limits max_total_nodes of 3 + Then a ConfigurationError is raised + And the error message mentions total nodes + + Scenario: Nested subgraph within max_subgraph_depth limit passes validation + Given a config dict with nested subgraphs of depth 2 + When I call validate_dict with platform_limits max_subgraph_depth of 3 + Then no exception is raised + + Scenario: Nested subgraph exceeding max_subgraph_depth raises ConfigurationError + Given a config dict with nested subgraphs of depth 4 + When I call validate_dict with platform_limits max_subgraph_depth of 3 + Then a ConfigurationError is raised + And the error message mentions subgraph depth + + # ── Return value contract ────────────────────────────────────────────────── + + Scenario: Valid config is returned unchanged (identity contract) + Given a fully valid spec-conformant config dict + When I call validate_dict with empty platform_limits + Then the returned dict is the same object as the input dict + + # ── Callable without file/env/app side effects ──────────────────────────── + + Scenario: Bridge route type passes validation + Given a config dict with a valid bridge route + When I call validate_dict with empty platform_limits + Then no exception is raised + + # ── Case-insensitive validation ──────────────────────────────────────────── + + Scenario: LLM agent with provider "OpenAI" passes default allowlist + Given a config dict with an llm agent using provider "OpenAI" in mixed case + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Graph route with node type "Agent" passes validation + Given a config dict with a graph route containing a node of type "Agent" in mixed case + When I call validate_dict with empty platform_limits + Then no exception is raised + + # ── Boundary-exact structural limit tests ────────────────────────────────── + + Scenario: Graph at exact max_graph_depth limit passes validation + Given a config dict with a graph route of exactly 5 edges for boundary test + When I call validate_dict with platform_limits max_graph_depth of 5 + Then no exception is raised + + Scenario: Actor at exact max_total_nodes limit passes validation + Given a config dict with a graph route containing exactly 5 non-end nodes for boundary test + When I call validate_dict with platform_limits max_total_nodes of 6 + Then no exception is raised + + Scenario: Nested subgraph at exact max_subgraph_depth limit passes validation + Given a config dict with nested subgraphs of exactly 3 levels for boundary test + When I call validate_dict with platform_limits max_subgraph_depth of 3 + Then no exception is raised + + # ── Zero-value boundary tests ────────────────────────────────────────────── + + Scenario: max_graph_depth=0 raises ConfigurationError when graph has depth 1 + Given a config dict with a graph route of 1 edge + When I call validate_dict with platform_limits max_graph_depth of 0 + Then a ConfigurationError is raised + And the error message mentions graph depth + + Scenario: max_total_nodes=0 raises ConfigurationError when config has any nodes + Given a config dict with a graph route containing 1 non-end node + When I call validate_dict with platform_limits max_total_nodes of 0 + Then a ConfigurationError is raised + And the error message mentions total nodes + + Scenario: max_subgraph_depth=0 raises ConfigurationError when config has a subgraph + Given a config dict with nested subgraphs of depth 1 + When I call validate_dict with platform_limits max_subgraph_depth of 0 + Then a ConfigurationError is raised + And the error message mentions subgraph depth + + # ── Empty collection boundary tests ──────────────────────────────────────── + + Scenario: Empty agents dict with valid routes passes validation + Given a config dict with empty agents and valid routes + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Empty routes dict with valid agents passes validation + Given a config dict with valid agents and empty routes + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Empty allowed_providers list raises ConfigurationError for LLM agent + Given a config dict with an llm agent using provider "openai" + When I call validate_dict with platform_limits containing allowed_providers "" + Then a ConfigurationError is raised + And the error message mentions provider validation + + # ── Negative platform limits rejected ────────────────────────────────────── + + Scenario: Negative max_graph_depth raises ConfigurationError + Given a config dict with a graph route of 1 edge + When I call validate_dict with platform_limits max_graph_depth of -1 + Then a ConfigurationError is raised + And the error message mentions platform_limits type validation + + Scenario: Negative max_total_nodes raises ConfigurationError + Given a config dict with a graph route containing 1 non-end node + When I call validate_dict with platform_limits max_total_nodes of -1 + Then a ConfigurationError is raised + And the error message mentions platform_limits type validation + + Scenario: Negative max_subgraph_depth raises ConfigurationError + Given a config dict with nested subgraphs of depth 1 + When I call validate_dict with platform_limits max_subgraph_depth of -1 + Then a ConfigurationError is raised + And the error message mentions platform_limits type validation + + # ── Crash-bug regression tests ───────────────────────────────────────────── + + Scenario: Non-string LLM provider raises ConfigurationError instead of crashing + Given a config dict with an llm agent using a non-string provider value + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions non-string provider + + Scenario: Non-string node type raises ConfigurationError instead of crashing + Given a config dict with a graph route containing a non-string node type + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions non-string node type + + Scenario: Edge missing source and target fields raises ConfigurationError + Given a config dict with a graph route where an edge lacks source and target + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions source and target fields + + Scenario: Graph route missing nodes key raises ConfigurationError + Given a config dict with a graph route missing the nodes key + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions "nodes" + + Scenario: Graph route missing edges key raises ConfigurationError + Given a config dict with a graph route missing the edges key + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions "edges" + + Scenario: Stream operator missing type field raises ConfigurationError + Given a config dict with a stream route containing an operator missing type + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions missing type + + Scenario: Bare string as allowed_providers raises ConfigurationError + Given a config dict with platform_limits allowed_providers as a bare string "openai" + When I call validate_dict with platform_limits containing the bare string + Then a ConfigurationError is raised + And the error message mentions allowed_providers type validation + + Scenario: Non-int max_graph_depth raises ConfigurationError + Given a config dict with platform_limits containing a non-int "max_graph_depth" value + When I call validate_dict with platform_limits containing a non-int limit value + Then a ConfigurationError is raised + And the error message mentions platform_limits type validation + + Scenario: agents section that is not a dict raises ConfigurationError + Given a config dict where agents is a list instead of a mapping + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + + Scenario: routes section that is not a dict raises ConfigurationError + Given a config dict where routes is a list instead of a mapping + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + + Scenario: Agent definition that is not a dict raises ConfigurationError + Given a config dict where an agent definition is a string instead of a mapping + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + + Scenario: Agent with agent_template key but no type skips type validation + Given a config dict with an agent that has an agent_template key instead of type + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Agent with no type and no template key raises ConfigurationError + Given a config dict with an agent that has no type and no template key + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + + Scenario: Agent with template key but no type skips type validation + Given a config dict with an agent that has a template key instead of type + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: LLM agent with non-dict config raises ConfigurationError + Given a config dict with an llm agent that has a non-dict config + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions config must be a mapping + + Scenario: Route definition that is not a dict raises ConfigurationError + Given a config dict where a route definition is a string instead of a mapping + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + + Scenario: Route with unknown type raises ConfigurationError + Given a config dict with a route of unknown type + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + + Scenario: Graph route with non-list edges raises ConfigurationError + Given a config dict with a graph route where edges is not a list + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions "edges" + + Scenario: Graph route with non-dict edge entry raises ConfigurationError + Given a config dict with a graph route where one edge is a string + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions edge must be a mapping + + Scenario: Graph route with non-dict node entry raises ConfigurationError + Given a config dict with a graph route where one node is a string + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + + Scenario: Graph route with non-dict nodes section raises ConfigurationError + Given a config dict with a graph route where nodes is not a dict + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions "nodes" + + Scenario: Stream route with non-list operators raises ConfigurationError + Given a config dict with a stream route where operators is not a list + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + + Scenario: Stream route with non-dict operator entry raises ConfigurationError + Given a config dict with a stream route where one operator is a string + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions operator must be a mapping + + Scenario: Stream operator with empty type string raises ConfigurationError + Given a config dict with a stream route where one operator has an empty type + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions operator type validation + + Scenario: Route with non-dict value raises ConfigurationError before structural limits + Given a config dict where a route value is not a dict for structural limits + When I call validate_dict with platform_limits max_graph_depth of 5 + Then a ConfigurationError is raised + + Scenario: Route with non-dict value raises ConfigurationError before total nodes count + Given a config dict with a non-dict route value for total nodes counting + When I call validate_dict with platform_limits max_total_nodes of 100 + Then a ConfigurationError is raised + + Scenario: Graph depth raises ConfigurationError when nodes field is not a dict + Given a config dict with a graph route that has non-dict nodes for depth calculation + When I call validate_dict with platform_limits max_graph_depth of 5 + Then a ConfigurationError is raised + And the error message mentions "nodes" + + Scenario: Graph depth check rejects disconnected entry_point + Given a config dict with a graph route whose entry_point does not match any node + When I call validate_dict with platform_limits max_graph_depth of 5 + Then a ConfigurationError is raised + And the error message mentions entry_point not found + + Scenario: Graph depth limit handles cycles gracefully + Given a config dict with a graph route containing a cycle + When I call validate_dict with platform_limits max_graph_depth of 5 + Then no exception is raised + + Scenario: Graph depth calculation raises ConfigurationError for non-dict edge entries + Given a config dict with a graph route where edges list contains a non-dict entry + When I call validate_dict with platform_limits max_graph_depth of 5 + Then a ConfigurationError is raised + And the error message mentions edge must be a mapping + + Scenario: Subgraph depth cycle guard prevents infinite recursion + Given a config dict with two routes that reference each other as subgraphs + When I call validate_dict with platform_limits max_subgraph_depth of 10 + Then no exception is raised + + Scenario: Subgraph depth raises ConfigurationError for non-existent subgraph reference + Given a config dict with a subgraph node referencing a missing route + When I call validate_dict with platform_limits max_subgraph_depth of 5 + Then a ConfigurationError is raised + + Scenario: Graph route with non-dict node definitions raises ConfigurationError before subgraph depth check + Given a config dict with a graph route containing non-dict node values + When I call validate_dict with platform_limits max_subgraph_depth of 5 + Then a ConfigurationError is raised + + Scenario: Graph route with non-dict nodes field raises ConfigurationError before subgraph depth check + Given a config dict with a graph route that has a non-dict nodes field and subgraph limit + When I call validate_dict with platform_limits max_subgraph_depth of 5 + Then a ConfigurationError is raised + And the error message mentions "nodes" + + # ── Strengthened error message assertions ────────────────────────────────── + + Scenario: Non-string agent type raises ConfigurationError with specific message + Given a config dict with an agent that has a non-string type + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions non-string agent type + + Scenario: Non-string route type raises ConfigurationError with specific message + Given a config dict with a route that has a non-string type + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions non-string route type + + Scenario: Subgraph node with non-string reference raises ConfigurationError + Given a config dict with a subgraph node referencing a missing route via a list + When I call validate_dict with platform_limits max_subgraph_depth of 5 + Then a ConfigurationError is raised + + # ── Cycle 3 fixes: entry_point, edge type, subgraph validation ──────────── + + Scenario: Graph route missing entry_point raises ConfigurationError + Given a config dict with a graph route that has no entry_point field + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions "entry_point" + + Scenario: Graph route with non-string entry_point raises ConfigurationError in route validation + Given a config dict with a graph route whose entry_point is not a string + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions "entry_point" + + Scenario: Graph route edge with non-string source raises ConfigurationError + Given a config dict with a graph route where an edge source is a list + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions source and target fields + + Scenario: Graph route node with empty string subgraph reference raises ConfigurationError + Given a config dict with a graph route containing a subgraph node with empty reference + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions subgraph reference + + Scenario: Graph route node with non-string subgraph reference raises ConfigurationError + Given a config dict with a graph route containing a subgraph node with list reference + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions subgraph reference + + Scenario: Route missing type field entirely raises ConfigurationError + Given a config dict with a route that has no type field + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions "type" + + # ── Cycle 4 fixes: empty-string source/target, empty entry_point ────────── + + Scenario: Graph route edge with empty string source raises ConfigurationError + Given a config dict with a graph route where an edge has an empty source + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions source and target fields + + Scenario: Graph route edge with empty string target raises ConfigurationError + Given a config dict with a graph route where an edge has an empty target + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions source and target fields + + Scenario: Graph route with empty entry_point string raises ConfigurationError + Given a config dict with a graph route that has an empty entry_point string + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions "entry_point" + + # ── Cycle 4: subgraph pointing to non-graph route ───────────────────────── + + Scenario: Subgraph node referencing a stream route raises ConfigurationError + Given a config dict with a subgraph node referencing a stream route + When I call validate_dict with platform_limits max_subgraph_depth of 5 + Then a ConfigurationError is raised + + Scenario: Subgraph node referencing a bridge route raises ConfigurationError + Given a config dict with a subgraph node referencing a bridge route + When I call validate_dict with platform_limits max_subgraph_depth of 5 + Then a ConfigurationError is raised + + # ── Coverage gap: non-string stream operator type ───────────────────────── + + Scenario: Stream operator with non-string type raises ConfigurationError + Given a config dict with a stream route where one operator has a non-string type + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions non-string operator type + + # ── Coverage gap: non-string element in allowed_providers ────────────────── + + Scenario: Allowed_providers list with a non-string element raises ConfigurationError + Given a config dict with llm agent and allowed_providers containing a non-string element + When I call validate_dict with platform_limits containing the mixed-type list + Then a ConfigurationError is raised + And the error message mentions allowed_providers type validation + + # ── Coverage gap: invalid types on max_total_nodes / max_subgraph_depth ──── + + Scenario: Boolean True as max_total_nodes raises ConfigurationError + Given a config dict with a graph route containing 3 non-end nodes + When I call validate_dict with platform_limits containing bool max_total_nodes + Then a ConfigurationError is raised + And the error message mentions platform_limits type validation + + Scenario: Non-int string as max_total_nodes raises ConfigurationError + Given a config dict with a graph route containing 3 non-end nodes + When I call validate_dict with platform_limits containing string max_total_nodes + Then a ConfigurationError is raised + And the error message mentions platform_limits type validation + + Scenario: Boolean True as max_subgraph_depth raises ConfigurationError + Given a config dict with nested subgraphs of depth 2 + When I call validate_dict with platform_limits containing bool max_subgraph_depth + Then a ConfigurationError is raised + And the error message mentions platform_limits type validation + + Scenario: Non-int string as max_subgraph_depth raises ConfigurationError + Given a config dict with nested subgraphs of depth 2 + When I call validate_dict with platform_limits containing string max_subgraph_depth + Then a ConfigurationError is raised + And the error message mentions platform_limits type validation + + # ── Crash-bug regression: non-string entry_point ──────────────────────────── + + Scenario: Non-string entry_point raises ConfigurationError instead of crashing + Given a config dict with a graph route whose entry_point is not a string + When I call validate_dict with platform_limits max_graph_depth of 5 + Then a ConfigurationError is raised + + # ── Crash-bug regression: allowed_providers as dict ───────────────────────── + + Scenario: Dict as allowed_providers raises ConfigurationError + Given a config dict with platform_limits containing a dict for allowed_providers + When I call validate_dict with platform_limits containing a dict as allowed_providers + Then a ConfigurationError is raised + And the error message mentions allowed_providers type validation + + # ── Crash-bug regression: boolean platform_limits ─────────────────────────── + + Scenario: Boolean True as max_graph_depth raises ConfigurationError + Given a config dict with platform_limits containing a bool for max_graph_depth + When I call validate_dict with platform_limits containing a bool as max_graph_depth + Then a ConfigurationError is raised + And the error message mentions platform_limits type validation + + # ── Cycle 5: None argument guards ───────────────────────────────────────── + + Scenario: validate_dict with None config_dict raises ConfigurationError + When I call validate_dict with config_dict set to None and empty platform_limits + Then a ConfigurationError is raised + And the error message mentions config_dict and dict + + Scenario: validate_dict with None platform_limits raises ConfigurationError + Given a minimal valid config dict with agents and routes + When I call validate_dict with platform_limits set to None + Then a ConfigurationError is raised + And the error message mentions platform_limits and dict + + # ── Cycle 5: missing node type key ──────────────────────────────────────── + + Scenario: Graph node missing type field produces clear error message + Given a config dict with a graph route where a node is missing the type key + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions missing type + + # ── Cycle 5: ConfigurationError exported from top-level package ─────────── + + Scenario: ConfigurationError is importable from the top-level cleveractors package + When I import ConfigurationError from the cleveractors package + Then the import succeeds and ConfigurationError is a class + + Scenario: ConfigurationError is listed in cleveractors.__all__ + When I check the cleveractors package __all__ + Then ConfigurationError is listed in the __all__ + + # ── Cycle 5: non-dict config_dict and platform_limits ────────────────────── + + Scenario: validate_dict with list config_dict raises ConfigurationError + When I call validate_dict with config_dict set to an empty list and empty platform_limits + Then a ConfigurationError is raised + And the error message mentions config_dict and dict + + Scenario: validate_dict with string platform_limits raises ConfigurationError + Given a minimal valid config dict with agents and routes + When I call validate_dict with platform_limits set to a string + Then a ConfigurationError is raised + And the error message mentions platform_limits and dict + + # ── Cycle 7: DFS step guard branches ────────────────────────────────────── + + Scenario: DFS step guard in _compute_graph_depth raises ConfigurationError + Given I monkey-patch _MAX_DFS_STEPS to 3 + And a config dict with a graph route of 10 edges + When I call validate_dict with platform_limits max_graph_depth of 100 + Then a ConfigurationError is raised + And the error message mentions too complex to validate depth + + Scenario: Subgraph step guard in _compute_subgraph_depth raises ConfigurationError + Given I monkey-patch _MAX_SUBGRAPH_STEPS to 3 + And a config dict with nested subgraphs of depth 4 + When I call validate_dict with platform_limits max_subgraph_depth of 10 + Then a ConfigurationError is raised + And the error message mentions too complex to validate + + # ── Cycle 7: Stronger platform_limits error assertions ──────────────────── + + Scenario: Negative limit error message mentions "must be >= 0" + Given a config dict with a graph route containing 1 non-end node + When I call validate_dict with platform_limits max_total_nodes of -1 + Then a ConfigurationError is raised + And the error message mentions "must be >= 0" + + Scenario: Type error limit message mentions "must be an integer" + Given a config dict with a graph route containing 3 non-end nodes + When I call validate_dict with platform_limits containing string max_total_nodes + Then a ConfigurationError is raised + And the error message mentions "must be an integer" + + # ── Cycle 8: Subgraph reference validation without max_subgraph_depth ───── + + Scenario: Subgraph referencing missing route raises error without max_subgraph_depth + Given a config dict with a subgraph node referencing a missing route without max_subgraph_depth + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + + Scenario: Subgraph referencing stream route raises error without max_subgraph_depth + Given a config dict with a subgraph node referencing a stream route without max_subgraph_depth + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + + Scenario: Subgraph referencing bridge route raises error without max_subgraph_depth + Given a config dict with a subgraph node referencing a bridge route without max_subgraph_depth + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + + # ── Cycle 8: Edge endpoint validation against declared nodes ───────────── + + Scenario: Edge referencing a non-existent target node raises ConfigurationError + Given a config dict with a graph route where an edge references a non-existent node + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions edge endpoint not declared + + Scenario: Edge referencing a non-existent source node raises ConfigurationError + Given a config dict with a graph route where an edge source references a non-existent node + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions edge endpoint not declared + + # ── Cycle 8: LLM provider whitespace stripping ─────────────────────────── + + Scenario: LLM agent provider with surrounding whitespace passes validation + Given a config dict with an llm agent using provider " openai " with whitespace + When I call validate_dict with empty platform_limits + Then no exception is raised + + # ── Review fix: reserved agent names (§4.2) ───────────────────────────── + + Scenario: Agent name beginning with double underscore raises ConfigurationError + Given a config dict with an agent named "__internal_agent" + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions reserved agent name + + # ── Review fix: reserved route names (§5.1) ───────────────────────────── + + Scenario: Route name beginning with double underscore raises ConfigurationError + Given a config dict with a route named "__input__" + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions reserved route name + + # ── Review fix: route type explicitly set to None ────────────────────── + + Scenario: Route with type set to None raises ConfigurationError + Given a config dict with a route whose type is null + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions "type" + + # ── Review fix: allowed_providers as set and frozenset ────────────────── + + Scenario: Allowed_providers as a Python set passes validation + Given a config dict with an llm agent using provider "anthropic" + When I call validate_dict with an allowed_providers set containing anthropic and openai + Then no exception is raised + + Scenario: Allowed_providers as a Python frozenset passes validation + Given a config dict with an llm agent using provider "anthropic" + When I call validate_dict with an allowed_providers frozenset containing anthropic and openai + Then no exception is raised + + # ── Review fix: max_total_nodes aggregation across multiple graph routes ── + + Scenario: max_total_nodes aggregates across multiple graph routes + Given a config dict with two graph routes of 3 nodes each + When I call validate_dict with platform_limits max_total_nodes of 5 + Then a ConfigurationError is raised + And the error message mentions total nodes + + Scenario: max_total_nodes at exact limit across multiple graph routes + Given a config dict with two graph routes of 3 nodes each + When I call validate_dict with platform_limits max_total_nodes of 6 + Then no exception is raised + + # ── Review fix: allowed_providers as a tuple ──────────────────────────── + + Scenario: Allowed_providers as a Python tuple passes validation + Given a config dict with an llm agent using provider "anthropic" + When I call validate_dict with an allowed_providers tuple containing anthropic and openai + Then no exception is raised + + # ── Review fix: empty operators list on stream route ─────────────────── + + Scenario: Stream route with empty operators list passes validation + Given a config dict with a stream route containing no operators + When I call validate_dict with empty platform_limits + Then no exception is raised + + # ── Review fix: non-string agent key guard ───────────────────────────── + + Scenario: Non-string key in agents dict raises ConfigurationError + Given a config dict with a non-string agent key + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions agent key must be a string + + # ── Review fix: non-string route key guard ───────────────────────────── + + Scenario: Non-string key in routes dict raises ConfigurationError + Given a config dict with a non-string route key + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions route key must be a string + + # ── Review fix: non-string graph node key guard ────────────────────── + + Scenario: Non-string key in graph route nodes raises ConfigurationError + Given a config dict with a graph route that has a non-string node key + When I call validate_dict with empty platform_limits + Then a ConfigurationError is raised + And the error message mentions node key must be a string + + # ── Review Cycle 2: LLM default provider exclusion test ────────────── + + Scenario: LLM agent without provider fails when default openai not in allowed_providers + Given a config dict with an llm agent without a provider field + When I call validate_dict with platform_limits containing allowed_providers "anthropic,google" + Then a ConfigurationError is raised + And the error message mentions provider validation + + # ── Review Cycle 2: positive valid node type coverage ───────────────── + + Scenario: Graph route with valid node type "start" passes validation + Given a config dict with a graph route containing a node of type "start" + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Graph route with valid node type "tool" passes validation + Given a config dict with a graph route containing a node of type "tool" + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Graph route with valid node type "conditional" passes validation + Given a config dict with a graph route containing a node of type "conditional" + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Graph route with valid node type "message_router" passes validation + Given a config dict with a graph route containing a node of type "message_router" + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Graph route with valid node type "function" passes validation + Given a config dict with a graph route containing a node of type "function" + When I call validate_dict with empty platform_limits + Then no exception is raised + + # ── Review Cycle 2: positive valid operator type coverage ───────────── + + Scenario: Stream route with valid operator type "filter" passes validation + Given a config dict with a stream route containing a "filter" operator + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Stream route with valid operator type "transform" passes validation + Given a config dict with a stream route containing a "transform" operator + When I call validate_dict with empty platform_limits + Then no exception is raised + + Scenario: Stream route with valid operator type "reduce" passes validation + Given a config dict with a stream route containing a "reduce" operator + When I call validate_dict with empty platform_limits + Then no exception is raised + + # ── Review Cycle 2: duplicate subgraph reference de-duplication ─────── + + Scenario: Route with duplicate subgraph references does not cause false too-complex error + Given a config dict with a graph route containing two subgraph nodes referencing the same route + When I call validate_dict with platform_limits max_subgraph_depth of 5 + Then no exception is raised \ No newline at end of file diff --git a/robot/CleverActorsLib.py b/robot/CleverActorsLib.py index 679fb8c..71547a9 100644 --- a/robot/CleverActorsLib.py +++ b/robot/CleverActorsLib.py @@ -1,10 +1,18 @@ -# Robot Framework resource and keyword library for cleveractors integration tests. -# This module is imported directly by Robot Framework test files. +"""Robot Framework resource and keyword library for cleveractors integration tests. +This module is imported directly by Robot Framework test files. +""" +from __future__ import annotations + +import asyncio +import json import os +import shutil import tempfile from pathlib import Path +from typing import Any +import cleveractors import yaml from cleveractors import ( @@ -14,6 +22,7 @@ from cleveractors import ( merge_configs, ) from cleveractors.agents.factory import Agent, AgentFactory +from cleveractors.agents.tool import ToolAgent from cleveractors.core.config import ConfigurationManager, SchemaValidator from cleveractors.core.exceptions import ( AgentCreationError, @@ -208,8 +217,6 @@ class CleverActorsLib: # pragma: no cover - integration test library def render_template_equals( self, name: str, context_json: str, expected: str ) -> None: - import json - ctx = json.loads(context_json) result = self._renderer.render(name, ctx) if str(result) != expected: @@ -220,8 +227,6 @@ class CleverActorsLib: # pragma: no cover - integration test library def render_string_equals( self, template: str, context_json: str, expected: str ) -> None: - import json - ctx = json.loads(context_json) result = self._renderer.render_string(template, ctx) if str(result) != expected: @@ -232,8 +237,6 @@ class CleverActorsLib: # pragma: no cover - integration test library def render_string_contains( self, template: str, context_json: str, expected: str ) -> None: - import json - ctx = json.loads(context_json) result = self._renderer.render_string(template, ctx) if expected not in str(result): @@ -341,8 +344,6 @@ class CleverActorsLib: # pragma: no cover - integration test library raise AssertionError(f"Context {name!r} not in list: {contexts}") def cleanup_temp_dir(self) -> None: - import shutil - if hasattr(self, "_temp_dir") and os.path.isdir(self._temp_dir): shutil.rmtree(self._temp_dir, ignore_errors=True) @@ -396,8 +397,6 @@ class CleverActorsLib: # pragma: no cover - integration test library self._factory = AgentFactory(config, self._renderer) def factory_registers_tool_agent(self) -> None: - from cleveractors.agents.tool import ToolAgent - self._factory.register_agent_type("tool", ToolAgent) agent_types = self._factory.get_agent_types() if "tool" not in agent_types: @@ -414,8 +413,6 @@ class CleverActorsLib: # pragma: no cover - integration test library raise AssertionError(f"Metadata missing key {key!r}: {meta}") def factory_create_all_agents_count(self, expected: str) -> None: - from cleveractors.agents.tool import ToolAgent - self._factory.register_agent_type("tool", ToolAgent) agents = self._factory.create_agents_from_config() if len(agents) != int(expected): @@ -448,8 +445,6 @@ class CleverActorsLib: # pragma: no cover - integration test library raise AssertionError("App has no agents") def app_can_dispose(self) -> None: - import asyncio - async def _dispose(): await self._app.dispose() diff --git a/robot/ValidateDictLib.py b/robot/ValidateDictLib.py new file mode 100644 index 0000000..0f6af4a --- /dev/null +++ b/robot/ValidateDictLib.py @@ -0,0 +1,70 @@ +"""Robot Framework keyword library for the public validate_dict() API. + +Contains keywords extracted from CleverActorsLib.py to keep that file +under the 500-line limit (CONTRIBUTING.md §General Principles). +""" + +from __future__ import annotations + +from typing import Any + +import cleveractors +from cleveractors import validate_dict +from cleveractors.core.exceptions import ConfigurationError + + +class ValidateDictLib: # pragma: no cover - integration test library + """Keyword library for validate_dict Robot Framework integration tests.""" + + ROBOT_LIBRARY_SCOPE = "TEST SUITE" + + def validate_dict_succeeds( + self, config_dict: dict[str, Any], platform_limits: dict[str, Any] + ) -> dict[str, Any]: + """Call validate_dict and return the result; raises on any error.""" + return validate_dict(config_dict, platform_limits) + + def validate_dict_raises_configuration_error( + self, config_dict: dict[str, Any], platform_limits: dict[str, Any] + ) -> None: + """Assert that validate_dict raises ConfigurationError.""" + try: + validate_dict(config_dict, platform_limits) + except ConfigurationError: + return + raise AssertionError( + "Expected ConfigurationError but validate_dict returned without error" + ) + + def validate_dict_returns_same_object( + self, config_dict: dict[str, Any] + ) -> None: + """Assert that validate_dict returns the same dict object (identity).""" + result = validate_dict(config_dict, {}) + if result is not config_dict: + raise AssertionError( + "validate_dict must return the same dict object as the input, " + "but returned a different object." + ) + + def validate_dict_is_exported(self) -> None: + """Assert that validate_dict is in cleveractors.__all__.""" + if "validate_dict" not in cleveractors.__all__: + raise AssertionError( + f"'validate_dict' not found in cleveractors.__all__: {cleveractors.__all__}" + ) + + def configuration_error_importable_from_top_level_package(self) -> None: + """Assert that ConfigurationError is importable from cleveractors.""" + if not hasattr(cleveractors, "ConfigurationError"): + raise AssertionError( + "ConfigurationError is not importable from cleveractors" + ) + + def configuration_error_listed_in_all(self) -> None: + """Assert that ConfigurationError is listed in cleveractors.__all__.""" + if "ConfigurationError" not in cleveractors.__all__: + raise AssertionError( + f"'ConfigurationError' not found in cleveractors.__all__: " + f"{cleveractors.__all__}" + ) \ No newline at end of file diff --git a/robot/validate_dict.robot b/robot/validate_dict.robot new file mode 100644 index 0000000..558e3ae --- /dev/null +++ b/robot/validate_dict.robot @@ -0,0 +1,188 @@ +*** Settings *** +Documentation Integration tests for the public validate_dict() API (ADR-2024, ADR-2025, ADR-2029) +Library ValidateDictLib.py +Library CleverActorsLib.py +Library Collections + +*** Keywords *** + +Build Minimal Config + [Documentation] Return a minimal valid spec-conformant config dict. + ${echo_agent}= Create Dictionary type=tool + ${agents}= Create Dictionary echo=${echo_agent} + ${map_params}= Create Dictionary agent=echo + ${map_op}= Create Dictionary type=map params=${map_params} + @{operators}= Create List ${map_op} + @{publications}= Create List __output__ + ${main_route}= Create Dictionary type=stream operators=${operators} publications=${publications} + ${routes}= Create Dictionary main=${main_route} + ${config}= Create Dictionary agents=${agents} routes=${routes} + RETURN ${config} + +*** Test Cases *** + +validate_dict Is Exported From Package + [Documentation] validate_dict must be listed in cleveractors.__all__ + Validate Dict Is Exported + +validate_dict Returns Same Object For Valid Config + [Documentation] validate_dict must return the same dict object (identity contract) + ${config}= Build Minimal Config + Validate Dict Returns Same Object ${config} + +validate_dict Raises ConfigurationError For Missing agents Key + [Documentation] Config without 'agents' key must raise ConfigurationError + ${map_params}= Create Dictionary agent=echo + ${map_op}= Create Dictionary type=map params=${map_params} + @{operators}= Create List ${map_op} + @{publications}= Create List __output__ + ${main_route}= Create Dictionary type=stream operators=${operators} publications=${publications} + ${routes}= Create Dictionary main=${main_route} + ${config}= Create Dictionary routes=${routes} + ${limits}= Create Dictionary + Validate Dict Raises Configuration Error ${config} ${limits} + +validate_dict Raises ConfigurationError For Missing routes Key + [Documentation] Config without 'routes' key must raise ConfigurationError + ${echo_agent}= Create Dictionary type=tool + ${agents}= Create Dictionary echo=${echo_agent} + ${config}= Create Dictionary agents=${agents} + ${limits}= Create Dictionary + Validate Dict Raises Configuration Error ${config} ${limits} + +validate_dict Raises ConfigurationError For Unknown Agent Type + [Documentation] An agent with an unknown type must raise ConfigurationError + ${config}= Build Minimal Config + ${bad_agent}= Create Dictionary type=nonexistent_type + Set To Dictionary ${config}[agents] bad_agent=${bad_agent} + ${limits}= Create Dictionary + Validate Dict Raises Configuration Error ${config} ${limits} + +validate_dict Accepts Valid LLM Providers By Default + [Documentation] LLM agents with openai/anthropic/google pass with empty platform_limits + ${config}= Build Minimal Config + ${llm_cfg}= Create Dictionary provider=openai + ${llm_agent}= Create Dictionary type=llm config=${llm_cfg} + Set To Dictionary ${config}[agents] ai_agent=${llm_agent} + ${limits}= Create Dictionary + Validate Dict Succeeds ${config} ${limits} + +validate_dict Enforces Provider Allowlist + [Documentation] An LLM agent with a provider not in allowed_providers must raise ConfigurationError + ${config}= Build Minimal Config + ${llm_cfg}= Create Dictionary provider=anthropic + ${llm_agent}= Create Dictionary type=llm config=${llm_cfg} + Set To Dictionary ${config}[agents] ai_agent=${llm_agent} + @{allowed}= Create List openai + ${limits}= Create Dictionary allowed_providers=${allowed} + Validate Dict Raises Configuration Error ${config} ${limits} + +validate_dict Rejects Legacy "from" Edge Field + [Documentation] Graph route edges using 'from' must raise ConfigurationError (ADR-2025) + ${config}= Build Minimal Config + ${bad_edge}= Create Dictionary from=node_a target=end + @{edges}= Create List ${bad_edge} + ${node_a}= Create Dictionary type=agent agent=echo + ${end_node}= Create Dictionary type=end + ${nodes}= Create Dictionary node_a=${node_a} end=${end_node} + ${graph_route}= Create Dictionary type=graph entry_point=node_a nodes=${nodes} edges=${edges} + Set To Dictionary ${config}[routes] graph_r=${graph_route} + ${limits}= Create Dictionary + Validate Dict Raises Configuration Error ${config} ${limits} + +validate_dict Rejects Legacy "to" Edge Field + [Documentation] Graph route edges using 'to' must raise ConfigurationError (ADR-2025) + ${config}= Build Minimal Config + ${bad_edge}= Create Dictionary source=node_a to=end + @{edges}= Create List ${bad_edge} + ${node_a}= Create Dictionary type=agent agent=echo + ${end_node}= Create Dictionary type=end + ${nodes}= Create Dictionary node_a=${node_a} end=${end_node} + ${graph_route}= Create Dictionary type=graph entry_point=node_a nodes=${nodes} edges=${edges} + Set To Dictionary ${config}[routes] graph_r=${graph_route} + ${limits}= Create Dictionary + Validate Dict Raises Configuration Error ${config} ${limits} + +validate_dict Enforces max_graph_depth + [Documentation] A graph exceeding max_graph_depth must raise ConfigurationError (ADR-2029) + ${config}= Build Minimal Config + ${n0}= Create Dictionary type=agent agent=echo + ${n1}= Create Dictionary type=agent agent=echo + ${n2}= Create Dictionary type=agent agent=echo + ${n3}= Create Dictionary type=agent agent=echo + ${end}= Create Dictionary type=end + ${nodes}= Create Dictionary n0=${n0} n1=${n1} n2=${n2} n3=${n3} end=${end} + ${e01}= Create Dictionary source=n0 target=n1 + ${e12}= Create Dictionary source=n1 target=n2 + ${e23}= Create Dictionary source=n2 target=n3 + ${e3e}= Create Dictionary source=n3 target=end + @{edges}= Create List ${e01} ${e12} ${e23} ${e3e} + ${graph}= Create Dictionary type=graph entry_point=n0 nodes=${nodes} edges=${edges} + Set To Dictionary ${config}[routes] deep=${graph} + ${limits}= Create Dictionary max_graph_depth=${2} + Validate Dict Raises Configuration Error ${config} ${limits} + +validate_dict Enforces max_total_nodes + [Documentation] An actor exceeding max_total_nodes must raise ConfigurationError (ADR-2029) + ${config}= Build Minimal Config + ${n0}= Create Dictionary type=agent agent=echo + ${n1}= Create Dictionary type=agent agent=echo + ${n2}= Create Dictionary type=agent agent=echo + ${n3}= Create Dictionary type=agent agent=echo + ${n4}= Create Dictionary type=agent agent=echo + ${n5}= Create Dictionary type=agent agent=echo + ${end}= Create Dictionary type=end + ${nodes}= Create Dictionary n0=${n0} n1=${n1} n2=${n2} n3=${n3} n4=${n4} n5=${n5} end=${end} + @{edges}= Create List + ${graph}= Create Dictionary type=graph entry_point=n0 nodes=${nodes} edges=${edges} + Set To Dictionary ${config}[routes] big=${graph} + ${limits}= Create Dictionary max_total_nodes=${3} + Validate Dict Raises Configuration Error ${config} ${limits} + +validate_dict Accepts Minimal Valid Config + [Documentation] validate_dict works on a minimal valid config without any app construction + ${config}= Build Minimal Config + ${limits}= Create Dictionary + Validate Dict Succeeds ${config} ${limits} + +validate_dict Enforces max_subgraph_depth + [Documentation] Nested subgraphs exceeding max_subgraph_depth must raise ConfigurationError (ADR-2029) + ${config}= Build Minimal Config + ${worker}= Create Dictionary type=agent agent=echo + ${end}= Create Dictionary type=end + ${sub_node}= Create Dictionary type=subgraph subgraph=child_graph + ${top_nodes}= Create Dictionary w=${worker} s=${sub_node} end=${end} + ${e1}= Create Dictionary source=w target=s + ${e2}= Create Dictionary source=s target=end + @{top_edges}= Create List ${e1} ${e2} + ${top_graph}= Create Dictionary type=graph entry_point=w nodes=${top_nodes} edges=${top_edges} + ${child_w}= Create Dictionary type=agent agent=echo + ${child_sub}= Create Dictionary type=subgraph subgraph=grandchild_graph + ${child_nodes}= Create Dictionary w=${child_w} s=${child_sub} end=${end} + ${c1}= Create Dictionary source=w target=s + ${c2}= Create Dictionary source=s target=end + @{child_edges}= Create List ${c1} ${c2} + ${child_graph}= Create Dictionary type=graph entry_point=w nodes=${child_nodes} edges=${child_edges} + ${gc_w}= Create Dictionary type=agent agent=echo + ${gc_sub}= Create Dictionary type=subgraph subgraph=nested + ${gc_nodes}= Create Dictionary w=${gc_w} s=${gc_sub} end=${end} + ${g1}= Create Dictionary source=w target=s + ${g2}= Create Dictionary source=s target=end + @{gc_edges}= Create List ${g1} ${g2} + ${grandchild_graph}= Create Dictionary type=graph entry_point=w nodes=${gc_nodes} edges=${gc_edges} + ${nested_w}= Create Dictionary type=agent agent=echo + ${nested_nodes}= Create Dictionary w=${nested_w} end=${end} + ${n1}= Create Dictionary source=w target=end + @{nested_edges}= Create List ${n1} + ${nested_graph}= Create Dictionary type=graph entry_point=w nodes=${nested_nodes} edges=${nested_edges} + Set To Dictionary ${config}[routes] top_graph=${top_graph} child_graph=${child_graph} grandchild_graph=${grandchild_graph} nested=${nested_graph} + ${limits}= Create Dictionary max_subgraph_depth=${1} + Validate Dict Raises Configuration Error ${config} ${limits} + +ConfigurationError Is Importable From Top-Level Package + [Documentation] ConfigurationError must be importable from cleveractors (not just cleveractors.core.exceptions) + Configuration Error Importable From Top Level Package + +ConfigurationError Is Listed In Package all + [Documentation] ConfigurationError must be listed in cleveractors.__all__ + Configuration Error Listed In All diff --git a/src/cleveractors/__init__.py b/src/cleveractors/__init__.py index 380503c..4f9c42f 100644 --- a/src/cleveractors/__init__.py +++ b/src/cleveractors/__init__.py @@ -13,25 +13,26 @@ from cleveractors.agent import Agent from cleveractors.config_utils import merge_configs from cleveractors.context_manager import ContextManager from cleveractors.core.application import ReactiveCleverAgentsApp -from cleveractors.core.exceptions import CleverAgentsException +from cleveractors.core.exceptions import CleverAgentsException, ConfigurationError from cleveractors.runtime import ( ActorResult, Executor, NodeUsage, create_executor, - validate_dict, ) +from cleveractors.validation import validate_dict __all__ = [ "__version__", "Agent", - "merge_configs", - "ContextManager", - "ReactiveCleverAgentsApp", + "ActorResult", "CleverAgentsException", - "validate_dict", + "ConfigurationError", + "ContextManager", "create_executor", "Executor", - "ActorResult", + "merge_configs", "NodeUsage", + "ReactiveCleverAgentsApp", + "validate_dict", ] diff --git a/src/cleveractors/runtime.py b/src/cleveractors/runtime.py index 999a795..b4321ee 100644 --- a/src/cleveractors/runtime.py +++ b/src/cleveractors/runtime.py @@ -1,7 +1,6 @@ """Router-facing runtime API for cleveractors-core. This module provides the public API that the CleverThis router consumes: - - validate_dict(d, platform_limits) - create_executor(config_dict, credentials, limits, pricing) - ActorResult / NodeUsage dataclasses @@ -48,98 +47,13 @@ class ActorResult: # --------------------------------------------------------------------------- -# Validation -# --------------------------------------------------------------------------- - - -def validate_dict(config_dict: dict[str, Any], platform_limits: Optional[dict[str, Any]] = None) -> dict[str, Any]: - """Validate a Python dict against the Actor Configuration Standard. - - Args: - config_dict: The raw configuration dictionary. - platform_limits: Optional platform-enforced limits - (max_graph_depth, max_subgraph_depth, max_total_nodes). - - Returns: - The validated dictionary (may include normalized defaults). - - Raises: - ConfigurationError: If the configuration is invalid. - """ - if not isinstance(config_dict, dict): - raise ConfigurationError("Actor config must be a dict/mapping.") - - # Determine actor type - actor_type = config_dict.get("type") - if actor_type is None: - # Multi-actor bundles have "actors" top-level key - if "actors" in config_dict: - actor_type = "multi_actor" - else: - raise ConfigurationError("Actor config must have 'type' field.") - - valid_types = {"llm", "graph", "tool", "multi_actor"} - if actor_type not in valid_types: - raise ConfigurationError(f"Unknown actor type: {actor_type!r}. Must be one of {sorted(valid_types)}.") - - # Validate name presence - if "name" not in config_dict and actor_type != "multi_actor": - raise ConfigurationError("Actor config must have a 'name' field.") - - # Platform limits - limits = platform_limits or {} - max_total_nodes = limits.get("max_total_nodes", 50) - - if actor_type == "graph": - route = config_dict.get("route") - if not isinstance(route, dict): - raise ConfigurationError("Graph actor must have a 'route' mapping.") - nodes = route.get("nodes", []) - if len(nodes) > max_total_nodes: - raise ConfigurationError( - f"Graph has {len(nodes)} nodes; platform limit is {max_total_nodes}." - ) - # Check for required fields - if "edges" not in route: - raise ConfigurationError("Graph route must have 'edges' list.") - if "entry_node" not in route: - raise ConfigurationError("Graph route must have 'entry_node'.") - - elif actor_type == "llm": - config_block = config_dict.get("config", {}) - provider = config_dict.get("provider") or config_block.get("provider") - model = config_dict.get("model") or config_block.get("model") - if not provider: - raise ConfigurationError("LLM actor must specify 'provider'.") - if not model: - raise ConfigurationError("LLM actor must specify 'model'.") - - elif actor_type == "tool": - tools = config_dict.get("tools", config_dict.get("config", {}).get("tools", [])) - if not tools: - raise ConfigurationError("Tool actor must specify at least one 'tool'.") - - elif actor_type == "multi_actor": - actors = config_dict.get("actors", {}) - if not isinstance(actors, dict) or not actors: - raise ConfigurationError("Multi-actor config must have a non-empty 'actors' mapping.") - total_nodes = sum( - len(a.get("route", {}).get("nodes", [])) if a.get("type") == "graph" else 1 - for a in actors.values() - ) - if total_nodes > max_total_nodes: - raise ConfigurationError( - f"Multi-actor bundle has {total_nodes} total nodes; platform limit is {max_total_nodes}." - ) - - # Return a deep copy so callers can't mutate the validated result - return copy.deepcopy(config_dict) # --------------------------------------------------------------------------- # Executor # --------------------------------------------------------------------------- + class Executor: """Runnable actor executor. @@ -169,7 +83,9 @@ class Executor: Returns: An :class:`ActorResult` with the response and token usage. """ - actor_type = self.config.get("type", "multi_actor" if "actors" in self.config else "llm") + actor_type = self.config.get( + "type", "multi_actor" if "actors" in self.config else "llm" + ) if actor_type == "llm": return await self._execute_llm(message) @@ -191,9 +107,15 @@ class Executor: config_block = self.config.get("config", {}) provider = self.config.get("provider") or config_block.get("provider", "openai") model = self.config.get("model") or config_block.get("model", "gpt-3.5-turbo") - system_prompt = self.config.get("system_prompt") or config_block.get("system_prompt", "") - temperature = self.config.get("temperature") or config_block.get("temperature", 0.7) - max_tokens = self.config.get("max_tokens") or config_block.get("max_tokens", 1000) + system_prompt = self.config.get("system_prompt") or config_block.get( + "system_prompt", "" + ) + temperature = self.config.get("temperature") or config_block.get( + "temperature", 0.7 + ) + max_tokens = self.config.get("max_tokens") or config_block.get( + "max_tokens", 1000 + ) # Inject credentials agent_config: dict[str, Any] = { @@ -203,14 +125,22 @@ class Executor: "temperature": temperature, "max_tokens": max_tokens, } - creds = self.credentials.get(provider) or self.credentials.get("openai_compatible") or {} + creds = ( + self.credentials.get(provider) + or self.credentials.get("openai_compatible") + or {} + ) if creds.get("api_key"): agent_config["api_key"] = creds["api_key"] if creds.get("base_url"): agent_config["base_url"] = creds["base_url"] renderer = TemplateRenderer({}) - agent = LLMAgent(name=self.config.get("name", "llm"), config=agent_config, template_renderer=renderer) + agent = LLMAgent( + name=self.config.get("name", "llm"), + config=agent_config, + template_renderer=renderer, + ) # Track usage via a simple callback wrapper prompt_tokens = 0 @@ -224,7 +154,9 @@ class Executor: raise ConfigurationError(f"LLM execution failed: {exc}") from exc # Estimate tokens if the agent didn't track them - prompt_tokens, completion_tokens = _estimate_tokens(message, response, model, provider) + prompt_tokens, completion_tokens = _estimate_tokens( + message, response, model, provider + ) node_usage = NodeUsage( node_id=self.config.get("name", "llm"), @@ -281,12 +213,14 @@ class Executor: pg_edges: List[Edge] = [] for edge_def in edges_cfg: - pg_edges.append(Edge( - source=edge_def.get("from", edge_def.get("source", "")), - target=edge_def.get("to", edge_def.get("target", "")), - condition=edge_def.get("condition"), - metadata=edge_def.get("metadata", {}), - )) + pg_edges.append( + Edge( + source=edge_def.get("from", edge_def.get("source", "")), + target=edge_def.get("to", edge_def.get("target", "")), + condition=edge_def.get("condition"), + metadata=edge_def.get("metadata", {}), + ) + ) pg_config = PureGraphConfig( name=self.config.get("name", "graph"), @@ -298,7 +232,9 @@ class Executor: # Build agents with credential injection renderer = TemplateRenderer({}) - factory = AgentFactory(config=self._build_factory_config(), template_renderer=renderer) + factory = AgentFactory( + config=self._build_factory_config(), template_renderer=renderer + ) # Pre-create agents referenced by nodes agents: dict[str, Any] = {} @@ -320,7 +256,9 @@ class Executor: # For graph execution, we don't have per-node token tracking yet # so we estimate based on the final response - prompt_tokens, completion_tokens = _estimate_tokens(message, str(response), "gpt-3.5-turbo", "openai") + prompt_tokens, completion_tokens = _estimate_tokens( + message, str(response), "gpt-3.5-turbo", "openai" + ) node_usage = NodeUsage( node_id=entry_point, @@ -422,7 +360,9 @@ class Executor: # Find agents using this provider and inject creds for _agent_name, agent_cfg in agents_block.items(): if isinstance(agent_cfg, dict): - agent_provider = agent_cfg.get("provider") or agent_cfg.get("config", {}).get("provider", "openai") + agent_provider = agent_cfg.get("provider") or agent_cfg.get( + "config", {} + ).get("provider", "openai") if agent_provider == provider or provider == "openai_compatible": agent_cfg.setdefault("config", {}) if creds.get("api_key"): @@ -476,10 +416,14 @@ def create_executor( # Token estimation # --------------------------------------------------------------------------- -def _estimate_tokens(prompt: str, response: str, model: str, provider: str) -> tuple[int, int]: + +def _estimate_tokens( + prompt: str, response: str, model: str, provider: str +) -> tuple[int, int]: """Estimate token counts using tiktoken when available, fallback to heuristic.""" try: import tiktoken + # Try to get encoding for the model enc = None if "gpt-4" in model or "gpt-3.5" in model: @@ -489,7 +433,7 @@ def _estimate_tokens(prompt: str, response: str, model: str, provider: str) -> t prompt_tokens = len(enc.encode(prompt)) completion_tokens = len(enc.encode(response)) return prompt_tokens, completion_tokens - except Exception: + except Exception: # nosec B110 – intentional silent fallback: tiktoken unavailable or model unrecognised; heuristic below is the designed degradation path pass # Fallback: ~4 chars per token for English text diff --git a/src/cleveractors/validation/__init__.py b/src/cleveractors/validation/__init__.py new file mode 100644 index 0000000..b1b5b36 --- /dev/null +++ b/src/cleveractors/validation/__init__.py @@ -0,0 +1,89 @@ +""" +Public validation API for Actor Configuration Standard v1.0.0 dicts. + +This sub-package exposes ``validate_dict``, the single router-facing +function that validates a Python dict against the spec-conformant +Actor Configuration Standard and enforces platform-level structural +constraints. +""" + +from __future__ import annotations + +from typing import Any + +from cleveractors.core.exceptions import ConfigurationError +from cleveractors.validation._agents import validate_agents +from cleveractors.validation._limits import validate_structural_limits +from cleveractors.validation._routes import validate_routes + +__all__ = ["validate_dict"] + + +def validate_dict( + config_dict: dict[str, Any], + platform_limits: dict[str, Any], +) -> dict[str, Any]: + """Validate a spec-conformant Actor Configuration dict. + + Checks the supplied *config_dict* against the Actor Configuration Standard + v1.0.0 and enforces any structural platform limits supplied in + *platform_limits*. Returns the dict unchanged when all checks pass. + + The function is a pure static validator: + + - It does **not** read files, environment variables, or construct any + application object. + - It does **not** mutate *config_dict*. + - It raises :class:`~cleveractors.core.exceptions.ConfigurationError` for + every detected violation. + + Args: + config_dict: Python dict representing a parsed Actor configuration + document (already loaded from YAML by the caller). + platform_limits: Dict of platform-level structural constraints. + Recognized keys: + + - ``"allowed_providers"`` *(list[str])* — provider allowlist for + LLM agents. When absent, defaults to + ``{"openai", "anthropic", "google"}``. + - ``"max_graph_depth"`` *(int)* — maximum longest-path depth + (number of edges from the entry node to the end node) for any + single graph route. Not enforced when absent. + - ``"max_subgraph_depth"`` *(int)* — maximum nesting depth of + subgraph references across all routes. Not enforced when absent. + - ``"max_total_nodes"`` *(int)* — maximum total count of declared + nodes across **all** graph routes. Not enforced when absent. + + Returns: + *config_dict* unchanged. + + Raises: + ConfigurationError: On any validation failure. + """ + if not isinstance(config_dict, dict): + raise ConfigurationError( + f"validate_dict expects config_dict to be a dict, " + f"got {type(config_dict).__name__}." + ) + if not isinstance(platform_limits, dict): + raise ConfigurationError( + f"validate_dict expects platform_limits to be a dict, " + f"got {type(platform_limits).__name__}." + ) + _validate_top_level_keys(config_dict) + validate_agents(config_dict["agents"], platform_limits) + validate_routes(config_dict["routes"]) + validate_structural_limits(config_dict["routes"], platform_limits) + return config_dict + + +def _validate_top_level_keys(config_dict: dict[str, Any]) -> None: + """Raise ConfigurationError if 'agents' or 'routes' are missing.""" + if "agents" not in config_dict: + raise ConfigurationError( + "Invalid actor configuration: required top-level key 'agents' is missing." + ) + if "routes" not in config_dict: + raise ConfigurationError( + "Invalid actor configuration: required top-level key 'routes' is missing." + ) diff --git a/src/cleveractors/validation/_agents.py b/src/cleveractors/validation/_agents.py new file mode 100644 index 0000000..8e6a397 --- /dev/null +++ b/src/cleveractors/validation/_agents.py @@ -0,0 +1,116 @@ +""" +Validators for the ``agents`` section of an Actor Configuration. +""" + +from __future__ import annotations + +from typing import Any + +from cleveractors.core.exceptions import ConfigurationError +from cleveractors.validation._vocabulary import ( + DEFAULT_ALLOWED_PROVIDERS, + VALID_AGENT_TYPES, +) + + +def validate_agents( + agents: Any, + platform_limits: dict[str, Any], +) -> None: + """Validate the 'agents' section. + + For each agent: + - The agent type must be one of the VALID_AGENT_TYPES. + - If the type is 'llm', the declared provider (defaulting to 'openai') must + be in the allowed-providers set derived from *platform_limits*. + """ + if not isinstance(agents, dict): + raise ConfigurationError( + "Invalid actor configuration: 'agents' must be a mapping." + ) + + # Determine the effective provider allowlist. + raw_allowed = platform_limits.get("allowed_providers") + if raw_allowed is not None: + if not isinstance(raw_allowed, (list, tuple, set, frozenset)): + raise ConfigurationError( + "Invalid platform_limits: 'allowed_providers' must be a list of " + f"strings, got {type(raw_allowed).__name__}." + ) + for p in raw_allowed: + if not isinstance(p, str): + raise ConfigurationError( + "Invalid platform_limits: each entry in 'allowed_providers' " + f"must be a string, got {type(p).__name__}." + ) + allowed_providers: frozenset[str] = frozenset( + p.strip().lower() for p in raw_allowed + ) + else: + allowed_providers = DEFAULT_ALLOWED_PROVIDERS + + for agent_name, agent_def in agents.items(): + if not isinstance(agent_name, str): + raise ConfigurationError( + f"Agent key must be a string, got {type(agent_name).__name__}." + ) + if agent_name.startswith("__"): + raise ConfigurationError( + f"Agent '{agent_name}': agent names beginning with '__' " + "are reserved for internal use." + ) + if not isinstance(agent_def, dict): + raise ConfigurationError( + f"Agent '{agent_name}': definition must be a mapping." + ) + + agent_type = agent_def.get("type") + if agent_type is None: + # Template-instance agents may omit 'type' in favour of 'template' / + # 'agent_template' keys — treat as template_instance implicitly. + # All other missing types are errors. + if "template" not in agent_def and "agent_template" not in agent_def: + raise ConfigurationError( + f"Agent '{agent_name}': missing required 'type' field." + ) + continue + + if not isinstance(agent_type, str): + raise ConfigurationError( + f"Agent '{agent_name}': type must be a string, " + f"got {type(agent_type).__name__}." + ) + if agent_type not in VALID_AGENT_TYPES: + raise ConfigurationError( + f"Agent '{agent_name}': unknown agent type '{agent_type}'. " + f"Valid types are: {sorted(VALID_AGENT_TYPES)}." + ) + + if agent_type == "llm": + _validate_llm_provider(agent_name, agent_def, allowed_providers) + + +def _validate_llm_provider( + agent_name: str, + agent_def: dict[str, Any], + allowed_providers: frozenset[str], +) -> None: + """Validate the 'provider' field of an LLM agent.""" + agent_config = agent_def.get("config", {}) + if not isinstance(agent_config, dict): + raise ConfigurationError( + f"Agent '{agent_name}' (type 'llm'): 'config' must be a mapping, " + f"got {type(agent_config).__name__}." + ) + # §4.4 — provider defaults to "openai" when unspecified. + provider = agent_config.get("provider", "openai") + if not isinstance(provider, str): + raise ConfigurationError( + f"Agent '{agent_name}' (type 'llm'): provider must be a string, " + f"got {type(provider).__name__}." + ) + if provider.strip().lower() not in allowed_providers: + raise ConfigurationError( + f"Agent '{agent_name}' (type 'llm'): provider '{provider}' is not " + f"in the allowed-providers list: {sorted(allowed_providers)}." + ) diff --git a/src/cleveractors/validation/_limits.py b/src/cleveractors/validation/_limits.py new file mode 100644 index 0000000..a440c41 --- /dev/null +++ b/src/cleveractors/validation/_limits.py @@ -0,0 +1,239 @@ +""" +Structural limit enforcement — graph depth, total nodes, subgraph depth. +""" + +from __future__ import annotations + +from typing import Any, Iterator + +from cleveractors.core.exceptions import ConfigurationError +from cleveractors.validation._vocabulary import _MAX_DFS_STEPS, _MAX_SUBGRAPH_STEPS + + +def _validate_limit_type(label: str, value: Any) -> None: + """Validate that a platform limit value is None or a non-negative int.""" + if value is not None: + if not isinstance(value, int) or isinstance(value, bool): + raise ConfigurationError( + f"Invalid platform_limits: '{label}' must be an integer, " + f"got {type(value).__name__}." + ) + if value < 0: + raise ConfigurationError( + f"Invalid platform_limits: '{label}' must be >= 0, got {value}." + ) + + +def validate_structural_limits( + routes: dict[str, Any], + platform_limits: dict[str, Any], +) -> None: + """Enforce max_graph_depth, max_total_nodes, and max_subgraph_depth.""" + max_graph_depth = platform_limits.get("max_graph_depth") + max_total_nodes = platform_limits.get("max_total_nodes") + max_subgraph_depth = platform_limits.get("max_subgraph_depth") + + _validate_limit_type("max_graph_depth", max_graph_depth) + _validate_limit_type("max_total_nodes", max_total_nodes) + _validate_limit_type("max_subgraph_depth", max_subgraph_depth) + + # Total node count across all graph routes. + if max_total_nodes is not None: + total = _count_total_nodes(routes) + if total > max_total_nodes: + raise ConfigurationError( + f"Actor configuration exceeds the platform's max_total_nodes limit of " + f"{max_total_nodes}: found {total} nodes across all graph routes." + ) + + if max_graph_depth is not None or max_subgraph_depth is not None: + depth_cache: dict[str, int] = {} + for route_name, route_def in routes.items(): + if route_def.get("type") != "graph": + continue + + if max_graph_depth is not None: + depth = _compute_graph_depth(route_name, route_def) + if depth > max_graph_depth: + raise ConfigurationError( + f"Route '{route_name}' exceeds the platform's max_graph_depth " + f"limit of {max_graph_depth}: longest path has depth {depth}." + ) + + if max_subgraph_depth is not None: + sub_depth = _compute_subgraph_depth(route_name, routes, depth_cache) + if sub_depth > max_subgraph_depth: + raise ConfigurationError( + f"Route '{route_name}' exceeds the platform's " + f"max_subgraph_depth limit of {max_subgraph_depth}: " + f"nested subgraph depth is {sub_depth}." + ) + + +def _count_total_nodes(routes: dict[str, Any]) -> int: + """Count all declared nodes across all graph routes. + + The *routes* dict has already been validated by + ``validate_routes``, which guarantees that every graph route + has a ``nodes`` key whose value is a ``dict``. + """ + total = 0 + for route_def in routes.values(): + if route_def.get("type") != "graph": + continue + nodes = route_def.get("nodes", {}) + if not isinstance(nodes, dict): # pragma: no branch + raise ConfigurationError( + "Invariant violation: graph route 'nodes' must be a dict " + "(guaranteed by validate_routes)." + ) + total += len(nodes) + return total + + +def _compute_graph_depth(route_name: str, route_def: dict[str, Any]) -> int: + """Return the longest-path depth (edge count) in a graph route. + + Uses iterative DFS with an explicit stack to avoid recursion limits. + Tracks visited nodes with a mutable ``set`` and explicit + add-on-push/pop-on-backtrack for O(1)-amortized per-step overhead. + The depth is the maximum number of edges on any simple path from the + entry node. + + The invariants below are guaranteed by ``_validate_graph_route`` + (in the ``_routes`` module), which always runs first. + """ + edges: list[dict[str, Any]] = route_def["edges"] # guaranteed list + entry_point: str = route_def["entry_point"] # guaranteed non-empty str + + # Build adjacency list from the edges. + adjacency: dict[str, set[str]] = {} + for edge in edges: + src = edge.get("source", "") + tgt = edge.get("target", "") + adjacency.setdefault(src, set()).add(tgt) + + # Iterative DFS with mutable-visited-set + explicit backtracking. + # Each stack entry is (node, iterator_over_neighbors, current_depth). + # When the iterator is exhausted we backtrack by removing *node* from + # the visited set. + max_depth = 0 + + # Prime the stack with the entry point. + neighbors = iter(adjacency.get(entry_point, [])) + stack: list[tuple[str, Iterator[str], int]] = [(entry_point, neighbors, 0)] + visited: set[str] = {entry_point} + steps = 0 + + while stack: + steps += 1 + if steps >= _MAX_DFS_STEPS: + raise ConfigurationError( + f"Route '{route_name}': graph is too complex to validate " + f"depth (exceeded {_MAX_DFS_STEPS} DFS steps)." + ) + + node, neighbor_iter, current_depth = stack[-1] + + try: + neighbor = next(neighbor_iter) + except StopIteration: + # All neighbors exhausted — backtrack. + stack.pop() + visited.discard(node) + continue + + if neighbor in visited: + continue # cycle detected — stop this path + + max_depth = max(max_depth, current_depth + 1) + visited.add(neighbor) + new_iter = iter(adjacency.get(neighbor, [])) + stack.append((neighbor, new_iter, current_depth + 1)) + + return max_depth + + +def _subgraph_children( + route_def: dict[str, Any], +) -> Iterator[str]: + """Yield de-duplicated subgraph-reference route names from a graph route's nodes.""" + nodes = route_def.get("nodes", {}) + seen: set[str] = set() + # The isinstance guards below are defensive invariant checks: + # _validate_graph_route guarantees nodes is a dict and each node_def + # is a dict, so these branches are dead from a coverage perspective. + if isinstance(nodes, dict): # pragma: no branch + for node_def in nodes.values(): + if ( + isinstance(node_def, dict) # pragma: no branch + and node_def.get("type", "").lower() == "subgraph" + ): + sub_ref = node_def.get("subgraph", "") + if isinstance(sub_ref, str) and sub_ref and sub_ref not in seen: + seen.add(sub_ref) + yield sub_ref + + +def _compute_subgraph_depth( + route_name: str, + routes: dict[str, Any], + depth_cache: dict[str, int], +) -> int: + """Return the subgraph nesting depth reachable from *route_name*. + + Uses iterative DFS over subgraph references with a single shared + mutable ``visiting_set`` and explicit add-on-push/discard-on-pop + backtracking — O(1) amortized per step, avoiding the O(D²) cost + of frozenset unions. + + *depth_cache* is a mutable dict shared across all calls within + a single ``validate_structural_limits`` invocation. Already-computed + depths are reused, avoiding redundant DFS traversals when multiple + graph routes share the same subgraph tree. + """ + if route_name in depth_cache: + return depth_cache[route_name] + # The initial route is guaranteed to be a dict with type='graph' + # by _validate_structural_limits, which only calls this for graph + # routes. + # Subgraph reference existence and target-type checks are handled + # unconditionally by _validate_graph_route, so we can assume all + # children referenced by subgraph nodes are valid graph routes. + route_def = routes[route_name] + + max_depth = 0 + visiting_set: set[str] = {route_name} + children = _subgraph_children(route_def) + stack: list[tuple[str, Iterator[str], int]] = [(route_name, children, 0)] + steps = 0 + + while stack: + steps += 1 + if steps >= _MAX_SUBGRAPH_STEPS: + raise ConfigurationError( + f"Subgraph depth computation exceeded {_MAX_SUBGRAPH_STEPS} " + "steps — the configuration may be too complex to validate." + ) + + current_route, child_iter, current_depth = stack[-1] + + try: + child = next(child_iter) + except StopIteration: + stack.pop() + visiting_set.discard(current_route) + continue + + if child in visiting_set: + continue # cycle guard + + # Route existence and type are guaranteed by _validate_graph_route. + child_def = routes[child] + + max_depth = max(max_depth, current_depth + 1) + visiting_set.add(child) + stack.append((child, _subgraph_children(child_def), current_depth + 1)) + + depth_cache[route_name] = max_depth + return max_depth diff --git a/src/cleveractors/validation/_routes.py b/src/cleveractors/validation/_routes.py new file mode 100644 index 0000000..8f1ad6b --- /dev/null +++ b/src/cleveractors/validation/_routes.py @@ -0,0 +1,247 @@ +""" +Validators for the ``routes`` section of an Actor Configuration. +""" + +from __future__ import annotations + +from typing import Any + +from cleveractors.core.exceptions import ConfigurationError +from cleveractors.validation._vocabulary import ( + VALID_NODE_TYPES, + VALID_OPERATOR_TYPES, + VALID_ROUTE_TYPES, +) + + +def validate_routes(routes: Any) -> None: + """Validate the 'routes' section. + + For each route: + - route type must be in VALID_ROUTE_TYPES. + - Graph routes: every edge must use 'source'/'target' (not 'from'/'to'); + every declared node type must be in VALID_NODE_TYPES. + - Stream routes: every operator type must be in VALID_OPERATOR_TYPES. + """ + if not isinstance(routes, dict): + raise ConfigurationError( + "Invalid actor configuration: 'routes' must be a mapping." + ) + + for route_name, route_def in routes.items(): + if not isinstance(route_name, str): + raise ConfigurationError( + f"Route key must be a string, got {type(route_name).__name__}." + ) + if route_name.startswith("__"): + raise ConfigurationError( + f"Route '{route_name}': route names beginning with '__' " + "are reserved for internal use." + ) + if not isinstance(route_def, dict): + raise ConfigurationError( + f"Route '{route_name}': definition must be a mapping." + ) + + route_type = route_def.get("type", "") + # ``is None`` handles the case where the ``type`` key is explicitly set + # to ``None`` (rather than absent, which returns ``""``). + if route_type is None or (isinstance(route_type, str) and not route_type): + raise ConfigurationError( + f"Route '{route_name}': missing required 'type' field." + ) + if not isinstance(route_type, str): + raise ConfigurationError( + f"Route '{route_name}': type must be a string, " + f"got {type(route_type).__name__}." + ) + if route_type not in VALID_ROUTE_TYPES: + raise ConfigurationError( + f"Route '{route_name}': unknown route type '{route_type}'. " + f"Valid types are: {sorted(VALID_ROUTE_TYPES)}." + ) + + if route_type == "graph": + _validate_graph_route(route_name, route_def, routes) + elif route_type == "stream": + _validate_stream_route(route_name, route_def) + # Bridge routes are intentionally opaque at this validation stage — + # their structure depends on external destination systems and cannot + # be statically validated against the Actor Configuration Standard + # vocabulary alone. + + +def _validate_graph_route( + route_name: str, + route_def: dict[str, Any], + routes: dict[str, Any], +) -> None: + """Validate graph-route edges and node types. + + *routes* is the full routes mapping, needed to resolve subgraph + references and to reject references to non-graph routes. + """ + # Nodes and edges must be present for graph routes. + if "nodes" not in route_def: + raise ConfigurationError( + f"Route '{route_name}' (type 'graph'): missing required 'nodes' section." + ) + if "edges" not in route_def: + raise ConfigurationError( + f"Route '{route_name}' (type 'graph'): missing required 'edges' section." + ) + + nodes_raw: Any = route_def["nodes"] + + # Validate entry_point — must be a non-empty string. + entry_point = route_def.get("entry_point") + if entry_point is None or not isinstance(entry_point, str) or not entry_point: + raise ConfigurationError( + f"Route '{route_name}' (type 'graph'): missing or non-string 'entry_point'." + ) + if isinstance(nodes_raw, dict) and entry_point not in nodes_raw: + raise ConfigurationError( + f"Route '{route_name}': entry_point '{entry_point}' does not match " + "any declared node." + ) + + edges: Any = route_def.get("edges", []) + if not isinstance(edges, list): + raise ConfigurationError(f"Route '{route_name}': 'edges' must be a list.") + for idx, edge in enumerate(edges): + if not isinstance(edge, dict): + raise ConfigurationError( + f"Route '{route_name}', edge {idx}: edge must be a mapping, " + f"got {type(edge).__name__}." + ) + # Reject legacy "from"/"to" field names (ADR-2025). + if "from" in edge: + raise ConfigurationError( + f"Route '{route_name}', edge {idx}: the field 'from' is not " + "accepted in the spec-conformant format. Use 'source' instead." + ) + if "to" in edge: + raise ConfigurationError( + f"Route '{route_name}', edge {idx}: the field 'to' is not " + "accepted in the spec-conformant format. Use 'target' instead." + ) + # Ensure edges have required 'source' and 'target' fields. + if "source" not in edge or "target" not in edge: + raise ConfigurationError( + f"Route '{route_name}', edge {idx}: each edge must have " + "'source' and 'target' fields." + ) + # Validate source and target are non-empty strings. + src_val = edge.get("source") + tgt_val = edge.get("target") + if ( + not isinstance(src_val, str) + or not src_val + or not isinstance(tgt_val, str) + or not tgt_val + ): + raise ConfigurationError( + f"Route '{route_name}', edge {idx}: 'source' and 'target' " + "must be non-empty strings." + ) + + if not isinstance(nodes_raw, dict): + raise ConfigurationError(f"Route '{route_name}': 'nodes' must be a mapping.") + for node_name, node_def in nodes_raw.items(): + if not isinstance(node_name, str): + raise ConfigurationError( + f"Route '{route_name}', node key must be a string, " + f"got {type(node_name).__name__}." + ) + if not isinstance(node_def, dict): + raise ConfigurationError( + f"Route '{route_name}', node '{node_name}': node definition " + f"must be a mapping, got {type(node_def).__name__}." + ) + node_type = node_def.get("type") + if node_type is None: + raise ConfigurationError( + f"Route '{route_name}', node '{node_name}': missing required " + "'type' field." + ) + if not isinstance(node_type, str): + raise ConfigurationError( + f"Route '{route_name}', node '{node_name}': node type must be " + f"a string, got {type(node_type).__name__}." + ) + if node_type.lower() not in VALID_NODE_TYPES: + raise ConfigurationError( + f"Route '{route_name}', node '{node_name}': unknown node type " + f"'{node_type}'. Valid types are: {sorted(VALID_NODE_TYPES)}." + ) + # Validate subgraph node references. + if node_type.lower() == "subgraph": + sub_ref = node_def.get("subgraph", "") + if not sub_ref or not isinstance(sub_ref, str): + raise ConfigurationError( + f"Route '{route_name}', node '{node_name}': subgraph node " + "requires a non-empty 'subgraph' reference." + ) + # Validate the referenced route exists. + child_def = routes.get(sub_ref) + if not isinstance(child_def, dict): + raise ConfigurationError( + f"Route '{route_name}', node '{node_name}': subgraph " + f"references route '{sub_ref}' which does not exist " + "in the configuration." + ) + # Validate the referenced route is a graph route. + child_route_type = child_def.get("type") + if child_route_type != "graph": + raise ConfigurationError( + f"Route '{route_name}', node '{node_name}': subgraph " + f"references route '{sub_ref}' which is of type " + f"'{child_route_type}', not 'graph'." + ) + + # Validate that edge endpoints reference declared nodes. + for idx, edge in enumerate(edges): + if not isinstance(edge, dict): + continue # pragma: no branch — already caught above + src = edge.get("source") + tgt = edge.get("target") + if not isinstance(src, str) or not isinstance(tgt, str): + continue # pragma: no branch — already caught above + if src not in nodes_raw: + raise ConfigurationError( + f"Route '{route_name}', edge {idx}: source node '{src}' " + "is not declared in 'nodes'." + ) + if tgt not in nodes_raw: + raise ConfigurationError( + f"Route '{route_name}', edge {idx}: target node '{tgt}' " + "is not declared in 'nodes'." + ) + + +def _validate_stream_route(route_name: str, route_def: dict[str, Any]) -> None: + """Validate stream-route operator types.""" + operators: Any = route_def.get("operators", []) + if not isinstance(operators, list): + raise ConfigurationError(f"Route '{route_name}': 'operators' must be a list.") + for idx, op in enumerate(operators): + if not isinstance(op, dict): + raise ConfigurationError( + f"Route '{route_name}', operator {idx}: operator must be a " + f"mapping, got {type(op).__name__}." + ) + op_type = op.get("type") + if op_type is None: + raise ConfigurationError( + f"Route '{route_name}', operator {idx}: missing required 'type' field." + ) + if not isinstance(op_type, str): + raise ConfigurationError( + f"Route '{route_name}', operator {idx}: operator type must be " + f"a string, got {type(op_type).__name__}." + ) + if not op_type or op_type not in VALID_OPERATOR_TYPES: + raise ConfigurationError( + f"Route '{route_name}', operator {idx}: unknown operator type " + f"'{op_type}'. Valid types are: {sorted(VALID_OPERATOR_TYPES)}." + ) diff --git a/src/cleveractors/validation/_vocabulary.py b/src/cleveractors/validation/_vocabulary.py new file mode 100644 index 0000000..234a2d4 --- /dev/null +++ b/src/cleveractors/validation/_vocabulary.py @@ -0,0 +1,92 @@ +""" +Vocabulary constants defined by the Actor Configuration Standard v1.0.0. + +These are module-level frozen sets shared by all validation sub-modules. +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Internal constants +# --------------------------------------------------------------------------- + +#: Maximum DFS steps in _compute_graph_depth before raising an error. +#: 50,000 steps is a safety ceiling — the DFS traversal is O(N + E) +#: per graph route, completing in well under 10,000 steps for a +#: well-formed configuration with thousands of nodes and a few hundred +#: edges per node. Deliberately pathological configurations (e.g., +#: a dense complete graph with M ≈ N² edges) can exhaust this ceiling +#: quickly, at which point failing fast is preferable to spinning +#: indefinitely. The 50k ceiling keeps worst-case CPU consumption at +#: upload-time under ~40 ms per graph route. +_MAX_DFS_STEPS: int = 50_000 + +#: Maximum DFS steps in _compute_subgraph_depth before raising an error. +#: Same rationale as _MAX_DFS_STEPS — subgraph chains are typically +#: shallow (single-digit depth), so 50k steps is an extreme outlier +#: guard. +_MAX_SUBGRAPH_STEPS: int = 50_000 + +# --------------------------------------------------------------------------- +# Vocabulary constants (Actor Configuration Standard v1.0.0) +# --------------------------------------------------------------------------- + +#: Agent types defined in §4.3 of the Actor Configuration Standard. +VALID_AGENT_TYPES: frozenset[str] = frozenset( + { + "llm", + "tool", + "composite", + "chain", + "template_instance", + } +) + +#: Default provider allowlist from §4.4.1 — used when platform_limits does not +#: supply an "allowed_providers" key. +DEFAULT_ALLOWED_PROVIDERS: frozenset[str] = frozenset({"openai", "anthropic", "google"}) + +#: Node types defined in §6.2. The spec says these MAY appear in either case; +#: we compare after lower-casing the declared value. +VALID_NODE_TYPES: frozenset[str] = frozenset( + { + "start", + "end", + "agent", + "function", + "tool", + "conditional", + "subgraph", + "message_router", + } +) + +#: Route types defined in §5.1. +VALID_ROUTE_TYPES: frozenset[str] = frozenset({"stream", "graph", "bridge"}) + +#: Stream-route operator types defined in §5.3.2. +VALID_OPERATOR_TYPES: frozenset[str] = frozenset( + { + "map", + "filter", + "transform", + "debounce", + "throttle", + "delay", + "buffer", + "scan", + "reduce", + "switch", + "conditional_route", + "catch", + "retry", + "distinct", + "take", + "skip", + "sample", + "graph_execute", + "state_update", + "state_checkpoint", + "graph_node", + } +) -- 2.52.0