Files
cleveragents-core/features/steps/actor_loading_steps.py
T
hurui200320 8953449dc2
CI / lint (pull_request) Successful in 30s
CI / typecheck (pull_request) Successful in 1m15s
CI / security (pull_request) Successful in 1m18s
CI / quality (pull_request) Successful in 30s
CI / build (pull_request) Successful in 37s
CI / helm (pull_request) Successful in 29s
CI / push-validation (pull_request) Successful in 26s
CI / e2e_tests (pull_request) Successful in 3m58s
CI / integration_tests (pull_request) Successful in 6m49s
CI / unit_tests (pull_request) Successful in 8m39s
CI / docker (pull_request) Successful in 1m23s
CI / coverage (pull_request) Successful in 13m32s
CI / benchmark-regression (push) Failing after 0s
CI / benchmark-publish (push) Failing after 0s
CI / status-check (pull_request) Successful in 1s
CI / lint (push) Successful in 25s
CI / typecheck (push) Successful in 1m1s
CI / quality (push) Successful in 55s
CI / security (push) Successful in 1m9s
CI / build (push) Successful in 24s
CI / helm (push) Successful in 30s
CI / push-validation (push) Successful in 20s
CI / e2e_tests (push) Successful in 5m13s
CI / integration_tests (push) Successful in 7m17s
CI / unit_tests (push) Successful in 8m50s
CI / docker (push) Successful in 1m30s
CI / coverage (push) Successful in 11m57s
CI / status-check (push) Successful in 2s
fix(actor): validate v3 YAML via ActorConfigSchema in agents actor add CLI
- schema.py: provider field changed to Optional[str] with model validator
  validate_provider_required_for_llm_graph() that requires it only for LLM
  and GRAPH actor types; TOOL actors do not require provider
- schema.py: is_v3_yaml() uses version_str == "3" or version_str.startswith("3.")
  to avoid false positives from "30" or "300" version strings
- schema.py: tool namespace validation uses strict 2-part split to reject
  empty namespace or empty name (e.g. "/tool", "ns/", "a/b/c")
