feat(validate_dict): expose public validate_dict(config_dict, platform_limits) API #18

Merged
hurui200320 merged 1 commits from feature/validate-dict-api into master 2026-06-05 10:19:26 +00:00
23 changed files with 4035 additions and 136 deletions
+1
View File
@@ -184,3 +184,4 @@ agents-test
# Generated test reports (CI artifacts) — build artifacts, not to be committed
test_reports/
cleveractors-core-new2/
pretty.output
+1
View File
@@ -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.
+10 -8
View File
@@ -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
@@ -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
@@ -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}"
)
@@ -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
+165
View File
@@ -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
@@ -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
@@ -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
+385
View File
@@ -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}"
)
@@ -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
@@ -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
+878
View File
@@ -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
+11 -16
View File
@@ -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()
+70
View File
@@ -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__}"
)
+188
View File
@@ -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
+8 -7
View File
@@ -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",
]
+49 -105
View File
@@ -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
+89
View File
@@ -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."
)
+116
View File
@@ -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)}."
)
+239
View File
@@ -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() 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
+247
View File
@@ -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)}."
)
@@ -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",
}
)