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
- 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
389 lines
14 KiB
Python
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")
|