- cli/commands/actor.py: schema_version extraction uses raw_version pattern
  (no # type: ignore[assignment]) for clean static typing
- actor/__init__.py: is_v3_yaml removed from __all__ and _LAZY_IMPORTS
  since it is a module-private helper, not a public API
- robot/actor_add_v3_schema_validation.robot: YAML fixtures for 'Reject v3
  LLM Actor Without Model Field' and 'Reject v3 TOOL Actor Without Tools
  Field' now include required provider field (and model for TOOL fixture)
- robot/helper_actor_add_v3_schema_validation.py: except clauses unified to
  catch (subprocess.TimeoutExpired, FileNotFoundError) in both add_actor()
  and update_actor() functions
- features/actor_add_v3_schema_validation.feature: 'Update a v3 actor with
  valid YAML succeeds' scenario now includes 'And the actor should be
  validated via ActorConfigSchema'; error assertions tightened to exact
  messages (e.g. "Input should be 'llm', 'tool' or 'graph'", "Node ID must
  be alphanumeric", "must be namespaced")
- features/steps/actor_add_v3_schema_validation_steps.py: step_run_actor_update
  now spies on ActorConfigSchema.model_validate; failure paths assert
  isinstance(result.exception, SystemExit)

ISSUES CLOSED: #5869
2026-04-17 16:28:45 +08:00

389 lines
14 KiB
Python

"""Step definitions for actor_loading.feature."""
from __future__ import annotations
import tempfile
from pathlib import Path
from behave import given, then, when
from behave.runner import Context
from cleveragents.actor.loader import ActorLoader
from cleveragents.actor.schema import ActorConfigSchema
from cleveragents.core.exceptions import ValidationError
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runtime import ToolSpec
def _minimal_actor_yaml(
name: str,
actor_type: str = "llm",
model: str = "gpt-4",
description: str = "Test actor",
tools: list[str] | None = None,
) -> str:
lines = [
f"name: {name}",
f"type: {actor_type}",
f"description: {description}",
'version: "1.0"',
]
if actor_type in ("llm", "graph"):
lines.append("provider: openai")
lines.append(f"model: {model}")
if tools:
lines.append("tools:")
for t in tools:
lines.append(f" - {t}")
return "\n".join(lines) + "\n"
def _fresh_dir(context: Context, label: str = "default") -> Path:
"""Create a unique temporary directory per scenario invocation."""
d = Path(tempfile.mkdtemp(prefix=f"actload_{label}_"))
if not hasattr(context, "_tmp_dirs"):
context._tmp_dirs = []
context._tmp_dirs.append(d)
return d
# ──────────────────────────────────────────────────────
# Given steps
# ──────────────────────────────────────────────────────
@given("a temporary actors directory with files")
def step_given_temp_dir_with_files(context: Context) -> None:
d = _fresh_dir(context, "actors")
context._actor_dir = d
for row in context.table:
content = _minimal_actor_yaml(
name=row["name"],
actor_type=row["type"],
model=row.get("model", "gpt-4"),
)
(d / row["filename"]).write_text(content)
@given('a temporary actors directory "{dir_name}" with files')
def step_given_named_dir_with_files(context: Context, dir_name: str) -> None:
d = _fresh_dir(context, dir_name)
if not hasattr(context, "_actor_dirs"):
context._actor_dirs = []
context._actor_dirs.append(d)
for row in context.table:
content = _minimal_actor_yaml(
name=row["name"],
actor_type=row["type"],
model=row.get("model", "gpt-4"),
)
(d / row["filename"]).write_text(content)
@given('a non-YAML file "{filename}" in the directory')
def step_given_non_yaml_file(context: Context, filename: str) -> None:
d: Path = context._actor_dir
(d / filename).write_text("this is not yaml actor config\n")
@given("a temporary actors directory with nested structure")
def step_given_nested_dir(context: Context) -> None:
d = _fresh_dir(context, "nested")
context._actor_dir = d
for row in context.table:
subdir = d / row["subdir"]
subdir.mkdir(parents=True, exist_ok=True)
content = _minimal_actor_yaml(
name=row["name"],
actor_type=row["type"],
model=row.get("model", "gpt-4"),
)
(subdir / row["filename"]).write_text(content)
@given("a top-level actor file")
def step_given_top_level_file(context: Context) -> None:
d: Path = context._actor_dir
for row in context.table:
content = _minimal_actor_yaml(
name=row["name"],
actor_type=row["type"],
model=row.get("model", "gpt-4"),
)
(d / row["filename"]).write_text(content)
@given("a temporary actors directory with a nameless actor file")
def step_given_nameless_actor(context: Context) -> None:
d = _fresh_dir(context, "nameless")
context._actor_dir = d
for row in context.table:
content = _minimal_actor_yaml(
name=row["raw_name"],
actor_type=row["type"],
model=row.get("model", "gpt-4"),
)
(d / row["filename"]).write_text(content)
@given('a temporary actors directory with an invalid YAML file "{filename}"')
def step_given_invalid_yaml(context: Context, filename: str) -> None:
d = _fresh_dir(context, "invalid")
context._actor_dir = d
(d / filename).write_text("name: [\ninvalid: yaml: content:\n")
@given('a temporary actors directory with a schema-invalid file "{filename}"')
def step_given_schema_invalid(context: Context, filename: str) -> None:
d = _fresh_dir(context, "schemainvalid")
context._actor_dir = d
(d / filename).write_text("description: missing required fields\n")
@given("the project examples/actors directory exists")
def step_given_examples_dir(context: Context) -> None:
project_root = Path(__file__).resolve().parent.parent.parent
context._actor_dir = project_root / "examples" / "actors"
assert context._actor_dir.is_dir(), f"Expected {context._actor_dir} to exist"
@given('a tool registry with tool "{tool_name}"')
def step_given_tool_registry(context: Context, tool_name: str) -> None:
registry = ToolRegistry()
spec = ToolSpec(
name=tool_name,
description=f"Mock tool {tool_name}",
handler=lambda **kwargs: {"ok": True},
)
registry.register(spec)
context._tool_registry = registry
@given("an empty tool registry")
def step_given_empty_tool_registry(context: Context) -> None:
context._tool_registry = ToolRegistry()
@given("a temporary actors directory with a tool-actor file")
def step_given_tool_actor_file(context: Context) -> None:
d = _fresh_dir(context, "toolactors")
context._actor_dir = d
for row in context.table:
tools = [t.strip() for t in row["tools"].split(",")]
content = _minimal_actor_yaml(
name=row["name"],
actor_type=row["type"],
tools=tools,
)
(d / row["filename"]).write_text(content)
# ──────────────────────────────────────────────────────
# When steps
# ──────────────────────────────────────────────────────
@when("I create an actor loader with that directory as search root")
def step_when_create_loader_single(context: Context) -> None:
tool_reg = getattr(context, "_tool_registry", None)
context._loader = ActorLoader(
search_roots=[context._actor_dir],
tool_registry=tool_reg,
)
@when("I create an actor loader with both directories as search roots")
def step_when_create_loader_multi(context: Context) -> None:
roots = context._actor_dirs
context._loader = ActorLoader(search_roots=roots)
@when("I run discovery")
def step_when_run_discovery(context: Context) -> None:
loader: ActorLoader = context._loader
context._discovered = loader.discover()
@when("I run discovery expecting errors")
def step_when_run_discovery_errors(context: Context) -> None:
loader: ActorLoader = context._loader
try:
loader.discover()
context._discovery_error = None
except (ValidationError, Exception) as exc:
context._discovery_error = exc
@when("I run discovery again")
def step_when_run_discovery_again(context: Context) -> None:
loader: ActorLoader = context._loader
context._discovered = loader.discover()
@when('I modify the actor file "{filename}" to change the model to "{new_model}"')
def step_when_modify_file(context: Context, filename: str, new_model: str) -> None:
d: Path = context._actor_dir
path = d / filename
text = path.read_text()
text = text.replace("model: gpt-4", f"model: {new_model}")
path.write_text(text)
@when("I clear the loader cache")
def step_when_clear_cache(context: Context) -> None:
loader: ActorLoader = context._loader
loader.clear()
@when('I delete the actor file "{filename}"')
def step_when_delete_file(context: Context, filename: str) -> None:
d: Path = context._actor_dir
(d / filename).unlink()
@when("I create an actor loader with the examples/actors directory")
def step_when_create_loader_examples(context: Context) -> None:
context._loader = ActorLoader(search_roots=[context._actor_dir])
@when("I create an actor loader with that directory and the tool registry")
def step_when_create_loader_with_tools(context: Context) -> None:
context._loader = ActorLoader(
search_roots=[context._actor_dir],
tool_registry=context._tool_registry,
)
# ──────────────────────────────────────────────────────
# Then steps
# ──────────────────────────────────────────────────────
@then("the loader should find {count:d} actors")
def step_then_loader_count(context: Context, count: int) -> None:
loader: ActorLoader = context._loader
actors = loader.list_actors()
assert len(actors) == count, (
f"Expected {count} actors, got {len(actors)}: {[a.name for a in actors]}"
)
@then("the loader should find at least {count:d} actors")
def step_then_loader_min_count(context: Context, count: int) -> None:
loader: ActorLoader = context._loader
actors = loader.list_actors()
assert len(actors) >= count, f"Expected at least {count} actors, got {len(actors)}"
@then('the loader should contain actor "{name}"')
def step_then_loader_contains(context: Context, name: str) -> None:
loader: ActorLoader = context._loader
config = loader.get(name)
assert config is not None, f"Actor '{name}' not found in loader"
@then('the loader should raise a validation error mentioning "{text}"')
def step_then_validation_error_with_text(context: Context, text: str) -> None:
err = context._discovery_error
assert err is not None, "Expected a validation error but none was raised"
assert text in str(err), f"Expected '{text}' in error message: {err}"
@then("the loader should raise a validation error")
def step_then_validation_error(context: Context) -> None:
err = context._discovery_error
assert err is not None, "Expected a validation error but none was raised"
@then('I can retrieve actor "{name}" by name')
def step_then_retrieve_by_name(context: Context, name: str) -> None:
loader: ActorLoader = context._loader
config = loader.get(name)
assert config is not None, f"Actor '{name}' not found"
context._retrieved_actor = config
@then('the retrieved actor type should be "{actor_type}"')
def step_then_retrieved_type(context: Context, actor_type: str) -> None:
config: ActorConfigSchema = context._retrieved_actor
assert config.type.value == actor_type, (
f"Expected type '{actor_type}', got '{config.type.value}'"
)
@then('getting actor "{name}" should return None')
def step_then_get_none(context: Context, name: str) -> None:
loader: ActorLoader = context._loader
config = loader.get(name)
assert config is None, f"Expected None for '{name}', got {config}"
@then(
'listing loaded actors with namespace "{namespace}" should return {count:d} actors'
)
def step_then_list_by_namespace(context: Context, namespace: str, count: int) -> None:
loader: ActorLoader = context._loader
actors = loader.list_actors(namespace=namespace)
assert len(actors) == count, (
f"Expected {count} actors in namespace '{namespace}', got {len(actors)}: "
f"{[a.name for a in actors]}"
)
@then('the actor "{name}" should have been loaded only once')
def step_then_loaded_once(context: Context, name: str) -> None:
loader: ActorLoader = context._loader
count = loader.get_load_count(name)
assert count == 1, f"Expected load_count=1 for '{name}', got {count}"
@then('the actor "{name}" model should be "{model}"')
def step_then_actor_model(context: Context, name: str, model: str) -> None:
loader: ActorLoader = context._loader
config = loader.get(name)
assert config is not None, f"Actor '{name}' not found"
assert config.model == model, f"Expected model '{model}', got '{config.model}'"
@then('the actor "{name}" tools should include "{tool_name}"')
def step_then_actor_tools_include(context: Context, name: str, tool_name: str) -> None:
loader: ActorLoader = context._loader
config = loader.get(name)
assert config is not None, f"Actor '{name}' not found"
tool_names = [t if isinstance(t, str) else t.name for t in config.tools]
assert tool_name in tool_names, f"Tool '{tool_name}' not in {tool_names}"
@then('the loader should have a warning about unresolved tool "{tool_name}"')
def step_then_warning_unresolved(context: Context, tool_name: str) -> None:
loader: ActorLoader = context._loader
warnings = loader.warnings
matched = [w for w in warnings if tool_name in w]
assert matched, f"No warning about '{tool_name}' found in: {warnings}"
@then("the loader should have {count:d} warnings")
def step_then_warning_count(context: Context, count: int) -> None:
loader: ActorLoader = context._loader
warnings = loader.warnings
assert len(warnings) == count, (
f"Expected {count} warnings, got {len(warnings)}: {warnings}"
)
# ──────────────────────────────────────────────────────
# Given steps for coverage edge cases
# ──────────────────────────────────────────────────────
@given("a non-existent directory path as search root")
def step_given_nonexistent_dir(context: Context) -> None:
context._actor_dir = Path("/tmp/does_not_exist_actorloader_test")
@given('a temporary actors directory with a list-content YAML file "{filename}"')
def step_given_list_yaml(context: Context, filename: str) -> None:
d = _fresh_dir(context, "listyaml")
context._actor_dir = d
(d / filename).write_text("- item_one\n- item_two\n")