diff --git a/features/steps/tdd_actor_run_response_steps.py b/features/steps/tdd_actor_run_response_steps.py new file mode 100644 index 000000000..85c840611 --- /dev/null +++ b/features/steps/tdd_actor_run_response_steps.py @@ -0,0 +1,148 @@ +"""Step definitions for TDD Bug #10861 - agents actor run returns nothing. + +These steps verify that resolve_config_files synthesises a v3 type: llm YAML +when the actor has a built-in config_blob with provider and model but no type +field. This is the regression guard for bug #10861. +""" + +from __future__ import annotations + +import yaml +from pathlib import Path +from unittest.mock import MagicMock, patch + +from behave import given, then, when # type: ignore[import-untyped] +from behave.runner import Context # type: ignore[import-untyped] + +from cleveragents.cli.commands._resolve_actor import resolve_config_files + + +@given("a built-in actor with provider and model but no type field for tdd-10861") +def step_given_builtin_actor(context: Context) -> None: + """Set up a mock built-in actor with provider/model but no type field.""" + mock_actor = MagicMock() + mock_actor.name = "anthropic/claude-sonnet-4-20250514" + mock_actor.yaml_text = "" + mock_actor.config_blob = { + "provider": "anthropic", + "model": "claude-sonnet-4-20250514", + "capabilities": {}, + "unsafe": False, + "source": "provider-registry", + } + mock_registry = MagicMock() + mock_registry.get.return_value = mock_actor + mock_container = MagicMock() + mock_container.actor_registry.return_value = mock_registry + context.mock_actor = mock_actor + context.mock_container = mock_container + + +@given("an actor with existing yaml_text for tdd-10861") +def step_given_actor_with_yaml_text(context: Context) -> None: + """Set up a mock actor that already has yaml_text.""" + mock_actor = MagicMock() + mock_actor.name = "local/my-custom-actor" + mock_actor.yaml_text = "name: local/my-custom-actor\ntype: llm\nprovider: openai\nmodel: gpt-4\n" + mock_actor.config_blob = None + mock_registry = MagicMock() + mock_registry.get.return_value = mock_actor + mock_container = MagicMock() + mock_container.actor_registry.return_value = mock_registry + context.mock_actor = mock_actor + context.mock_container = mock_container + + +@when("resolve_config_files is called with the built-in actor name for tdd-10861") +def step_when_resolve_builtin(context: Context) -> None: + """Call resolve_config_files with the built-in actor name.""" + with patch( + "cleveragents.cli.commands._resolve_actor.get_container", + return_value=context.mock_container, + ): + context.result_paths = resolve_config_files( + "anthropic/claude-sonnet-4-20250514", [] + ) + context.add_cleanup( + lambda: [p.unlink(missing_ok=True) for p in context.result_paths] + ) + + +@when("resolve_config_files is called with the actor name for tdd-10861") +def step_when_resolve_actor(context: Context) -> None: + """Call resolve_config_files with the actor name.""" + with patch( + "cleveragents.cli.commands._resolve_actor.get_container", + return_value=context.mock_container, + ): + context.result_paths = resolve_config_files("local/my-custom-actor", []) + context.add_cleanup( + lambda: [p.unlink(missing_ok=True) for p in context.result_paths] + ) + + +@then("the resulting YAML file contains type llm for tdd-10861") +def step_then_yaml_contains_type_llm(context: Context) -> None: + """Assert the synthesised YAML contains type: llm.""" + assert len(context.result_paths) == 1 + tmp_path: Path = context.result_paths[0] + assert tmp_path.exists(), f"Temp file does not exist: {tmp_path}" + content = tmp_path.read_text(encoding="utf-8") + parsed = yaml.safe_load(content) + assert isinstance(parsed, dict), f"Expected dict, got {type(parsed)}" + assert parsed.get("type") == "llm", ( + "Expected type: llm in synthesised YAML, got: " + + str(parsed.get("type")) + + "\nFull content:\n" + + content + ) + + +@then("the resulting YAML file contains the provider and model for tdd-10861") +def step_then_yaml_contains_provider_model(context: Context) -> None: + """Assert the synthesised YAML contains the correct provider and model.""" + tmp_path: Path = context.result_paths[0] + content = tmp_path.read_text(encoding="utf-8") + parsed = yaml.safe_load(content) + assert parsed.get("provider") == "anthropic", ( + "Expected provider: anthropic, got: " + str(parsed.get("provider")) + ) + assert parsed.get("model") == "claude-sonnet-4-20250514", ( + "Expected model: claude-sonnet-4-20250514, got: " + str(parsed.get("model")) + ) + + +@then("the resulting YAML file is parseable as a v3 llm actor config for tdd-10861") +def step_then_yaml_parseable_as_v3(context: Context) -> None: + """Assert the synthesised YAML is parseable by ReactiveConfigParser.""" + from cleveragents.reactive.config_parser import ReactiveConfigParser + + tmp_path: Path = context.result_paths[0] + parser = ReactiveConfigParser() + rc = parser.parse_files([tmp_path]) + + assert rc.agents, ( + "ReactiveConfigParser produced no agents from synthesised YAML. " + "This means run_single_shot() would return empty string (bug #10861)." + ) + assert rc.routes, ( + "ReactiveConfigParser produced no routes from synthesised YAML. " + "This means run_single_shot() would return empty string (bug #10861)." + ) + + +@then("the original yaml_text is used without modification for tdd-10861") +def step_then_original_yaml_used(context: Context) -> None: + """Assert that actors with existing yaml_text are not affected by the fix.""" + tmp_path: Path = context.result_paths[0] + assert tmp_path.exists(), f"Temp file does not exist: {tmp_path}" + content = tmp_path.read_text(encoding="utf-8") + assert "local/my-custom-actor" in content, ( + "Expected original yaml_text content, got: " + content + ) + assert "type: llm" in content, ( + "Expected type: llm from original yaml_text, got: " + content + ) + assert "provider: openai" in content, ( + "Expected provider: openai from original yaml_text, got: " + content + ) diff --git a/features/tdd_actor_run_response.feature b/features/tdd_actor_run_response.feature new file mode 100644 index 000000000..c42ad24e8 --- /dev/null +++ b/features/tdd_actor_run_response.feature @@ -0,0 +1,30 @@ +@tdd_issue @tdd_issue_10861 +Feature: TDD Bug #10861 - agents actor run returns nothing for built-in LLM actors + + Bug #10861 reports that running agents actor run with a built-in LLM actor + returns nothing instead of a response from the LLM. + + Root cause: built-in actors have a config_blob with provider and model + fields but no type field. When serialised to YAML and fed to + ReactiveConfigParser, the parser produces an empty ReactiveConfig with no + agents and no routes, causing run_single_shot() to return empty string. + + Fix: resolve_config_files now synthesises a minimal v3 type: llm YAML + when the actor has no yaml_text and the config_blob has provider and model + but no type field. + + Scenario: resolve_config_files synthesises v3 llm YAML for built-in actor + Given a built-in actor with provider and model but no type field for tdd-10861 + When resolve_config_files is called with the built-in actor name for tdd-10861 + Then the resulting YAML file contains type llm for tdd-10861 + And the resulting YAML file contains the provider and model for tdd-10861 + + Scenario: synthesised YAML is parseable by ReactiveConfigParser for tdd-10861 + Given a built-in actor with provider and model but no type field for tdd-10861 + When resolve_config_files is called with the built-in actor name for tdd-10861 + Then the resulting YAML file is parseable as a v3 llm actor config for tdd-10861 + + Scenario: actor with existing yaml_text is not affected by the fix for tdd-10861 + Given an actor with existing yaml_text for tdd-10861 + When resolve_config_files is called with the actor name for tdd-10861 + Then the original yaml_text is used without modification for tdd-10861 diff --git a/src/cleveragents/cli/commands/_resolve_actor.py b/src/cleveragents/cli/commands/_resolve_actor.py index a78259e07..9a7f0b343 100644 --- a/src/cleveragents/cli/commands/_resolve_actor.py +++ b/src/cleveragents/cli/commands/_resolve_actor.py @@ -19,6 +19,7 @@ from __future__ import annotations import atexit import tempfile from pathlib import Path +from typing import Any import typer import yaml @@ -52,6 +53,44 @@ def _sanitize_name(name: str) -> str: return "".join(c for c in name if c.isprintable()) + + +def _synthesize_llm_yaml(actor_name: str, config_blob: dict[str, Any]) -> str: + """Synthesize a v3 type: llm YAML from a built-in actor config blob. + + Built-in actors generated from the provider registry have a config_blob + with provider and model fields but no type field. When this raw blob is + serialised to YAML and fed to ReactiveConfigParser, the parser finds no + type, no agents/actors map, and no routes key so it produces an empty + ReactiveConfig with no agents and no routes. run_single_shot() then + returns empty string because there is nothing to execute (fix #10861). + + Args: + actor_name: The namespaced actor name. + config_blob: The actor canonical configuration blob from the registry. + + Returns: + A YAML string in v3 type: llm format that the reactive config + parser can consume to produce a working single-agent graph route. + """ + provider = str(config_blob.get("provider") or "") + model = str(config_blob.get("model") or "") + + v3_blob: dict[str, Any] = { + "name": actor_name, + "type": "llm", + "description": f"Built-in LLM actor for {provider}/{model}", + "provider": provider, + "model": model, + } + + system_prompt = config_blob.get("system_prompt") + if system_prompt: + v3_blob["system_prompt"] = system_prompt + + return yaml.safe_dump(v3_blob, default_flow_style=False, sort_keys=False) + + def resolve_config_files(name: str, config: list[Path]) -> list[Path]: """Return config file paths, resolving *name* from the actor registry when *config* is empty. @@ -102,15 +141,28 @@ def resolve_config_files(name: str, config: list[Path]) -> list[Path]: err=True, ) raise typer.Exit(code=2) - try: - yaml_text = yaml.safe_dump(config_blob, default_flow_style=False) - except yaml.YAMLError: - typer.echo( - f"Error: Actor '{safe_name}' config_blob could not be " - "serialised to YAML.", - err=True, - ) - raise typer.Exit(code=2) from None + # Fix #10861: built-in actors have a config_blob with provider + # and model but no type field. Serialising this blob as-is + # produces YAML that the reactive config parser cannot interpret + # (no agents, no routes -> empty ReactiveConfig -> empty response). + # Synthesise a v3 type: llm YAML so the parser can create a + # working agent and graph route. + if ( + config_blob.get("provider") + and config_blob.get("model") + and not config_blob.get("type") + ): + yaml_text = _synthesize_llm_yaml(actor.name, config_blob) + else: + try: + yaml_text = yaml.safe_dump(config_blob, default_flow_style=False) + except yaml.YAMLError: + typer.echo( + f"Error: Actor '{safe_name}' config_blob could not be " + "serialised to YAML.", + err=True, + ) + raise typer.Exit(code=2) from None with tempfile.NamedTemporaryFile( delete=False, suffix=".yaml", mode="w", encoding="utf-8"