"""Step definitions for actor YAML schema validation. Tests for features/actor_schema.feature — validates the ActorConfigSchema Pydantic model, YAML loading, graph topology validation, tool definitions, and error messages. """ from __future__ import annotations import tempfile from pathlib import Path from behave import given, then, when # type: ignore[import-untyped] from behave.runner import Context # type: ignore[import-untyped] from pydantic import ValidationError from cleveragents.actor.schema import ActorConfigSchema, NodeType # ──────────────────────────────────────────────────────────── # Test YAML Templates # ──────────────────────────────────────────────────────────── _MINIMAL_LLM_YAML = """\ name: assistants/simple type: llm description: A simple LLM actor model: gpt-4 """ _LLM_WITH_PROMPT_YAML = """\ name: assistants/expert type: llm description: An expert assistant model: gpt-4 system_prompt: "You are an expert Python developer" """ _LLM_WITH_TOOLS_YAML = """\ name: assistants/helper type: llm description: Helper with tools model: gpt-4 tools: - files/read_file - files/write_file """ _LLM_WITH_MEMORY_YAML = """\ name: assistants/chatbot type: llm description: Chatbot with memory model: gpt-4 memory: enabled: true max_messages: 50 max_tokens: 4000 """ _LLM_WITH_CONTEXT_YAML = """\ name: assistants/analyzer type: llm description: Code analyzer model: gpt-4 context: include_files: - README.md - pyproject.toml include_dirs: - src/ exclude_patterns: - "**/__pycache__/**" """ _TOOL_MINIMAL_YAML = """\ name: utilities/helpers type: tool description: Utility tools tools: - files/read_file """ _TOOL_INLINE_YAML = """\ name: utilities/custom type: tool description: Custom tools tools: - name: utils/count_lines description: Count lines in a file parameters: - name: file_path type: str description: Path to file required: true code: | def count_lines(file_path: str) -> int: with open(file_path, 'r') as f: return len(f.readlines()) """ _GRAPH_MINIMAL_YAML = """\ name: workflows/simple type: graph description: Simple workflow model: gpt-4 route: nodes: - id: start type: agent name: Starter description: Start node config: prompt: "Begin processing" edges: [] entry_node: start exit_nodes: - start """ _GRAPH_LINEAR_YAML = """\ name: workflows/linear type: graph description: Linear workflow model: gpt-4 route: nodes: - id: extract type: tool name: Extractor description: Extract data config: tool_name: data/extract - id: process type: agent name: Processor description: Process data config: prompt: "Process the data" - id: save type: tool name: Saver description: Save results config: tool_name: data/save edges: - from_node: extract to_node: process - from_node: process to_node: save entry_node: extract exit_nodes: - save """ _GRAPH_CONDITIONAL_YAML = """\ name: workflows/conditional type: graph description: Workflow with conditionals model: gpt-4 route: nodes: - id: start type: agent name: Starter description: Start config: prompt: "Start" - id: checker type: conditional name: Checker description: Check condition config: conditions: - check: "state.get('ok') == True" route_to: success - check: "state.get('ok') == False" route_to: failure - id: success type: agent name: Success description: Success path config: prompt: "Success" - id: failure type: agent name: Failure description: Failure path config: prompt: "Failure" edges: - from_node: start to_node: checker entry_node: start exit_nodes: - success - failure """ _GRAPH_SUBGRAPH_YAML = """\ name: workflows/composed type: graph description: Workflow with subgraph model: gpt-4 route: nodes: - id: main type: agent name: Main description: Main processor config: prompt: "Process" - id: review type: subgraph name: Reviewer description: Review subgraph config: actor_path: actors/reviewer.yaml edges: - from_node: main to_node: review entry_node: main exit_nodes: - review """ # ──────────────────────────────────────────────────────────── # Given Steps # ──────────────────────────────────────────────────────────── @given("an actor YAML string with minimal LLM configuration") def step_given_minimal_llm(context: Context) -> None: """Provide minimal LLM actor YAML.""" context.actor_yaml_string = _MINIMAL_LLM_YAML @given("an actor YAML string with LLM and system prompt") def step_given_llm_with_prompt(context: Context) -> None: """Provide LLM actor with system prompt.""" context.actor_yaml_string = _LLM_WITH_PROMPT_YAML @given("an actor YAML string with LLM and tools") def step_given_llm_with_tools(context: Context) -> None: """Provide LLM actor with tools.""" context.actor_yaml_string = _LLM_WITH_TOOLS_YAML @given("an actor YAML string with memory configuration") def step_given_llm_with_memory(context: Context) -> None: """Provide LLM actor with memory config.""" context.actor_yaml_string = _LLM_WITH_MEMORY_YAML @given("an actor YAML string with context configuration") def step_given_llm_with_context(context: Context) -> None: """Provide LLM actor with context config.""" context.actor_yaml_string = _LLM_WITH_CONTEXT_YAML @given("an actor YAML string with TOOL type and tools") def step_given_tool_minimal(context: Context) -> None: """Provide minimal TOOL actor.""" context.actor_yaml_string = _TOOL_MINIMAL_YAML @given("an actor YAML string with inline tool definition") def step_given_tool_inline(context: Context) -> None: """Provide TOOL actor with inline tool.""" context.actor_yaml_string = _TOOL_INLINE_YAML @given("an actor YAML string with minimal GRAPH configuration") def step_given_graph_minimal(context: Context) -> None: """Provide minimal GRAPH actor.""" context.actor_yaml_string = _GRAPH_MINIMAL_YAML @given("an actor YAML string with linear graph topology") def step_given_graph_linear(context: Context) -> None: """Provide linear GRAPH actor.""" context.actor_yaml_string = _GRAPH_LINEAR_YAML @given("an actor YAML string with conditional routing") def step_given_graph_conditional(context: Context) -> None: """Provide GRAPH with conditional node.""" context.actor_yaml_string = _GRAPH_CONDITIONAL_YAML @given("an actor YAML string with subgraph node") def step_given_graph_subgraph(context: Context) -> None: """Provide GRAPH with subgraph node.""" context.actor_yaml_string = _GRAPH_SUBGRAPH_YAML @given('an actor YAML string with name "{name}"') def step_given_actor_with_name(context: Context, name: str) -> None: """Provide actor YAML with specific name.""" context.actor_yaml_string = f"""\ name: {name} type: llm description: Test actor model: gpt-4 """ @given("an actor YAML string with LLM type but no model") def step_given_llm_no_model(context: Context) -> None: """Provide LLM actor without model field.""" context.actor_yaml_string = """\ name: assistants/broken type: llm description: Missing model """ @given("an actor YAML string with TOOL type but no tools") def step_given_tool_no_tools(context: Context) -> None: """Provide TOOL actor without tools field.""" context.actor_yaml_string = """\ name: utilities/broken type: tool description: Missing tools """ @given("an actor YAML string with TOOL type and empty tools") def step_given_tool_empty_tools(context: Context) -> None: """Provide TOOL actor with empty tools list.""" context.actor_yaml_string = """\ name: utilities/broken type: tool description: Empty tools tools: [] """ @given("an actor YAML string with GRAPH type but no model") def step_given_graph_no_model(context: Context) -> None: """Provide GRAPH actor without model field.""" context.actor_yaml_string = """\ name: workflows/broken type: graph description: Missing model route: nodes: [] edges: [] entry_node: start exit_nodes: [] """ @given("an actor YAML string with GRAPH type but no route") def step_given_graph_no_route(context: Context) -> None: """Provide GRAPH actor without route field.""" context.actor_yaml_string = """\ name: workflows/broken type: graph description: Missing route model: gpt-4 """ @given("an actor YAML string with duplicate node IDs") def step_given_duplicate_nodes(context: Context) -> None: """Provide GRAPH with duplicate node IDs.""" context.actor_yaml_string = """\ name: workflows/duplicate type: graph description: Duplicate nodes model: gpt-4 route: nodes: - id: node1 type: agent name: First description: First node config: prompt: "First" - id: node1 type: agent name: Second description: Duplicate ID config: prompt: "Second" edges: [] entry_node: node1 exit_nodes: - node1 """ @given("an actor YAML string with non-existent entry node") def step_given_invalid_entry_node(context: Context) -> None: """Provide GRAPH with invalid entry node.""" context.actor_yaml_string = """\ name: workflows/bad_entry type: graph description: Bad entry node model: gpt-4 route: nodes: - id: actual_node type: agent name: Node description: The only node config: prompt: "Process" edges: [] entry_node: missing_node exit_nodes: - actual_node """ @given("an actor YAML string with non-existent exit node") def step_given_invalid_exit_node(context: Context) -> None: """Provide GRAPH with invalid exit node.""" context.actor_yaml_string = """\ name: workflows/bad_exit type: graph description: Bad exit node model: gpt-4 route: nodes: - id: actual_node type: agent name: Node description: The only node config: prompt: "Process" edges: [] entry_node: actual_node exit_nodes: - missing_node """ @given("an actor YAML string with invalid edge from_node") def step_given_invalid_edge_from(context: Context) -> None: """Provide GRAPH with invalid from_node in edge.""" context.actor_yaml_string = """\ name: workflows/bad_edge type: graph description: Bad edge from_node model: gpt-4 route: nodes: - id: node_a type: agent name: Node A description: First node config: prompt: "A" - id: node_b type: agent name: Node B description: Second node config: prompt: "B" edges: - from_node: missing_node to_node: node_b entry_node: node_a exit_nodes: - node_b """ @given("an actor YAML string with invalid edge to_node") def step_given_invalid_edge_to(context: Context) -> None: """Provide GRAPH with invalid to_node in edge.""" context.actor_yaml_string = """\ name: workflows/bad_edge type: graph description: Bad edge to_node model: gpt-4 route: nodes: - id: node_a type: agent name: Node A description: First node config: prompt: "A" - id: node_b type: agent name: Node B description: Second node config: prompt: "B" edges: - from_node: node_a to_node: missing_node entry_node: node_a exit_nodes: - node_b """ @given("an actor YAML string with cyclic graph") def step_given_cyclic_graph(context: Context) -> None: """Provide GRAPH with cycle.""" context.actor_yaml_string = """\ name: workflows/cyclic type: graph description: Graph with cycle model: gpt-4 route: nodes: - id: node_a type: agent name: Node A description: First config: prompt: "A" - id: node_b type: agent name: Node B description: Second config: prompt: "B" - id: node_c type: agent name: Node C description: Third config: prompt: "C" edges: - from_node: node_a to_node: node_b - from_node: node_b to_node: node_c - from_node: node_c to_node: node_a entry_node: node_a exit_nodes: - node_c """ @given('an actor YAML string with inline tool name "{name}"') def step_given_inline_tool_name(context: Context, name: str) -> None: """Provide actor with inline tool having specific name.""" context.actor_yaml_string = f"""\ name: utilities/test type: tool description: Test tool tools: - name: {name} description: Test tool parameters: [] code: "def test(): pass" """ @given("an actor YAML string with invalid tool parameter name") def step_given_invalid_param_name(context: Context) -> None: """Provide inline tool with invalid parameter name.""" context.actor_yaml_string = """\ name: utilities/bad_param type: tool description: Bad parameter tools: - name: utils/bad_tool description: Tool with bad param parameters: - name: invalid-name! type: str required: true code: "def bad_tool(): pass" """ @given("an actor YAML string with node ID containing spaces") def step_given_invalid_node_id(context: Context) -> None: """Provide GRAPH with invalid node ID.""" context.actor_yaml_string = """\ name: workflows/bad_id type: graph description: Bad node ID model: gpt-4 route: nodes: - id: "node with spaces" type: agent name: Bad Node description: Node with invalid ID config: prompt: "Process" edges: [] entry_node: "node with spaces" exit_nodes: - "node with spaces" """ @given('an actor YAML string with node ID "{node_id}"') def step_given_specific_node_id(context: Context, node_id: str) -> None: """Provide GRAPH with specific node ID.""" context.actor_yaml_string = f"""\ name: workflows/test type: graph description: Test workflow model: gpt-4 route: nodes: - id: {node_id} type: agent name: Test Node description: Test node config: prompt: "Test" edges: [] entry_node: {node_id} exit_nodes: - {node_id} """ @given('an actor YAML string with context_view "{view}"') def step_given_context_view(context: Context, view: str) -> None: """Provide actor with specific context view.""" context.actor_yaml_string = f"""\ name: assistants/test type: llm description: Test actor model: gpt-4 context_view: {view} """ @given("an actor YAML string with env_vars") def step_given_env_vars(context: Context) -> None: """Provide actor with environment variables.""" context.actor_yaml_string = """\ name: assistants/test type: llm description: Test actor model: gpt-4 env_vars: LOG_LEVEL: info WORK_DIR: /tmp/work """ @given('the actor YAML file "{file_path}"') def step_given_actor_yaml_file(context: Context, file_path: str) -> None: """Store actor YAML file path for loading.""" context.actor_yaml_file = file_path @given("a valid actor configuration object") def step_given_valid_actor_object(context: Context) -> None: """Create a valid actor configuration object.""" context.actor_config = ActorConfigSchema( name="test/actor", type="llm", description="Test actor", model="gpt-4", ) @given("a non-existent actor YAML file path") def step_given_nonexistent_file(context: Context) -> None: """Provide a non-existent file path.""" context.actor_yaml_file = "/nonexistent/path/actor.yaml" @given("an actor YAML string with edges having different priorities") def step_given_edge_priorities(context: Context) -> None: """Provide GRAPH with edge priorities.""" context.actor_yaml_string = """\ name: workflows/priorities type: graph description: Workflow with priorities model: gpt-4 route: nodes: - id: start type: agent name: Start description: Start node config: prompt: "Start" - id: end type: agent name: End description: End node config: prompt: "End" edges: - from_node: start to_node: end priority: 10 entry_node: start exit_nodes: - end """ @given("an actor YAML string with memory enabled false") def step_given_memory_disabled(context: Context) -> None: """Provide actor with memory disabled.""" context.actor_yaml_string = """\ name: assistants/test type: llm description: Test actor model: gpt-4 memory: enabled: false """ @given("an actor YAML string with max_messages {count:d}") def step_given_max_messages(context: Context, count: int) -> None: """Provide actor with max_messages limit.""" context.actor_yaml_string = f"""\ name: assistants/test type: llm description: Test actor model: gpt-4 memory: max_messages: {count} """ @given("an actor YAML string with max_tokens {count:d}") def step_given_max_tokens(context: Context, count: int) -> None: """Provide actor with max_tokens limit.""" context.actor_yaml_string = f"""\ name: assistants/test type: llm description: Test actor model: gpt-4 memory: max_tokens: {count} """ @given("an actor YAML string with summarize_old true") def step_given_summarize_old(context: Context) -> None: """Provide actor with summarize_old enabled.""" context.actor_yaml_string = """\ name: assistants/test type: llm description: Test actor model: gpt-4 memory: summarize_old: true """ # ──────────────────────────────────────────────────────────── # When Steps # ──────────────────────────────────────────────────────────── @when("I validate the actor schema") def step_when_validate_schema(context: Context) -> None: """Validate actor YAML string.""" import yaml try: data = yaml.safe_load(context.actor_yaml_string) context.actor_config = ActorConfigSchema.model_validate(data) context.validation_error = None context.error = None # For compatibility with service_steps except (ValidationError, ValueError) as e: context.actor_config = None context.validation_error = e context.error = e # For compatibility with service_steps @when("I validate the actor schema from file") def step_when_validate_from_file(context: Context) -> None: """Validate actor YAML from file.""" try: context.actor_config = ActorConfigSchema.from_yaml_file(context.actor_yaml_file) context.validation_error = None context.error = None # For compatibility with service_steps except (ValidationError, ValueError, FileNotFoundError) as e: context.actor_config = None context.validation_error = e context.error = e # For compatibility with service_steps @when("I save the actor to YAML file") def step_when_save_to_file(context: Context) -> None: """Save actor configuration to temporary YAML file.""" with tempfile.NamedTemporaryFile( mode="w", suffix=".yaml", delete=False ) as temp_file: context.temp_file_name = temp_file.name context.actor_config.to_yaml_file(context.temp_file_name) @when("I reload the actor from YAML file") def step_when_reload_from_file(context: Context) -> None: """Reload actor from saved YAML file.""" context.reloaded_actor = ActorConfigSchema.from_yaml_file(context.temp_file_name) Path(context.temp_file_name).unlink() # Clean up @when("I attempt to load the actor from file") def step_when_attempt_load(context: Context) -> None: """Attempt to load actor from file (may fail).""" try: context.actor_config = ActorConfigSchema.from_yaml_file(context.actor_yaml_file) context.validation_error = None context.error = None # For compatibility with service_steps except FileNotFoundError as e: context.actor_config = None context.validation_error = e context.error = e # For compatibility with service_steps # ──────────────────────────────────────────────────────────── # Then Steps # ──────────────────────────────────────────────────────────── @then("the actor schema validation should succeed") def step_then_validation_succeeds(context: Context) -> None: """Assert validation succeeded.""" assert context.validation_error is None, ( f"Expected validation to succeed, but got error: {context.validation_error}" ) assert context.actor_config is not None @then("the actor schema validation should fail") def step_then_validation_fails(context: Context) -> None: """Assert validation failed.""" assert context.validation_error is not None, ( "Expected validation to fail, but it succeeded" ) assert context.actor_config is None @then('the actor config name should be "{expected_name}"') def step_then_name_matches(context: Context, expected_name: str) -> None: """Assert actor name matches.""" assert context.actor_config is not None assert context.actor_config.name == expected_name @then('the actor config type should be "{expected_type}"') def step_then_type_matches(context: Context, expected_type: str) -> None: """Assert actor type matches.""" assert context.actor_config is not None assert context.actor_config.type.value == expected_type @then('the actor config model should be "{expected_model}"') def step_then_model_matches(context: Context, expected_model: str) -> None: """Assert actor model matches.""" assert context.actor_config is not None assert context.actor_config.model == expected_model @then('the actor config system_prompt should contain "{text}"') def step_then_prompt_contains(context: Context, text: str) -> None: """Assert system prompt contains text.""" assert context.actor_config is not None assert context.actor_config.system_prompt is not None assert text in context.actor_config.system_prompt @then("the actor config should have {count:d} tools") def step_then_tool_count(context: Context, count: int) -> None: """Assert tool count matches.""" assert context.actor_config is not None assert len(context.actor_config.tools) == count @then("the actor config should have at least {count:d} tool") def step_then_at_least_tools(context: Context, count: int) -> None: """Assert at least N tools.""" assert context.actor_config is not None assert len(context.actor_config.tools) >= count @then("the actor config should have at least {count:d} tools") def step_then_at_least_tools_plural(context: Context, count: int) -> None: """Assert at least N tools (plural).""" assert context.actor_config is not None assert len(context.actor_config.tools) >= count @then("the actor config should have {count:d} inline tool") def step_then_inline_tool_count(context: Context, count: int) -> None: """Assert inline tool count.""" assert context.actor_config is not None inline_count = sum( 1 for tool in context.actor_config.tools if not isinstance(tool, str) ) assert inline_count == count @then("the actor memory enabled should be {expected:w}") def step_then_memory_enabled(context: Context, expected: str) -> None: """Assert memory enabled state.""" assert context.actor_config is not None expected_bool = expected.lower() == "true" assert context.actor_config.memory.enabled == expected_bool @then("the actor memory max_messages should be {expected:d}") def step_then_memory_max_messages(context: Context, expected: int) -> None: """Assert memory max_messages value.""" assert context.actor_config is not None assert context.actor_config.memory.max_messages == expected @then("the actor memory max_tokens should be {expected:d}") def step_then_memory_max_tokens(context: Context, expected: int) -> None: """Assert memory max_tokens value.""" assert context.actor_config is not None assert context.actor_config.memory.max_tokens == expected @then("the actor memory summarize_old should be {expected:w}") def step_then_memory_summarize(context: Context, expected: str) -> None: """Assert memory summarize_old state.""" assert context.actor_config is not None expected_bool = expected.lower() == "true" assert context.actor_config.memory.summarize_old == expected_bool @then("the actor context should include {count:d} files") def step_then_context_files(context: Context, count: int) -> None: """Assert context includes N files.""" assert context.actor_config is not None assert len(context.actor_config.context.include_files) == count @then("the actor context should include {count:d} directory") def step_then_context_dirs(context: Context, count: int) -> None: """Assert context includes N directories.""" assert context.actor_config is not None assert len(context.actor_config.context.include_dirs) == count @then('the actor route should have entry_node "{node_id}"') def step_then_entry_node(context: Context, node_id: str) -> None: """Assert route entry node matches.""" assert context.actor_config is not None assert context.actor_config.route is not None assert context.actor_config.route.entry_node == node_id @then("the actor route should have {count:d} nodes") def step_then_node_count(context: Context, count: int) -> None: """Assert route node count.""" assert context.actor_config is not None assert context.actor_config.route is not None assert len(context.actor_config.route.nodes) == count @then("the actor route should have {count:d} edges") def step_then_edge_count(context: Context, count: int) -> None: """Assert route edge count.""" assert context.actor_config is not None assert context.actor_config.route is not None assert len(context.actor_config.route.edges) == count @then("the actor route should have {count:d} conditional node") def step_then_conditional_count(context: Context, count: int) -> None: """Assert conditional node count.""" assert context.actor_config is not None assert context.actor_config.route is not None conditional_count = sum( 1 for node in context.actor_config.route.nodes if node.type == NodeType.CONDITIONAL ) assert conditional_count == count @then("the actor route should have {count:d} subgraph node") def step_then_subgraph_count(context: Context, count: int) -> None: """Assert subgraph node count.""" assert context.actor_config is not None assert context.actor_config.route is not None subgraph_count = sum( 1 for node in context.actor_config.route.nodes if node.type == NodeType.SUBGRAPH ) assert subgraph_count == count @then('the validation error should contain "{text}"') def step_then_error_contains(context: Context, text: str) -> None: """Assert error message contains text.""" assert context.validation_error is not None error_str = str(context.validation_error) assert text in error_str, f"Expected '{text}' in error: {error_str}" @then('the actor context_view should be "{expected_view}"') def step_then_context_view_matches(context: Context, expected_view: str) -> None: """Assert context view matches.""" assert context.actor_config is not None assert context.actor_config.context_view is not None assert context.actor_config.context_view.value == expected_view @then("the actor should have {count:d} env_vars") def step_then_env_var_count(context: Context, count: int) -> None: """Assert env_vars count.""" assert context.actor_config is not None assert len(context.actor_config.env_vars) == count @then("the reloaded actor should match the original") def step_then_reloaded_matches(context: Context) -> None: """Assert reloaded actor matches original.""" assert context.actor_config.name == context.reloaded_actor.name assert context.actor_config.type == context.reloaded_actor.type assert context.actor_config.model == context.reloaded_actor.model @then("a FileNotFoundError should be raised") def step_then_file_not_found(context: Context) -> None: """Assert FileNotFoundError was raised.""" assert isinstance(context.validation_error, FileNotFoundError) @then("the highest priority edge should be {priority:d}") def step_then_highest_priority(context: Context, priority: int) -> None: """Assert highest edge priority.""" assert context.actor_config is not None assert context.actor_config.route is not None max_priority = max(edge.priority for edge in context.actor_config.route.edges) assert max_priority == priority