feat(actor): add actor registry and loader

This commit is contained in:
2026-02-19 11:29:34 +00:00
parent 3ea2413b43
commit ef4ba36779
9 changed files with 1380 additions and 17 deletions
+145
View File
@@ -0,0 +1,145 @@
"""ASV benchmarks for actor loader discovery and registry performance.
Measures the performance of:
- Directory scanning and YAML file collection
- Single-root and multi-root discovery
- Cache hit performance (unchanged files)
- Re-discovery after file modification
- Namespace-filtered listing
"""
from __future__ import annotations
import importlib
import sys
import tempfile
from pathlib import Path
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
import cleveragents # noqa: E402
importlib.reload(cleveragents)
from cleveragents.actor.loader import ActorLoader # noqa: E402
_ACTOR_TEMPLATE = """\
name: {namespace}/{name}
type: llm
description: Benchmark actor {name}
version: "1.0"
model: gpt-4
"""
def _create_actor_dir(count: int, namespace: str = "bench") -> Path:
tmp = Path(tempfile.mkdtemp(prefix="actor_bench_"))
for i in range(count):
content = _ACTOR_TEMPLATE.format(namespace=namespace, name=f"actor_{i}")
(tmp / f"actor_{i}.yaml").write_text(content)
return tmp
class TimeActorDiscoverySingle:
"""Benchmark discovering actors from a single directory."""
def setup(self) -> None:
self.dir_5 = _create_actor_dir(5)
self.dir_20 = _create_actor_dir(20)
self.dir_50 = _create_actor_dir(50)
def teardown(self) -> None:
import shutil
for d in (self.dir_5, self.dir_20, self.dir_50):
shutil.rmtree(d, ignore_errors=True)
def time_discover_5_actors(self) -> None:
loader = ActorLoader(search_roots=[self.dir_5])
loader.discover()
def time_discover_20_actors(self) -> None:
loader = ActorLoader(search_roots=[self.dir_20])
loader.discover()
def time_discover_50_actors(self) -> None:
loader = ActorLoader(search_roots=[self.dir_50])
loader.discover()
class TimeActorDiscoveryMultiRoot:
"""Benchmark discovering actors from multiple search roots."""
def setup(self) -> None:
self.roots = [_create_actor_dir(10, f"ns{i}") for i in range(5)]
def teardown(self) -> None:
import shutil
for d in self.roots:
shutil.rmtree(d, ignore_errors=True)
def time_discover_5_roots_50_actors(self) -> None:
loader = ActorLoader(search_roots=self.roots)
loader.discover()
class TimeActorCacheHit:
"""Benchmark re-discovery when files are unchanged (cache hit)."""
def setup(self) -> None:
self.directory = _create_actor_dir(20)
self.loader = ActorLoader(search_roots=[self.directory])
self.loader.discover()
def teardown(self) -> None:
import shutil
shutil.rmtree(self.directory, ignore_errors=True)
def time_rediscover_cached(self) -> None:
self.loader.discover()
class TimeActorLookup:
"""Benchmark namespaced actor lookup and listing."""
def setup(self) -> None:
self.directory = _create_actor_dir(50)
self.loader = ActorLoader(search_roots=[self.directory])
self.loader.discover()
def teardown(self) -> None:
import shutil
shutil.rmtree(self.directory, ignore_errors=True)
def time_get_by_name(self) -> None:
self.loader.get("bench/actor_25")
def time_list_all(self) -> None:
self.loader.list_actors()
def time_list_by_namespace(self) -> None:
self.loader.list_actors(namespace="bench")
class TimeActorExamplesDiscovery:
"""Benchmark discovering the project example actors."""
def setup(self) -> None:
project_root = Path(__file__).resolve().parents[1]
self.examples_dir = project_root / "examples" / "actors"
def time_discover_examples(self) -> None:
loader = ActorLoader(search_roots=[self.examples_dir])
loader.discover()
time_single = TimeActorDiscoverySingle()
time_multi = TimeActorDiscoveryMultiRoot()
time_cache = TimeActorCacheHit()
time_lookup = TimeActorLookup()
time_examples = TimeActorExamplesDiscovery()
+81
View File
@@ -0,0 +1,81 @@
# Actor Loading and Discovery
The actor loader discovers, validates, and caches actor YAML configuration files from configurable search roots.
## Discovery Rules
The `ActorLoader` scans one or more **search root** directories for actor YAML files:
| Rule | Detail |
|------|--------|
| **File extensions** | Only `*.yaml` and `*.yml` files are loaded |
| **Recursive scan** | Subdirectories are scanned recursively |
| **Non-YAML ignored** | Files with other extensions (`.json`, `.txt`, etc.) are silently skipped |
| **Invalid YAML rejected** | Files that fail YAML parsing or schema validation raise `ValidationError` |
### Search Roots
Search roots are directories passed to the loader at construction time. Common roots include:
- `actors/` — project-level actor definitions
- `examples/actors/` — example actor files shipped with CleverAgents
## Namespace Handling
All actors must have a namespaced name in `<namespace>/<name>` format.
| Input | Result |
|-------|--------|
| `assistants/reviewer` | Kept as-is |
| `my-agent` (no slash) | Automatically becomes `local/my-agent` |
The `local/` namespace is the default for local-only actors. See the [Namespace System](../specification.md#namespaces) for the full namespace hierarchy.
## Duplicate Detection
When the same actor name appears in multiple files (within a single search root or across roots), the loader emits a **single consolidated error** listing all conflicting file paths:
```
Actor discovery failed:
Duplicate actor 'assistants/writer' found in: /path/a/writer.yaml, /path/b/writer.yaml
```
## Content Hash Caching
The loader caches loaded actors using a SHA-256 content hash to avoid redundant parsing:
| Condition | Behavior |
|-----------|----------|
| File content unchanged | Actor is served from cache (no re-parse) |
| File content modified | Actor is re-parsed and cache is updated |
| File deleted | Actor is removed from cache on next discovery |
This means calling `discover()` multiple times is efficient — only changed files trigger re-parsing.
## Tool Registry Integration
When a `ToolRegistry` is provided, the loader verifies tool references at load time:
- **Resolved tools**: Tool references that exist in the registry are validated silently.
- **Unresolved tools**: Missing tool references emit a warning but do not block loading. This allows actors to be loaded before all tools are registered.
## API Reference
### `ActorLoader`
```python
from cleveragents.actor.loader import ActorLoader
loader = ActorLoader(
search_roots=[Path("actors/"), Path("examples/actors/")],
tool_registry=tool_registry, # optional
)
```
| Method | Description |
|--------|-------------|
| `discover()` | Scan search roots and load/reload actors. Returns list of `ActorConfigSchema`. |
| `get(name)` | Retrieve a loaded actor by namespaced name. Returns `None` if not found. |
| `list_actors(namespace=None)` | List actors with optional namespace filter. |
| `clear()` | Drop all cached actors and content hashes. |
| `warnings` | List of warnings emitted during the last discovery run. |
+255
View File
@@ -0,0 +1,255 @@
Feature: Actor registry and loader
As a CleverAgents user
I want to discover, load, and cache actor YAML configs from the filesystem
So that actors are available for compilation and execution
#
# Discovery from search roots
#
Scenario: Discover actors from a single directory
Given a temporary actors directory with files
| filename | name | type | model |
| reviewer.yaml | assistants/reviewer | llm | gpt-4 |
| formatter.yaml | utilities/formatter | llm | gpt-4 |
When I create an actor loader with that directory as search root
And I run discovery
Then the loader should find 2 actors
And the loader should contain actor "assistants/reviewer"
And the loader should contain actor "utilities/formatter"
Scenario: Discover actors from multiple search roots
Given a temporary actors directory "root_a" with files
| filename | name | type | model |
| writer.yaml | assistants/writer | llm | gpt-4 |
And a temporary actors directory "root_b" with files
| filename | name | type | model |
| analyst.yaml | data/analyst | llm | gpt-4 |
When I create an actor loader with both directories as search roots
And I run discovery
Then the loader should find 2 actors
And the loader should contain actor "assistants/writer"
And the loader should contain actor "data/analyst"
Scenario: Only YAML files are discovered
Given a temporary actors directory with files
| filename | name | type | model |
| valid.yaml | assistants/valid | llm | gpt-4 |
And a non-YAML file "readme.txt" in the directory
And a non-YAML file "config.json" in the directory
When I create an actor loader with that directory as search root
And I run discovery
Then the loader should find 1 actors
Scenario: Nested YAML files are discovered recursively
Given a temporary actors directory with nested structure
| subdir | filename | name | type | model |
| sub | nested.yaml | assistants/nested | llm | gpt-4 |
| sub/deep | deep.yaml | assistants/deep | llm | gpt-4 |
And a top-level actor file
| filename | name | type | model |
| top.yaml | assistants/top | llm | gpt-4 |
When I create an actor loader with that directory as search root
And I run discovery
Then the loader should find 3 actors
# ────────────────────────────────────────────────────────────
# Duplicate detection
# ────────────────────────────────────────────────────────────
Scenario: Duplicate actor names across search roots emit consolidated error
Given a temporary actors directory "dup_a" with files
| filename | name | type | model |
| writer.yaml | assistants/writer | llm | gpt-4 |
And a temporary actors directory "dup_b" with files
| filename | name | type | model |
| writer2.yaml | assistants/writer | llm | gpt-4 |
When I create an actor loader with both directories as search roots
And I run discovery expecting errors
Then the loader should raise a validation error mentioning "assistants/writer"
Scenario: Duplicate actor names within a single search root emit error
Given a temporary actors directory with files
| filename | name | type | model |
| one.yaml | assistants/helper | llm | gpt-4 |
| two.yaml | assistants/helper | llm | gpt-4 |
When I create an actor loader with that directory as search root
And I run discovery expecting errors
Then the loader should raise a validation error mentioning "assistants/helper"
# ────────────────────────────────────────────────────────────
# Namespace handling
# ────────────────────────────────────────────────────────────
Scenario: Default local/ namespace applied when namespace omitted
Given a temporary actors directory with a nameless actor file
| filename | raw_name | type | model |
| bare.yaml | my-agent | llm | gpt-4 |
When I create an actor loader with that directory as search root
And I run discovery
Then the loader should contain actor "local/my-agent"
Scenario: Explicit namespace preserved as-is
Given a temporary actors directory with files
| filename | name | type | model |
| custom.yaml | myteam/my-actor | llm | gpt-4 |
When I create an actor loader with that directory as search root
And I run discovery
Then the loader should contain actor "myteam/my-actor"
# ────────────────────────────────────────────────────────────
# Namespaced lookup
# ────────────────────────────────────────────────────────────
Scenario: Get actor by namespaced name
Given a temporary actors directory with files
| filename | name | type | model |
| helper.yaml | assistants/helper | llm | gpt-4 |
When I create an actor loader with that directory as search root
And I run discovery
Then I can retrieve actor "assistants/helper" by name
And the retrieved actor type should be "llm"
Scenario: Get nonexistent actor returns None
Given a temporary actors directory with files
| filename | name | type | model |
| helper.yaml | assistants/helper | llm | gpt-4 |
When I create an actor loader with that directory as search root
And I run discovery
Then getting actor "assistants/missing" should return None
Scenario: List actors filtered by namespace
Given a temporary actors directory with files
| filename | name | type | model |
| rev.yaml | assistants/reviewer | llm | gpt-4 |
| fmt.yaml | utilities/formatter | llm | gpt-4 |
| ana.yaml | assistants/analyzer | llm | gpt-4 |
When I create an actor loader with that directory as search root
And I run discovery
Then listing actors with namespace "assistants" should return 2 actors
And listing actors with namespace "utilities" should return 1 actors
# ────────────────────────────────────────────────────────────
# Content hash caching
# ────────────────────────────────────────────────────────────
Scenario: Unchanged files are not reloaded on re-discovery
Given a temporary actors directory with files
| filename | name | type | model |
| cached.yaml | assistants/cached | llm | gpt-4 |
When I create an actor loader with that directory as search root
And I run discovery
And I run discovery again
Then the actor "assistants/cached" should have been loaded only once
Scenario: Modified files are reloaded on re-discovery
Given a temporary actors directory with files
| filename | name | type | model |
| mutable.yaml | assistants/mutable | llm | gpt-4 |
When I create an actor loader with that directory as search root
And I run discovery
And I modify the actor file "mutable.yaml" to change the model to "gpt-3.5-turbo"
And I run discovery again
Then the actor "assistants/mutable" model should be "gpt-3.5-turbo"
# ────────────────────────────────────────────────────────────
# Validation at load time
# ────────────────────────────────────────────────────────────
Scenario: Invalid YAML file is rejected with clear error
Given a temporary actors directory with an invalid YAML file "bad.yaml"
When I create an actor loader with that directory as search root
And I run discovery expecting errors
Then the loader should raise a validation error mentioning "bad.yaml"
Scenario: YAML file missing required fields is rejected
Given a temporary actors directory with a schema-invalid file "incomplete.yaml"
When I create an actor loader with that directory as search root
And I run discovery expecting errors
Then the loader should raise a validation error
# ────────────────────────────────────────────────────────────
# Integration with example actors
# ────────────────────────────────────────────────────────────
Scenario: Discover all example actors from examples/actors/
Given the project examples/actors directory exists
When I create an actor loader with the examples/actors directory
And I run discovery
Then the loader should find at least 5 actors
# ────────────────────────────────────────────────────────────
# Cache invalidation
# ────────────────────────────────────────────────────────────
Scenario: Clear cache removes all loaded actors
Given a temporary actors directory with files
| filename | name | type | model |
| test.yaml | assistants/test | llm | gpt-4 |
When I create an actor loader with that directory as search root
And I run discovery
And I clear the loader cache
Then the loader should find 0 actors
Scenario: Deleted actor file is removed on re-discovery
Given a temporary actors directory with files
| filename | name | type | model |
| keep.yaml | assistants/keep | llm | gpt-4 |
| remove.yaml | assistants/removable | llm | gpt-4 |
When I create an actor loader with that directory as search root
And I run discovery
And I delete the actor file "remove.yaml"
And I run discovery again
Then the loader should find 1 actors
And the loader should contain actor "assistants/keep"
And getting actor "assistants/removable" should return None
# ────────────────────────────────────────────────────────────
# Tool Registry integration
# ────────────────────────────────────────────────────────────
Scenario: Actor loader resolves tool references through tool registry
Given a tool registry with tool "files/read_file"
And a temporary actors directory with a tool-actor file
| filename | name | type | tools |
| tools.yaml | utilities/file_ops | tool | files/read_file |
When I create an actor loader with that directory and the tool registry
And I run discovery
Then the loader should contain actor "utilities/file_ops"
And the actor "utilities/file_ops" tools should include "files/read_file"
Scenario: Actor with unresolvable tool reference logs warning
Given an empty tool registry
And a temporary actors directory with a tool-actor file
| filename | name | type | tools |
| tools.yaml | utilities/ops | tool | missing/nonexistent |
When I create an actor loader with that directory and the tool registry
And I run discovery
Then the loader should contain actor "utilities/ops"
And the loader should have a warning about unresolved tool "missing/nonexistent"
# ────────────────────────────────────────────────────────────
# Edge cases for coverage
# ────────────────────────────────────────────────────────────
Scenario: Non-existent search root is skipped gracefully
Given a non-existent directory path as search root
When I create an actor loader with that directory as search root
And I run discovery
Then the loader should find 0 actors
Scenario: YAML file containing a list instead of a mapping is rejected
Given a temporary actors directory with a list-content YAML file "list.yaml"
When I create an actor loader with that directory as search root
And I run discovery expecting errors
Then the loader should raise a validation error mentioning "list.yaml"
Scenario: LLM actor without tools does not produce warnings with tool registry
Given a tool registry with tool "files/read_file"
And a temporary actors directory with files
| filename | name | type | model |
| simple.yaml | assistants/simple | llm | gpt-4 |
When I create an actor loader with that directory and the tool registry
And I run discovery
Then the loader should contain actor "assistants/simple"
And the loader should have 0 warnings
+385
View File
@@ -0,0 +1,385 @@
"""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(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 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")
+96 -15
View File
@@ -1171,6 +1171,87 @@ The following work from the previous implementation has been completed and will
- ASV benchmarks: 4/4 suites pass
- Key decision: Followed `PlanLifecycleService` dual-mode pattern — `SkillService` works fully in-memory now; when Luis adds `C0.skill.registry` with `UnitOfWork`/`SkillRepository`, DB writes are added alongside the in-memory dict without changing the public API
- Key decision: Module-level `_service` singleton (not DI container) matches the pattern in `action.py` CLI; `_reset_skill_service()` enables test isolation
**2026-02-19**: Task C2.loader Complete (Aditya) - Actor Registry and Loader
- **Implementation**: Created `src/cleveragents/actor/loader.py` (277 lines) — `ActorLoader` class with thread-safe discovery, caching, and namespace management
- `ActorLoader.__init__(search_roots, tool_registry)` — accepts configurable search root directories and optional `ToolRegistry` for tool reference resolution
- `discover()` — scans all search roots recursively for `*.yaml`/`*.yml` files, parses against `ActorConfigSchema`, detects duplicates across roots, emits consolidated `ValidationError` with all errors
- `get(name)` — retrieves a loaded actor by its namespaced name
- `list_actors(namespace)` — lists all loaded actors with optional namespace prefix filtering
- `clear()` — drops all cached actors and content hashes
- `get_load_count(name)` — returns load count for a named actor (test introspection)
- `_CacheEntry` internal class stores `ActorConfigSchema`, SHA-256 content hash, source path, and load count
- `_compute_hash(content)` — SHA-256 content hashing for cache invalidation
- `_normalize_name(raw_name)` — applies `local/` default namespace when no slash present
- `_collect_yaml_files()` — recursive file collection from search roots with non-directory warning
- `_prune_deleted(current_files)` — removes stale actors whose source files no longer exist
- `_apply_namespace_default(raw)` — applies namespace default to raw YAML dict before validation
- `_resolve_tools(config)` — verifies tool references against `ToolRegistry`, logs warnings for unresolved tools
- All mutating operations protected by `threading.RLock`
- Updated `src/cleveragents/actor/__init__.py` — exported `ActorLoader` in package `__all__`
- **Documentation**: Created `docs/reference/actors_loading.md` (83 lines)
- Discovery rules: search roots, YAML suffixes, recursive scanning
- Namespace handling: `local/` default, explicit namespace preservation, `<namespace>/<name>` format
- Duplicate detection: consolidated error across search roots
- Content-hash caching: SHA-256 based, skip reload for unchanged files
- API usage examples with code snippets
- **Tests**: Comprehensive coverage across all test layers (all passing)
- **Behave**: `features/actor_loading.feature` (23 scenarios, 117 steps)
- Discovery: single root, multiple roots, YAML-only filtering, recursive nested discovery
- Duplicate detection: across roots, within single root, consolidated error messages
- Namespace handling: default `local/` application, explicit namespace preservation
- Namespaced lookup: get by name, nonexistent returns None, list filtered by namespace
- Content-hash caching: unchanged files not reloaded, modified files reloaded
- Validation: invalid YAML rejected, schema-invalid files rejected, list-content YAML rejected
- Integration: discover all example actors from `examples/actors/`
- Cache invalidation: clear removes all, deleted files removed on re-discovery
- Tool Registry: resolved tool references, unresolved tool warnings, no-tool actors with registry
- Edge cases: non-existent search root skipped gracefully
- **Step Definitions**: `features/steps/actor_loading_steps.py` (390 lines)
- Per-scenario temporary directory isolation via `_fresh_dir()` helper
- `_minimal_actor_yaml()` helper for generating valid actor YAML content
- Full coverage of given/when/then steps for all 23 scenarios
- **Robot**: `robot/actor_loading.robot` (4 test cases)
- Integration smoke tests: discover example actors, valid load, invalid rejection, namespaced lookup
- Helper script: `robot/helper_actor_loading.py`
- **ASV**: `benchmarks/actor_loading_bench.py` (4 benchmark classes)
- Discovery performance, cached re-discovery, namespace lookup, clear-and-reload cycle
- **Coverage**: 98% on `src/cleveragents/actor/loader.py` (153 statements, 1 miss, 48 branches, 3 partial)
- Remaining uncovered: `_normalize_name` early return (line 62), two partial branch edges for empty iteration loops
- **Quality checks (file-specific)**:
- Ruff lint: 0 findings
- Ruff format: all files formatted
- Bandit security: 0 findings
- Pyright typecheck: 0 errors
- Behave tests: 23 scenarios, 117 steps, 0 failures
- Robot tests: 4/4 pass
- ASV benchmarks: 4/4 suites pass
- **Key design decisions**:
- Followed `ToolRegistry` pattern for thread-safe in-memory registry with `threading.RLock`
- SHA-256 content hashing (not mtime) for deterministic cache invalidation
- Single consolidated `ValidationError` when duplicates or parse errors found (not one error per file)
- Tool reference resolution at load time via optional `ToolRegistry` integration (warnings, not errors, for unresolved tools)
- `local/` default namespace applied during YAML parsing before schema validation
- **Branch**: `feature/m3-actor-loader`
- **Files created/modified**:
- `src/cleveragents/actor/loader.py` (new)
- `src/cleveragents/actor/__init__.py` (modified)
- `features/actor_loading.feature` (new)
- `features/steps/actor_loading_steps.py` (new)
- `robot/actor_loading.robot` (new)
- `robot/helper_actor_loading.py` (new)
- `benchmarks/actor_loading_bench.py` (new)
- `docs/reference/actors_loading.md` (new)
- **Next steps**: C2.compiler (Actor Compiler) depends on this loader; C2.context depends on C2.loader
---
## Roadmap
@@ -3362,21 +3443,21 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
**Parallel Group C2: Actor Loading & Compilation [Aditya + Jeff]** (depends on C1)
**PARALLEL SUBTRACK C2.legacy [Jeff]**: Remove v2 actor config compatibility (after C1.schema)
**SEQUENTIAL NOTE**: C2.legacy must land before C2.loader/C2.compiler to avoid dual-format support.
- [ ] **COMMIT (Owner: Aditya | Group: C2.loader | Branch: feature/m3-actor-loader | Planned: Day 13 | Expected: Day 19) - Commit message: "feat(actor): add actor registry and loader"**
- [ ] Git [Aditya]: `git checkout master`
- [ ] Git [Aditya]: `git pull origin master`
- [ ] Git [Aditya]: `git checkout -b feature/m3-actor-loader`
- [ ] Git [Aditya]: `git fetch origin && git merge origin/master`
- [ ] Code [Aditya]: Implement actor loader/registry with namespaced lookup, cache invalidation, and file discovery in `actors/` and `examples/actors/`.
- [ ] Code [Aditya]: Normalize actor file paths, reject non-`.yaml` files, and emit a single consolidated error when duplicates are found across search roots.
- [ ] Code [Aditya]: Store and compare a content hash for actor configs to avoid reloading unchanged actors (mtime + hash fallback).
- [ ] Code [Aditya]: Add registry integration with Tool Registry so tool nodes resolve at load time.
- [ ] Code [Aditya]: Validate actor namespaced names on load and apply default `local/` when namespace omitted.
- [ ] Docs [Aditya]: Add `docs/reference/actors_loading.md` with discovery rules and namespaces.
- [ ] Tests (Behave) [Aditya]: Add `features/actor_loading.feature` for discovery, duplicates, and namespace lookup.
- [ ] Tests (Robot) [Aditya]: Add `robot/actor_loading.robot` for loader smoke tests.
- [ ] Tests (ASV) [Aditya]: Add `benchmarks/actor_loading_bench.py` for registry load performance.
- [ ] Quality [Aditya]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] **COMMIT (Owner: Aditya | Group: C2.loader | Branch: feature/m3-actor-loader | Planned: Day 13 | Expected: Day 19) - Commit message: "feat(actor): add actor registry and loader"**
- [X] Git [Aditya]: `git checkout master`
- [X] Git [Aditya]: `git pull origin master`
- [X] Git [Aditya]: `git checkout -b feature/m3-actor-loader`
- [X] Git [Aditya]: `git fetch origin && git merge origin/master`
- [X] Code [Aditya]: Implement actor loader/registry with namespaced lookup, cache invalidation, and file discovery in `actors/` and `examples/actors/`.
- [X] Code [Aditya]: Normalize actor file paths, reject non-`.yaml` files, and emit a single consolidated error when duplicates are found across search roots.
- [X] Code [Aditya]: Store and compare a content hash for actor configs to avoid reloading unchanged actors (mtime + hash fallback).
- [X] Code [Aditya]: Add registry integration with Tool Registry so tool nodes resolve at load time.
- [X] Code [Aditya]: Validate actor namespaced names on load and apply default `local/` when namespace omitted.
- [X] Docs [Aditya]: Add `docs/reference/actors_loading.md` with discovery rules and namespaces.
- [X] Tests (Behave) [Aditya]: Add `features/actor_loading.feature` for discovery, duplicates, and namespace lookup.
- [X] Tests (Robot) [Aditya]: Add `robot/actor_loading.robot` for loader smoke tests.
- [X] Tests (ASV) [Aditya]: Add `benchmarks/actor_loading_bench.py` for registry load performance.
- [X] Quality [Aditya]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] Quality [Aditya]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [ ] Git [Aditya]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [ ] Git [Aditya]: `git commit -m "feat(actor): add actor registry and loader"`
+47
View File
@@ -0,0 +1,47 @@
*** Settings ***
Documentation Smoke tests for actor YAML loader and discovery
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_actor_loading.py
*** Test Cases ***
Discover Example Actors
[Documentation] Load all actors from examples/actors/ and verify count
${result}= Run Process ${PYTHON} ${HELPER} discover-examples cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-count:
Discover Actors From Custom Directory
[Documentation] Discover actors from a temporary directory with a valid YAML
${tmp}= Set Variable ${TEMPDIR}${/}actor_loading_robot
Run Keyword And Ignore Error Remove Directory ${tmp} recursive=True
Create Directory ${tmp}
Create File ${tmp}${/}test.yaml name: local/robot-test\ntype: llm\ndescription: Robot test actor\nversion: "1.0"\nmodel: gpt-4\n
${result}= Run Process ${PYTHON} ${HELPER} discover ${tmp} cwd=${WORKSPACE}
Log ${result.stdout}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-loaded: local/robot-test
Should Contain ${result.stdout} actor-count: 1
Reject Invalid Actor YAML
[Documentation] Attempt to discover from a directory with invalid YAML and assert failure
${tmp}= Set Variable ${TEMPDIR}${/}actor_loading_invalid
Run Keyword And Ignore Error Remove Directory ${tmp} recursive=True
Create Directory ${tmp}
Create File ${tmp}${/}bad.yaml name: [\ninvalid yaml content\n
${result}= Run Process ${PYTHON} ${HELPER} discover-invalid ${tmp} cwd=${WORKSPACE}
Log ${result.stdout}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-loading-expected-fail
Default Namespace Applied
[Documentation] Verify that actors without namespace get local/ prefix
${result}= Run Process ${PYTHON} ${HELPER} namespace-default cwd=${WORKSPACE}
Log ${result.stdout}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} namespace-default-ok: local/bare-name
+92
View File
@@ -0,0 +1,92 @@
"""Robot Framework helper for actor loader smoke tests.
Provides a CLI-style interface for Robot to invoke actor discovery
and loading. Exit code 0 = success, 1 = failure.
Usage:
python robot/helper_actor_loading.py discover <directory>
python robot/helper_actor_loading.py discover-examples
python robot/helper_actor_loading.py discover-invalid <directory>
python robot/helper_actor_loading.py namespace-default <directory>
"""
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.actor.loader import ActorLoader # noqa: E402
def main() -> int:
if len(sys.argv) < 2:
print("Usage: helper_actor_loading.py <command> [args...]")
return 1
command = sys.argv[1]
if command == "discover":
directory = sys.argv[2]
return _discover(Path(directory))
if command == "discover-examples":
project_root = Path(__file__).resolve().parents[1]
return _discover(project_root / "examples" / "actors")
if command == "discover-invalid":
directory = sys.argv[2]
return _discover_invalid(Path(directory))
if command == "namespace-default":
return _namespace_default()
print(f"Unknown command: {command}")
return 1
def _discover(directory: Path) -> int:
loader = ActorLoader(search_roots=[directory])
actors = loader.discover()
for actor in actors:
print(f"actor-loaded: {actor.name} type={actor.type.value}")
print(f"actor-count: {len(actors)}")
return 0
def _discover_invalid(directory: Path) -> int:
loader = ActorLoader(search_roots=[directory])
try:
loader.discover()
print("actor-loading-unexpected-success")
return 1
except Exception as exc:
print(f"actor-loading-expected-fail: {exc}")
return 0
def _namespace_default() -> int:
tmp = Path(tempfile.mkdtemp(prefix="actor_ns_"))
yaml_content = (
"name: bare-name\n"
"type: llm\n"
"description: Test namespace defaulting\n"
'version: "1.0"\n'
"model: gpt-4\n"
)
(tmp / "bare.yaml").write_text(yaml_content)
loader = ActorLoader(search_roots=[tmp])
actors = loader.discover()
if len(actors) == 1 and actors[0].name == "local/bare-name":
print("namespace-default-ok: local/bare-name")
return 0
print(f"namespace-default-fail: {[a.name for a in actors]}")
return 1
if __name__ == "__main__":
sys.exit(main())
+3 -2
View File
@@ -1,6 +1,7 @@
"""Actor package for actor registry and configuration helpers."""
"""Actor package for actor registry, configuration, and loading helpers."""
from .config import ActorConfiguration
from .loader import ActorLoader
from .registry import ActorRegistry
__all__ = ["ActorConfiguration", "ActorRegistry"]
__all__ = ["ActorConfiguration", "ActorLoader", "ActorRegistry"]
+276
View File
@@ -0,0 +1,276 @@
"""Actor loader for discovering and caching actor YAML configurations.
Discovers actor YAML files from configurable search roots, parses them
against the v3 ``ActorConfigSchema``, applies namespace defaults, and
caches results using content hashing to avoid redundant reloads.
Operations:
| Method | Description |
|--------------------------|------------------------------------------------|
| ``discover()`` | Scan search roots and load/reload actors |
| ``get(name)`` | Retrieve a loaded actor by namespaced name |
| ``list_actors(...)`` | List actors with optional namespace filter |
| ``clear()`` | Drop all cached actors and hashes |
All mutating operations are protected by ``threading.RLock``.
"""
from __future__ import annotations
import hashlib
import logging
import threading
from pathlib import Path
from typing import Any
import yaml
from cleveragents.actor.schema import ActorConfigSchema
from cleveragents.core.exceptions import ValidationError
from cleveragents.tool.registry import ToolRegistry
logger = logging.getLogger(__name__)
_DEFAULT_NAMESPACE = "local"
_YAML_SUFFIXES = frozenset({".yaml", ".yml"})
class _CacheEntry:
"""Internal cache entry storing an actor config alongside its content hash."""
__slots__ = ("config", "content_hash", "load_count", "source_path")
def __init__(
self,
config: ActorConfigSchema,
content_hash: str,
source_path: Path,
) -> None:
self.config = config
self.content_hash = content_hash
self.source_path = source_path
self.load_count = 1
def _compute_hash(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
def _normalize_name(raw_name: str) -> str:
"""Apply ``local/`` default when the name has no namespace slash."""
if "/" in raw_name:
return raw_name
return f"{_DEFAULT_NAMESPACE}/{raw_name}"
class ActorLoader:
"""Discover, validate, and cache actor YAML configurations.
Parameters
----------
search_roots:
Directories to scan for ``*.yaml`` / ``*.yml`` actor files.
tool_registry:
Optional tool registry used to verify tool references at load time.
"""
def __init__(
self,
search_roots: list[Path],
tool_registry: ToolRegistry | None = None,
) -> None:
self._search_roots = [Path(r) for r in search_roots]
self._tool_registry = tool_registry
self._actors: dict[str, _CacheEntry] = {}
self._path_to_name: dict[Path, str] = {}
self._lock = threading.RLock()
self._warnings: list[str] = []
@property
def warnings(self) -> list[str]:
"""Warnings emitted during the last discovery run."""
with self._lock:
return list(self._warnings)
def discover(self) -> list[ActorConfigSchema]:
"""Scan search roots and load or reload actor configs.
Returns the list of all currently loaded actor configs.
Raises
------
ValidationError
When duplicate actor names are found across files or when
a YAML file fails schema validation.
"""
with self._lock:
self._warnings = []
found_files = self._collect_yaml_files()
self._prune_deleted(found_files)
pending: dict[str, list[tuple[Path, ActorConfigSchema]]] = {}
errors: list[str] = []
for path in found_files:
resolved = path.resolve()
content = resolved.read_bytes()
content_hash = _compute_hash(content)
existing_name = self._path_to_name.get(resolved)
if existing_name and existing_name in self._actors:
entry = self._actors[existing_name]
if entry.content_hash == content_hash:
name = existing_name
config = entry.config
pending.setdefault(name, []).append((resolved, config))
continue
try:
raw = yaml.safe_load(content)
except yaml.YAMLError as exc:
errors.append(f"Invalid YAML in {path.name}: {exc}")
continue
if not isinstance(raw, dict):
errors.append(
f"Expected mapping in {path.name}, got {type(raw).__name__}"
)
continue
raw = self._apply_namespace_default(raw)
try:
config = ActorConfigSchema.model_validate(raw)
except Exception as exc:
errors.append(f"Schema validation failed for {path.name}: {exc}")
continue
name = config.name
pending.setdefault(name, []).append((resolved, config))
duplicate_msgs: list[str] = []
for name, entries in pending.items():
if len(entries) > 1:
paths_str = ", ".join(str(p) for p, _ in entries)
duplicate_msgs.append(
f"Duplicate actor '{name}' found in: {paths_str}"
)
if duplicate_msgs:
all_errors = errors + duplicate_msgs
raise ValidationError(
"Actor discovery failed:\n" + "\n".join(all_errors),
details={"duplicates": duplicate_msgs, "errors": errors},
)
if errors:
raise ValidationError(
"Actor discovery failed:\n" + "\n".join(errors),
details={"errors": errors},
)
new_actors: dict[str, _CacheEntry] = {}
new_path_map: dict[Path, str] = {}
for name, entries in pending.items():
resolved_path, config = entries[0]
content_hash = _compute_hash(resolved_path.read_bytes())
old_entry = self._actors.get(name)
if old_entry and old_entry.content_hash == content_hash:
new_actors[name] = old_entry
else:
entry = _CacheEntry(
config=config,
content_hash=content_hash,
source_path=resolved_path,
)
if old_entry:
entry.load_count = old_entry.load_count + 1
new_actors[name] = entry
new_path_map[resolved_path] = name
self._resolve_tools(config)
self._actors = new_actors
self._path_to_name = new_path_map
return [e.config for e in self._actors.values()]
def get(self, name: str) -> ActorConfigSchema | None:
"""Retrieve a loaded actor by its namespaced name."""
with self._lock:
entry = self._actors.get(name)
return entry.config if entry else None
def list_actors(
self,
namespace: str | None = None,
) -> list[ActorConfigSchema]:
"""List loaded actors with optional namespace filter.
Parameters
----------
namespace:
If provided, only return actors whose name starts with
``namespace/``.
"""
with self._lock:
configs = [e.config for e in self._actors.values()]
if namespace is not None:
prefix = f"{namespace}/"
configs = [c for c in configs if c.name.startswith(prefix)]
return configs
def clear(self) -> None:
"""Drop all cached actors and content hashes."""
with self._lock:
self._actors.clear()
self._path_to_name.clear()
self._warnings.clear()
def get_load_count(self, name: str) -> int:
"""Return how many times an actor was loaded from disk (for testing)."""
with self._lock:
entry = self._actors.get(name)
return entry.load_count if entry else 0
def _collect_yaml_files(self) -> list[Path]:
files: list[Path] = []
for root in self._search_roots:
if not root.is_dir():
logger.warning("Search root is not a directory: %s", root)
continue
for path in root.rglob("*"):
if path.is_file() and path.suffix in _YAML_SUFFIXES:
files.append(path)
return sorted(files)
def _prune_deleted(self, current_files: list[Path]) -> None:
resolved_set = {p.resolve() for p in current_files}
stale_paths = [p for p in self._path_to_name if p not in resolved_set]
for stale in stale_paths:
name = self._path_to_name.pop(stale, None)
if name:
self._actors.pop(name, None)
@staticmethod
def _apply_namespace_default(raw: dict[str, Any]) -> dict[str, Any]:
name = raw.get("name")
if isinstance(name, str) and "/" not in name:
raw["name"] = _normalize_name(name)
return raw
def _resolve_tools(self, config: ActorConfigSchema) -> None:
if self._tool_registry is None:
return
for tool_ref in config.tools:
if isinstance(tool_ref, str):
spec = self._tool_registry.get(tool_ref)
if spec is None:
msg = (
f"Unresolved tool reference '{tool_ref}' "
f"in actor '{config.name}'"
)
logger.warning(msg)
self._warnings.append(msg)