fix(actor): Get responses from actor-run
CI / benchmark-publish (pull_request) Has been skipped
CI / push-validation (pull_request) Successful in 37s
CI / helm (pull_request) Successful in 42s
CI / build (pull_request) Successful in 1m8s
CI / quality (pull_request) Successful in 1m32s
CI / lint (pull_request) Failing after 1m37s
CI / typecheck (pull_request) Successful in 1m52s
CI / security (pull_request) Successful in 2m5s
CI / coverage (pull_request) Has been skipped
CI / integration_tests (pull_request) Successful in 4m23s
CI / e2e_tests (pull_request) Successful in 4m45s
CI / unit_tests (pull_request) Successful in 8m16s
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 3s

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 produces
an empty ReactiveConfig with no agents and no routes, causing
run_single_shot() to return empty string (bug #10861).

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. This allows the reactive config parser to
create a working agent and graph route, enabling the LLM to be invoked
and its response returned to the caller.

ISSUES CLOSED: #10861
This commit is contained in:
2026-04-28 10:42:53 +00:00
parent ecf9710369
commit 38fa155e66
3 changed files with 239 additions and 9 deletions
@@ -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
)
+30
View File
@@ -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
@@ -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"