feat(validate_dict): expose public validate_dict(config_dict, platform_limits) API #18
@@ -184,3 +184,4 @@ agents-test
|
||||
# Generated test reports (CI artifacts) — build artifacts, not to be committed
|
||||
test_reports/
|
||||
cleveractors-core-new2/
|
||||
pretty.output
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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__}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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)}."
|
||||
)
|
||||
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Structural limit enforcement — graph depth, total nodes, subgraph depth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterator
|
||||
|
||||
from cleveractors.core.exceptions import ConfigurationError
|
||||
from cleveractors.validation._vocabulary import _MAX_DFS_STEPS, _MAX_SUBGRAPH_STEPS
|
||||
|
||||
|
||||
def _validate_limit_type(label: str, value: Any) -> None:
|
||||
"""Validate that a platform limit value is None or a non-negative int."""
|
||||
if value is not None:
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ConfigurationError(
|
||||
f"Invalid platform_limits: '{label}' must be an integer, "
|
||||
f"got {type(value).__name__}."
|
||||
)
|
||||
if value < 0:
|
||||
raise ConfigurationError(
|
||||
f"Invalid platform_limits: '{label}' must be >= 0, got {value}."
|
||||
)
|
||||
|
||||
|
||||
def validate_structural_limits(
|
||||
routes: dict[str, Any],
|
||||
platform_limits: dict[str, Any],
|
||||
) -> None:
|
||||
"""Enforce max_graph_depth, max_total_nodes, and max_subgraph_depth."""
|
||||
max_graph_depth = platform_limits.get("max_graph_depth")
|
||||
max_total_nodes = platform_limits.get("max_total_nodes")
|
||||
max_subgraph_depth = platform_limits.get("max_subgraph_depth")
|
||||
|
||||
_validate_limit_type("max_graph_depth", max_graph_depth)
|
||||
_validate_limit_type("max_total_nodes", max_total_nodes)
|
||||
_validate_limit_type("max_subgraph_depth", max_subgraph_depth)
|
||||
|
||||
# Total node count across all graph routes.
|
||||
if max_total_nodes is not None:
|
||||
total = _count_total_nodes(routes)
|
||||
if total > max_total_nodes:
|
||||
raise ConfigurationError(
|
||||
f"Actor configuration exceeds the platform's max_total_nodes limit of "
|
||||
f"{max_total_nodes}: found {total} nodes across all graph routes."
|
||||
)
|
||||
|
||||
if max_graph_depth is not None or max_subgraph_depth is not None:
|
||||
depth_cache: dict[str, int] = {}
|
||||
for route_name, route_def in routes.items():
|
||||
if route_def.get("type") != "graph":
|
||||
continue
|
||||
|
||||
if max_graph_depth is not None:
|
||||
depth = _compute_graph_depth(route_name, route_def)
|
||||
if depth > max_graph_depth:
|
||||
raise ConfigurationError(
|
||||
f"Route '{route_name}' exceeds the platform's max_graph_depth "
|
||||
f"limit of {max_graph_depth}: longest path has depth {depth}."
|
||||
)
|
||||
|
||||
if max_subgraph_depth is not None:
|
||||
sub_depth = _compute_subgraph_depth(route_name, routes, depth_cache)
|
||||
if sub_depth > max_subgraph_depth:
|
||||
raise ConfigurationError(
|
||||
f"Route '{route_name}' exceeds the platform's "
|
||||
f"max_subgraph_depth limit of {max_subgraph_depth}: "
|
||||
f"nested subgraph depth is {sub_depth}."
|
||||
)
|
||||
|
||||
|
||||
def _count_total_nodes(routes: dict[str, Any]) -> int:
|
||||
"""Count all declared nodes across all graph routes.
|
||||
|
||||
The *routes* dict has already been validated by
|
||||
``validate_routes``, which guarantees that every graph route
|
||||
has a ``nodes`` key whose value is a ``dict``.
|
||||
"""
|
||||
total = 0
|
||||
for route_def in routes.values():
|
||||
if route_def.get("type") != "graph":
|
||||
continue
|
||||
nodes = route_def.get("nodes", {})
|
||||
if not isinstance(nodes, dict): # pragma: no branch
|
||||
raise ConfigurationError(
|
||||
"Invariant violation: graph route 'nodes' must be a dict "
|
||||
"(guaranteed by validate_routes)."
|
||||
)
|
||||
total += len(nodes)
|
||||
return total
|
||||
|
||||
|
||||
def _compute_graph_depth(route_name: str, route_def: dict[str, Any]) -> int:
|
||||
"""Return the longest-path depth (edge count) in a graph route.
|
||||
|
||||
Uses iterative DFS with an explicit stack to avoid recursion limits.
|
||||
Tracks visited nodes with a mutable ``set`` and explicit
|
||||
add-on-push/pop-on-backtrack for O(1)-amortized per-step overhead.
|
||||
The depth is the maximum number of edges on any simple path from the
|
||||
entry node.
|
||||
|
||||
The invariants below are guaranteed by ``_validate_graph_route``
|
||||
(in the ``_routes`` module), which always runs first.
|
||||
"""
|
||||
edges: list[dict[str, Any]] = route_def["edges"] # guaranteed list
|
||||
entry_point: str = route_def["entry_point"] # guaranteed non-empty str
|
||||
|
||||
# Build adjacency list from the edges.
|
||||
adjacency: dict[str, set[str]] = {}
|
||||
for edge in edges:
|
||||
src = edge.get("source", "")
|
||||
tgt = edge.get("target", "")
|
||||
adjacency.setdefault(src, set()).add(tgt)
|
||||
|
||||
# Iterative DFS with mutable-visited-set + explicit backtracking.
|
||||
# Each stack entry is (node, iterator_over_neighbors, current_depth).
|
||||
# When the iterator is exhausted we backtrack by removing *node* from
|
||||
# the visited set.
|
||||
max_depth = 0
|
||||
|
||||
# Prime the stack with the entry point.
|
||||
neighbors = iter(adjacency.get(entry_point, []))
|
||||
stack: list[tuple[str, Iterator[str], int]] = [(entry_point, neighbors, 0)]
|
||||
visited: set[str] = {entry_point}
|
||||
steps = 0
|
||||
|
||||
while stack:
|
||||
steps += 1
|
||||
if steps >= _MAX_DFS_STEPS:
|
||||
raise ConfigurationError(
|
||||
f"Route '{route_name}': graph is too complex to validate "
|
||||
f"depth (exceeded {_MAX_DFS_STEPS} DFS steps)."
|
||||
)
|
||||
|
||||
node, neighbor_iter, current_depth = stack[-1]
|
||||
|
||||
try:
|
||||
neighbor = next(neighbor_iter)
|
||||
except StopIteration:
|
||||
# All neighbors exhausted — backtrack.
|
||||
stack.pop()
|
||||
visited.discard(node)
|
||||
continue
|
||||
|
||||
if neighbor in visited:
|
||||
continue # cycle detected — stop this path
|
||||
|
||||
max_depth = max(max_depth, current_depth + 1)
|
||||
visited.add(neighbor)
|
||||
new_iter = iter(adjacency.get(neighbor, []))
|
||||
stack.append((neighbor, new_iter, current_depth + 1))
|
||||
|
||||
return max_depth
|
||||
|
||||
|
||||
def _subgraph_children(
|
||||
route_def: dict[str, Any],
|
||||
) -> Iterator[str]:
|
||||
"""Yield de-duplicated subgraph-reference route names from a graph route's nodes."""
|
||||
nodes = route_def.get("nodes", {})
|
||||
seen: set[str] = set()
|
||||
# The isinstance guards below are defensive invariant checks:
|
||||
# _validate_graph_route guarantees nodes is a dict and each node_def
|
||||
# is a dict, so these branches are dead from a coverage perspective.
|
||||
if isinstance(nodes, dict): # pragma: no branch
|
||||
for node_def in nodes.values():
|
||||
if (
|
||||
isinstance(node_def, dict) # pragma: no branch
|
||||
and node_def.get("type", "").lower() == "subgraph"
|
||||
):
|
||||
sub_ref = node_def.get("subgraph", "")
|
||||
if isinstance(sub_ref, str) and sub_ref and sub_ref not in seen:
|
||||
seen.add(sub_ref)
|
||||
yield sub_ref
|
||||
|
||||
|
||||
def _compute_subgraph_depth(
|
||||
route_name: str,
|
||||
routes: dict[str, Any],
|
||||
depth_cache: dict[str, int],
|
||||
) -> int:
|
||||
"""Return the subgraph nesting depth reachable from *route_name*.
|
||||
|
||||
Uses iterative DFS over subgraph references with a single shared
|
||||
mutable ``visiting_set`` and explicit add-on-push/discard-on-pop
|
||||
backtracking — O(1) amortized per step, avoiding the O(D²) cost
|
||||
of frozenset unions.
|
||||
|
||||
*depth_cache* is a mutable dict shared across all calls within
|
||||
a single ``validate_structural_limits`` invocation. Already-computed
|
||||
depths are reused, avoiding redundant DFS traversals when multiple
|
||||
graph routes share the same subgraph tree.
|
||||
"""
|
||||
if route_name in depth_cache:
|
||||
return depth_cache[route_name]
|
||||
# The initial route is guaranteed to be a dict with type='graph'
|
||||
# by _validate_structural_limits, which only calls this for graph
|
||||
# routes.
|
||||
# Subgraph reference existence and target-type checks are handled
|
||||
# unconditionally by _validate_graph_route, so we can assume all
|
||||
# children referenced by subgraph nodes are valid graph routes.
|
||||
route_def = routes[route_name]
|
||||
|
||||
max_depth = 0
|
||||
visiting_set: set[str] = {route_name}
|
||||
children = _subgraph_children(route_def)
|
||||
stack: list[tuple[str, Iterator[str], int]] = [(route_name, children, 0)]
|
||||
steps = 0
|
||||
|
||||
while stack:
|
||||
steps += 1
|
||||
if steps >= _MAX_SUBGRAPH_STEPS:
|
||||
raise ConfigurationError(
|
||||
f"Subgraph depth computation exceeded {_MAX_SUBGRAPH_STEPS} "
|
||||
"steps — the configuration may be too complex to validate."
|
||||
)
|
||||
|
||||
current_route, child_iter, current_depth = stack[-1]
|
||||
|
||||
try:
|
||||
child = next(child_iter)
|
||||
except StopIteration:
|
||||
stack.pop()
|
||||
visiting_set.discard(current_route)
|
||||
continue
|
||||
|
||||
if child in visiting_set:
|
||||
continue # cycle guard
|
||||
|
||||
# Route existence and type are guaranteed by _validate_graph_route.
|
||||
child_def = routes[child]
|
||||
|
||||
max_depth = max(max_depth, current_depth + 1)
|
||||
visiting_set.add(child)
|
||||
stack.append((child, _subgraph_children(child_def), current_depth + 1))
|
||||
|
||||
depth_cache[route_name] = max_depth
|
||||
return max_depth
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user