fix(actor): support v3 Actor YAML schema in CLI registration and execution #9921

Merged
hurui200320 merged 1 commits from bugfix/m2-actor-cli-v3-yaml-schema into master 2026-04-27 05:26:24 +00:00
19 changed files with 2825 additions and 322 deletions
+19
View File
@@ -73,6 +73,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`features/uko_runtime.feature`, completing four-layer guarantee verification
for the UKO runtime.
- **Actor CLI v3 YAML Schema Support** (#6283): Fixed three components to add
full v3 `ActorConfigSchema` support to the actor CLI registration and
execution paths. `ActorConfiguration.from_blob()` now detects v3 format
(top-level `type` key of `llm`/`graph`/`tool`) and correctly extracts
provider, model, and graph descriptors — including `type: tool` actors
without a `model` field. `ActorRegistry.add()` validates against the full
Pydantic v2 schema, persists `skills`/`lsp`/`description` in the config
blob, and compiles graph actors with proper metadata.
`ReactiveConfigParser._build_from_v3()` now uses correct `source`/`target`
edge keys (fixing `KeyError` in `to_graph_config()`), handles `config: null`
nodes without crashing, propagates `context_view`/`memory`/`context`/
`env_vars`/`response_format`/`lsp_capabilities`/`lsp_context_enrichment`
into agent configs, and validates `entry_node` against the nodes map.
Exception handling narrowed from broad `except Exception` to specific
`NotFoundError` and `ActorCompilationError`. v3 registration logic
extracted to `v3_registry.py` to keep `registry.py` under the 500-line
limit. 19 BDD scenarios cover all v3 paths including tool actors,
update mode, LSP dict bindings, and field propagation.
- **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in
`features/environment.py` now emits its non-assertion exception guard warning to
both the structured logger and `stderr` via a new `_warning_with_stderr` helper.
+4 -4
View File
@@ -34,8 +34,8 @@ Feature: ActorConfiguration.load_yaml_text and defensive paths
When I call load_yaml_text with YAML text "provider: openai\nmodel: gpt-4\n"
Then the load_yaml_text result should have key "provider" equal to "openai"
# --- _load_v2_yaml_content: interpolation returns non-dict (line 127) ---
Scenario: _load_v2_yaml_content raises ValueError when interpolation returns non-dict
Given _interpolate_env_vars is patched to return a list
When I call _load_v2_yaml_content with valid YAML via patched interpolation
# --- load_yaml_text: interpolation returns non-dict (line 89) ---
Scenario: load_yaml_text raises ValueError when interpolation returns non-dict
Given interpolate_env_vars is patched to return a list
When I call load_yaml_text with valid YAML via patched interpolation
Then a ValueError with message containing "object" should be raised
+245
View File
@@ -0,0 +1,245 @@
Feature: v3 Actor YAML schema support in CLI and registry
As a developer using v3 actor YAML configs
I want the CLI and registry to validate, persist, and execute v3 schema actors
So that spec-compliant actors with skills, LSP bindings, and LangGraph routes work
Scenario: ActorConfiguration.from_blob extracts provider and model from v3 LLM YAML
Given a v3 LLM actor blob with model "gpt-4"
When I call ActorConfiguration.from_blob with the v3 blob
Then the configuration should have provider "custom"
And the configuration should have model "gpt-4"
Scenario: ActorConfiguration.from_blob infers provider from model with slash
Given a v3 LLM actor blob with model "openai/gpt-4"
When I call ActorConfiguration.from_blob with the v3 blob
Then the configuration should have provider "openai"
And the configuration should have model "openai/gpt-4"
Scenario: ActorConfiguration.from_blob infers custom provider for slash-prefixed model
Given a v3 LLM actor blob with model "/gpt-4"
When I call ActorConfiguration.from_blob with the v3 blob
Then the configuration should have provider "custom"
And the configuration should have model "/gpt-4"
Scenario: ActorConfiguration.from_blob builds graph descriptor for graph actors
Given a v3 graph actor blob with a route block
When I call ActorConfiguration.from_blob with the v3 blob
Then the configuration should have a graph_descriptor with type "graph"
Scenario: ActorConfiguration.from_blob falls through to v2 for legacy YAML
Given a v2 actor blob with agents and routes
When I call ActorConfiguration.from_blob with the v2 blob
Then the configuration should have provider "openai"
And the configuration should have model "gpt-4"
Scenario: ActorRegistry.add validates v3 LLM actor YAML
Given a mock actor service for v3 testing
And a v3 LLM actor YAML text
When I call ActorRegistry.add with the v3 YAML
Then the actor should be persisted with v3 fields in config_blob
And the persisted config_blob should contain skills
And the persisted config_blob should contain lsp bindings
Scenario: ActorRegistry.add rejects v3 YAML without required description
Given a mock actor service for v3 testing
And a v3 actor YAML text missing description
When I call ActorRegistry.add with the invalid v3 YAML
Then a ValidationError should be raised mentioning description
Scenario: ActorRegistry.add validates and compiles v3 graph actor
Given a mock actor service for v3 testing
And a v3 graph actor YAML text with route
When I call ActorRegistry.add with the v3 graph YAML
Then the actor should be persisted with compiled metadata
And the graph_descriptor should contain route information
Scenario: ReactiveConfigParser handles v3 LLM actor format
Given a v3 LLM actor config dict
When I parse the v3 config through ReactiveConfigParser
Then the reactive config should have one agent
And the agent config should contain the model
And the agent config should contain skills
Scenario: ReactiveConfigParser handles v3 graph actor format
Given a v3 graph actor config dict with route
When I parse the v3 config through ReactiveConfigParser
Then the reactive config should have agents for each node
And the reactive config should have a graph route
And the graph route edges should use source and target keys
Scenario: v3 format detection returns false for v2 data
Given a v2 actor config dict with agents key
When I check if the config is v3 format
Then it should not be detected as v3
# M14: test update=True path
Scenario: ActorRegistry.add with update=True overwrites existing actor
Given a mock actor service for v3 testing
And an existing actor in the mock service
And a v3 LLM actor YAML text
When I call ActorRegistry.add with update=True for the v3 YAML
Then the actor should be persisted successfully with update
# M15: test type:tool extraction and parsing paths
# from_blob requires model for ActorConfiguration — tool actors without
# model raise ValueError. The proper v3 path is ActorRegistry.add().
Scenario: ActorConfiguration.from_blob rejects type tool without model
Given a v3 tool actor blob without model
When I call ActorConfiguration.from_blob with the v3 tool blob
Then the tool actor from_blob should raise ValueError for missing model
Scenario: ActorRegistry.add validates v3 tool actor YAML
Given a mock actor service for v3 testing
And a v3 tool actor YAML text
When I call ActorRegistry.add with the v3 tool YAML
Then the tool actor should be persisted with type tool
# m13: test _build_from_v3 with lsp as dict
Scenario: ReactiveConfigParser handles v3 LLM actor with lsp as dict
Given a v3 LLM actor config dict with lsp as dict
When I parse the v3 config through ReactiveConfigParser
Then the reactive config should have one agent
And the agent config should contain lsp as dict
# m13: test context_view, memory, env_vars, response_format propagation
Scenario: ReactiveConfigParser propagates context_view memory env_vars and response_format
Given a v3 LLM actor config dict with context_view and memory
When I parse the v3 config through ReactiveConfigParser
Then the reactive config should have one agent
And the agent config should contain context_view
And the agent config should contain memory settings
And the agent config should contain env_vars
And the agent config should contain response_format
# m13: positive _is_v3_format test
Scenario: v3 format detection returns true for v3 LLM data
Given a v3 LLM actor config dict
When I check if the v3 LLM config is v3 format
Then it should be detected as v3
# Cycle 2 review: defensive guard BDD coverage
Scenario: ReactiveConfigParser rejects graph with invalid entry_node
Given a v3 graph actor config dict with invalid entry_node
When I parse the v3 config through ReactiveConfigParser expecting error
Then a ConfigurationError should be raised mentioning entry_node
Scenario: ReactiveConfigParser skips edges with missing from_node or to_node
Given a v3 graph actor config dict with incomplete edges
When I parse the v3 config through ReactiveConfigParser
Then the graph route should have fewer edges than the raw input
Scenario: ReactiveConfigParser skips non-dict edge entries
Given a v3 graph actor config dict with non-dict edges
When I parse the v3 config through ReactiveConfigParser
Then the graph route should have only valid dict edges
# Coverage: v3_registry.py — name without slash gets local/ prefix
Scenario: ActorRegistry.add namespaces bare actor name with local prefix
Given a mock actor service for v3 testing
And a v3 LLM actor YAML text with bare name
When I call ActorRegistry.add with the bare-name v3 YAML
Then the persisted actor name should be prefixed with local
# Coverage: v3_registry.py — unsafe actor rejected without allow_unsafe
Scenario: ActorRegistry.add rejects unsafe v3 actor without allow_unsafe flag
Given a mock actor service for v3 testing
And a v3 LLM actor YAML text marked unsafe
When I call ActorRegistry.add with the unsafe v3 YAML
Then a ValidationError should be raised mentioning unsafe
# Coverage: v3_registry.py — duplicate actor rejected when update=False
Scenario: ActorRegistry.add rejects duplicate v3 actor when update is False
Given a mock actor service for v3 testing
And an existing actor in the mock service
And a v3 LLM actor YAML text
When I call ActorRegistry.add with the v3 YAML expecting error
Then a ValidationError should be raised mentioning already exists
# Coverage: v3_registry.py — ActorCompilationError during graph compilation
Scenario: ActorRegistry.add persists graph actor even when compilation fails
Given a mock actor service for v3 testing
And a v3 graph actor YAML text with route
When I call ActorRegistry.add with the v3 graph YAML and compilation fails
Then the actor should still be persisted without compiled metadata
# Coverage: registry.py — _ensure_namespaced via get()
Scenario: ActorRegistry.get namespaces bare actor name with local prefix
Given a mock actor service for v3 testing
When I call ActorRegistry.get with a bare actor name
Then the actor service should be queried with local-prefixed name
# Coverage: registry.py — add() with non-dict YAML
Scenario: ActorRegistry.add rejects non-mapping YAML
Given a mock actor service for v3 testing
When I call ActorRegistry.add with a non-mapping YAML string
Then a ValidationError should be raised mentioning mapping
# Coverage: registry.py — _add_legacy v3 schema validation failure
Scenario: ActorRegistry._add_legacy rejects invalid v3 YAML via schema validation
Given a mock actor service for v3 testing
And a legacy v3 YAML text with invalid schema
When I call ActorRegistry.add with the legacy invalid v3 YAML
Then a ValidationError should be raised mentioning invalid v3 actor
# Coverage: registry.py — _add_legacy missing provider/model in non-v3 blob
Scenario: ActorRegistry._add_legacy rejects non-v3 blob missing provider and model
Given a mock actor service for v3 testing
And a legacy non-v3 YAML text missing provider and model
When I call ActorRegistry.add with the legacy non-v3 YAML
Then a ValidationError should be raised mentioning provider and model
# Coverage: registry.py — _add_legacy unsafe flag rejection
Scenario: ActorRegistry._add_legacy rejects unsafe legacy actor without allow_unsafe
Given a mock actor service for v3 testing
And a legacy actor YAML text marked unsafe
When I call ActorRegistry.add with the legacy unsafe YAML
Then a ValidationError should be raised mentioning unsafe
# Coverage: registry.py — upsert_actor v3 validation failure
Scenario: ActorRegistry.upsert_actor rejects invalid v3 config_blob
Given a mock actor service for v3 testing
When I call ActorRegistry.upsert_actor with an invalid v3 config_blob
Then a ValidationError should be raised mentioning invalid v3 actor
# Coverage: config_parser.py — _merge with new=None
Scenario: ReactiveConfigParser merge handles None new dict gracefully
When I merge a None dict into a base config
Then the base config should remain unchanged
# Coverage: config_parser.py — missing model for llm actor
Scenario: ReactiveConfigParser rejects v3 LLM actor with missing model
Given a v3 LLM actor config dict with no model field
When I parse the v3 config through ReactiveConfigParser expecting error
Then a ConfigurationError should be raised mentioning model
# Coverage: config_parser.py — lsp_capabilities and lsp_context_enrichment propagation
Scenario: ReactiveConfigParser propagates lsp_capabilities and lsp_context_enrichment
Given a v3 LLM actor config dict with lsp_capabilities and lsp_context_enrichment
When I parse the v3 config through ReactiveConfigParser
Then the agent config should contain lsp_capabilities
And the agent config should contain lsp_context_enrichment
# Coverage: config_parser.py — graph actor without route raises ConfigurationError
Scenario: ReactiveConfigParser rejects v3 graph actor without route
Given a v3 graph actor config dict without route
When I parse the v3 config through ReactiveConfigParser expecting error
Then a ConfigurationError should be raised mentioning route
# Coverage: config_parser.py — non-dict node and empty-id node skipped in graph
Scenario: ReactiveConfigParser skips non-dict and empty-id nodes in graph route
Given a v3 graph actor config dict with malformed nodes
When I parse the v3 config through ReactiveConfigParser
Then the reactive config should only have agents for valid nodes
# Coverage: config_parser.py — skills and lsp propagated to graph nodes
Scenario: ReactiveConfigParser propagates skills and lsp bindings to graph nodes
Given a v3 graph actor config dict with skills and lsp
When I parse the v3 config through ReactiveConfigParser
Then each graph node agent should have skills propagated
And each graph node agent should have lsp propagated
# Coverage: config_parser.py — lsp as dict propagated to graph nodes (line 330)
Scenario: ReactiveConfigParser propagates lsp dict bindings to graph nodes
Given a v3 graph actor config dict with skills and lsp as dict
When I parse the v3 config through ReactiveConfigParser
Then each graph node agent should have lsp dict propagated
@@ -1,8 +1,8 @@
"""Step definitions for actor_config_coverage_boost.feature.
Targets uncovered lines in src/cleveragents/actor/config.py:
Targets uncovered lines in src/cleveragents/actor/yaml_loader.py:
- Lines 50-54: load_yaml_text JSON else-branch (null, non-dict, valid dict)
- Line 127: _load_v2_yaml_content defensive check after interpolation
- Line 89: load_yaml_text defensive check after interpolation
"""
from __future__ import annotations
@@ -13,6 +13,7 @@ from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from cleveragents.actor.config import ActorConfiguration
from cleveragents.actor.yaml_loader import load_yaml_text
# Module-level variable to hold the mock patch (avoids behave Context delattr issues)
_active_patch = None
@@ -106,26 +107,25 @@ def step_load_yaml_text_yaml_fallback(context: Context, yaml_text: str) -> None:
context.yaml_result = ActorConfiguration.load_yaml_text(real_text)
# ── _load_v2_yaml_content: interpolation returns non-dict (line 127) ─
# ── load_yaml_text: interpolation returns non-dict (line 89) ─
@given("_interpolate_env_vars is patched to return a list")
@given("interpolate_env_vars is patched to return a list")
def step_patch_interpolate(context: Context) -> None:
global _active_patch
_active_patch = patch.object(
ActorConfiguration,
"_interpolate_env_vars",
staticmethod(lambda config: ["not", "a", "dict"]),
_active_patch = patch(
"cleveragents.actor.yaml_loader.interpolate_env_vars",
lambda config: ["not", "a", "dict"],
)
_active_patch.start()
@when("I call _load_v2_yaml_content with valid YAML via patched interpolation")
@when("I call load_yaml_text with valid YAML via patched interpolation")
def step_call_load_v2_patched(context: Context) -> None:
global _active_patch
context.caught_error = None
try:
ActorConfiguration._load_v2_yaml_content("key: value\n")
load_yaml_text("key: value\n")
except ValueError as exc:
context.caught_error = exc
finally:
@@ -12,6 +12,16 @@ from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from cleveragents.actor.config import ActorConfiguration
from cleveragents.actor.yaml_loader import (
_restore_template_syntax,
interpolate_env_vars,
load_yaml_text,
)
from cleveragents.actor.yaml_loader import (
_restore_template_syntax,
interpolate_env_vars,
load_yaml_text,
)
@then('an actor config ValueError should mention "{text}"')
@@ -113,7 +123,7 @@ def step_load_blob_yaml(context: Context) -> None:
@when("I load v2 YAML content without templates")
def step_load_v2_plain(context: Context) -> None:
text = "provider: openai\nmodel: gpt-4\n"
context.result = ActorConfiguration._load_v2_yaml_content(text)
context.result = load_yaml_text(text)
@then("the v2 result should be a dict with expected keys")
@@ -124,7 +134,7 @@ def step_assert_dict_expected_keys(context: Context) -> None:
@when("I load v2 YAML content that is empty")
def step_load_v2_empty(context: Context) -> None:
context.result = ActorConfiguration._load_v2_yaml_content("")
context.result = load_yaml_text("")
@then("the v2 result should be an empty dict")
@@ -136,7 +146,7 @@ def step_assert_empty_result(context: Context) -> None:
def step_load_v2_list(context: Context) -> None:
context.error = None
try:
ActorConfiguration._load_v2_yaml_content("- item1\n- item2\n")
load_yaml_text("- item1\n- item2\n")
except ValueError as exc:
context.error = exc
@@ -144,7 +154,7 @@ def step_load_v2_list(context: Context) -> None:
@when("I load v2 YAML content with Jinja2 templates")
def step_load_v2_templated(context: Context) -> None:
text = 'provider: openai\nmodel: gpt-4\nsystem_prompt: "Hello {{ context.name }}"\n'
context.result = ActorConfiguration._load_v2_yaml_content(text)
context.result = load_yaml_text(text)
@then("the actor config result should be a dict")
@@ -158,7 +168,7 @@ def step_restore_markers(context: Context) -> None:
"system_prompt": "Hello <<<TEMPLATE_START>>>name<<<TEMPLATE_END>>>",
"other": "<<<BLOCK_START>>> for x <<<BLOCK_END>>>",
}
context.result = ActorConfiguration._restore_template_syntax(config)
context.result = _restore_template_syntax(config)
@then("the system_prompt should have Jinja2 syntax restored")
@@ -173,7 +183,7 @@ def step_restore_list(context: Context) -> None:
{"system_prompt": "<<<TEMPLATE_START>>>x<<<TEMPLATE_END>>>"},
"plain",
]
context.result = ActorConfiguration._restore_template_syntax(config)
context.result = _restore_template_syntax(config)
@then("nested list items should have markers restored")
@@ -193,7 +203,7 @@ def step_set_env_var(context: Context, name: str, value: str) -> None:
@when('I interpolate env vars in config with "${{{var_expr}}}"')
def step_interpolate_env(context: Context, var_expr: str) -> None:
config = f"${{{var_expr}}}"
context.result = ActorConfiguration._interpolate_env_vars(config)
context.result = interpolate_env_vars(config)
@then('the interpolated value should be "{expected}"')
@@ -208,14 +218,14 @@ def step_interpolate_no_default(context: Context, var_expr: str) -> None:
config = f"${{{var_expr}}}"
context.error = None
try:
ActorConfiguration._interpolate_env_vars(config)
interpolate_env_vars(config)
except ValueError as exc:
context.error = exc
@when('I interpolate env vars for string "{value}"')
def step_interpolate_literal(context: Context, value: str) -> None:
context.result = ActorConfiguration._interpolate_env_vars(value)
context.result = interpolate_env_vars(value)
@then("the interpolated result should be boolean True")
@@ -247,7 +257,7 @@ def step_interpolate_nested(context: Context) -> None:
"items": ["first", "second"],
"nested": {"key": "value"},
}
context.result = ActorConfiguration._interpolate_env_vars(config)
context.result = interpolate_env_vars(config)
@then("all nested string values should be interpolated")
@@ -384,4 +394,4 @@ def step_from_file(context: Context) -> None:
@when('I interpolate env vars with "${{{var_expr}}}"')
def step_interpolate_with_default(context: Context, var_expr: str) -> None:
config = f"${{{var_expr}}}"
context.result = ActorConfiguration._interpolate_env_vars(config)
context.result = interpolate_env_vars(config)
+5 -4
View File
@@ -13,7 +13,6 @@ from typing import Any
from behave import given, then, when
import cleveragents.actor.config as actor_config
from cleveragents.actor.config import ActorConfiguration
@@ -65,11 +64,13 @@ def step_actor_config_file_with_content(context, filename: str) -> None:
@given("PyYAML parsing is unavailable for actor config")
def step_disable_yaml(context) -> None:
context._original_yaml_module = actor_config.yaml
actor_config.yaml = None
import cleveragents.actor.yaml_loader as yaml_loader
context._original_yaml_module = yaml_loader.yaml
yaml_loader.yaml = None
def restore_yaml() -> None:
actor_config.yaml = context._original_yaml_module
yaml_loader.yaml = context._original_yaml_module
_add_cleanup(context, restore_yaml)
@@ -74,7 +74,11 @@ class _StubActorService:
def get_actor(self, name: str) -> Actor:
if name not in self.actors:
raise NotFoundError(f"Actor '{name}' not found")
raise NotFoundError(
f"Actor '{name}' not found",
resource_type="actor",
resource_id=name,
)
return self.actors[name]
def list_actors(self) -> list[Actor]:
@@ -82,7 +86,11 @@ class _StubActorService:
def remove_actor(self, name: str) -> None:
if name not in self.actors:
raise NotFoundError(f"Actor '{name}' not found")
raise NotFoundError(
f"Actor '{name}' not found",
resource_type="actor",
resource_id=name,
)
del self.actors[name]
if self.default_actor_name == name:
self.default_actor_name = None
@@ -0,0 +1,856 @@
# pyright: reportRedeclaration=false
"""Extended step definitions for v3 Actor YAML schema support.
Covers tool-actor extraction, update-mode overwriting, LSP-as-dict
bindings, and field-propagation scenarios (context_view, memory,
env_vars, response_format). Core scenarios live in
``actor_v3_schema_steps.py``.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import patch
import yaml
from behave import given, then, when
from cleveragents.actor.compiler import ActorCompilationError
from cleveragents.actor.config import ActorConfiguration
from cleveragents.core.exceptions import (
ConfigurationError,
ValidationError,
)
from cleveragents.domain.models.core.actor import Actor
from cleveragents.reactive.config_parser import ReactiveConfigParser
def _make_actor_ext(
*,
name: str = "local/test-actor",
provider: str = "custom",
model: str = "gpt-4",
config_blob: dict[str, Any] | None = None,
) -> Actor:
blob = config_blob or {}
return Actor(
id=1,
name=name,
provider=provider,
model=model,
config_blob=blob,
config_hash=Actor.compute_hash(blob),
graph_descriptor=None,
unsafe=False,
is_built_in=False,
is_default=False,
yaml_text=None,
schema_version="1.0",
compiled_metadata=None,
)
# ── Given steps ──────────────────────────────────────────────────────────
@given("a v3 tool actor blob without model")
def step_v3_tool_blob_no_model(context: Any) -> None:
# Tool actors may omit model in v3 schema, but from_blob requires
# a model for ActorConfiguration. The v3 extraction returns
# provider="custom" and model="" which from_blob treats as missing.
# The proper v3 path goes through ActorRegistry.add() / add_v3().
context.v3_blob = {
"name": "local/test-tool",
"type": "tool",
"description": "A test tool actor",
"tools": ["local/file-ops"],
}
@given("a v3 tool actor YAML text")
def step_v3_tool_yaml_text(context: Any) -> None:
context.v3_tool_yaml_text = yaml.safe_dump(
{
"name": "local/test-tool",
"type": "tool",
"description": "A test tool actor",
"tools": ["local/file-ops"],
}
)
@given("a v3 LLM actor config dict with lsp as dict")
def step_v3_llm_config_dict_lsp_dict(context: Any) -> None:
context.v3_config = {
"name": "local/test-llm",
"type": "llm",
"description": "A test LLM actor",
"model": "gpt-4",
"lsp": {"auto": True, "languages": ["python"]},
}
@given("a v3 LLM actor config dict with context_view and memory")
def step_v3_llm_config_with_context_view(context: Any) -> None:
context.v3_config = {
"name": "local/test-llm",
"type": "llm",
"description": "A test LLM actor",
"model": "gpt-4",
"context_view": "executor",
"memory": {"enabled": True, "max_messages": 50},
"context": {"include_files": ["README.md"]},
"env_vars": {"API_KEY": "test"},
"response_format": {"type": "object"},
}
# ── When steps ───────────────────────────────────────────────────────────
@when("I call ActorRegistry.add with update=True for the v3 YAML")
def step_call_registry_add_v3_update(context: Any) -> None:
context.result_actor = context.registry.add(context.v3_yaml_text, update=True)
context.upsert_kwargs = context.mock_actor_service.upsert_actor.call_args
@when("I call ActorConfiguration.from_blob with the v3 tool blob")
def step_call_from_blob_v3_tool(context: Any) -> None:
# from_blob requires model for ActorConfiguration, so tool actors
# without model will raise ValueError. This is expected — the
# proper v3 path is through ActorRegistry.add() / add_v3().
try:
context.actor_config = ActorConfiguration.from_blob(
blob=context.v3_blob,
name="local/test",
)
context.from_blob_error = None
except ValueError as exc:
context.from_blob_error = exc
context.actor_config = None
@when("I call ActorRegistry.add with the v3 tool YAML")
def step_call_registry_add_v3_tool(context: Any) -> None:
context.result_actor = context.registry.add(context.v3_tool_yaml_text)
context.upsert_kwargs = context.mock_actor_service.upsert_actor.call_args
@when("I check if the v3 LLM config is v3 format")
def step_check_v3_llm_format(context: Any) -> None:
context.is_v3 = ReactiveConfigParser._is_v3_format(context.v3_config)
# ── Then steps ───────────────────────────────────────────────────────────
@then("the actor should be persisted successfully with update")
def step_actor_persisted_update(context: Any) -> None:
assert context.result_actor is not None, "Expected actor to be persisted"
call_kwargs = context.upsert_kwargs
assert call_kwargs is not None, "upsert_actor was not called"
# When update=True the duplicate-check branch (get_actor) is skipped,
# so the actor is upserted directly without raising "already exists".
# Verify the persisted blob contains expected v3 fields.
_, kwargs = call_kwargs
blob = kwargs.get("config_blob", {})
assert blob.get("type") == "llm", (
f"Expected type 'llm' in config_blob, got '{blob.get('type')}'"
)
assert kwargs.get("name") == "local/test-llm", (
f"Expected name 'local/test-llm', got '{kwargs.get('name')}'"
)
@then("the tool actor from_blob should raise ValueError for missing model")
def step_tool_from_blob_raises(context: Any) -> None:
assert context.from_blob_error is not None, (
"Expected ValueError for tool actor without model in from_blob"
)
error_msg = str(context.from_blob_error).lower()
assert "model" in error_msg, (
f"Error should mention 'model': {context.from_blob_error}"
)
# Verify the error message guides users to the correct registration path.
assert "actorregistry.add" in error_msg or "agents actor add" in error_msg, (
f"Error should mention ActorRegistry.add() or 'agents actor add': "
f"{context.from_blob_error}"
)
@then("the tool actor should be persisted with type tool")
def step_tool_actor_persisted(context: Any) -> None:
call_kwargs = context.upsert_kwargs
assert call_kwargs is not None, "upsert_actor was not called"
_, kwargs = call_kwargs
blob = kwargs.get("config_blob", {})
assert blob.get("type") == "tool", (
f"Expected type 'tool' in config_blob, got '{blob.get('type')}'"
)
@then("the agent config should contain lsp as dict")
def step_agent_config_has_lsp_dict(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
lsp = agent.config.get("lsp")
assert isinstance(lsp, dict), f"Expected lsp as dict, got {type(lsp)}"
assert lsp.get("auto") is True, f"Expected lsp.auto=True, got {lsp}"
@then("the agent config should contain context_view")
def step_agent_config_has_context_view(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
assert agent.config.get("context_view") == "executor", (
f"Expected context_view 'executor', got '{agent.config.get('context_view')}'"
)
@then("the agent config should contain memory settings")
def step_agent_config_has_memory(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
memory = agent.config.get("memory")
assert isinstance(memory, dict), f"Expected memory as dict, got {type(memory)}"
assert memory.get("enabled") is True
@then("the agent config should contain env_vars")
def step_agent_config_has_env_vars(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
env_vars = agent.config.get("env_vars")
assert isinstance(env_vars, dict), (
f"Expected env_vars as dict, got {type(env_vars)}"
)
assert env_vars.get("API_KEY") == "test"
@then("the agent config should contain response_format")
def step_agent_config_has_response_format(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
rf = agent.config.get("response_format")
assert isinstance(rf, dict), f"Expected response_format as dict, got {type(rf)}"
assert rf.get("type") == "object"
@then("it should be detected as v3")
def step_is_v3(context: Any) -> None:
assert context.is_v3 is True, "Expected v3 data to be detected as v3"
# ── Cycle 2 review: defensive guard BDD steps ───────────────────────────
@given("a v3 graph actor config dict with invalid entry_node")
def step_v3_graph_invalid_entry(context: Any) -> None:
context.v3_config = {
"name": "local/test-graph",
"type": "graph",
"description": "Graph with bad entry_node",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "planner",
"type": "agent",
"name": "Planner",
"description": "Plans",
"config": {},
},
],
"edges": [],
"entry_node": "nonexistent_node",
"exit_nodes": ["planner"],
},
}
@given("a v3 graph actor config dict with incomplete edges")
def step_v3_graph_incomplete_edges(context: Any) -> None:
context.v3_config = {
"name": "local/test-graph",
"type": "graph",
"description": "Graph with incomplete edges",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "planner",
"type": "agent",
"name": "Planner",
"description": "Plans",
"config": {},
},
{
"id": "executor",
"type": "tool",
"name": "Executor",
"description": "Executes",
"config": {},
},
],
"edges": [
{"from_node": "planner", "to_node": "executor"},
{"from_node": "planner", "to_node": ""},
{"from_node": "", "to_node": "executor"},
],
"entry_node": "planner",
"exit_nodes": ["executor"],
},
}
context.raw_edge_count = 3
@given("a v3 graph actor config dict with non-dict edges")
def step_v3_graph_non_dict_edges(context: Any) -> None:
context.v3_config = {
"name": "local/test-graph",
"type": "graph",
"description": "Graph with non-dict edges",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "planner",
"type": "agent",
"name": "Planner",
"description": "Plans",
"config": {},
},
{
"id": "executor",
"type": "tool",
"name": "Executor",
"description": "Executes",
"config": {},
},
],
"edges": [
{"from_node": "planner", "to_node": "executor"},
"not-a-dict",
42,
],
"entry_node": "planner",
"exit_nodes": ["executor"],
},
}
@when("I parse the v3 config through ReactiveConfigParser expecting error")
def step_parse_v3_config_error(context: Any) -> None:
parser = ReactiveConfigParser()
try:
parser._build(context.v3_config)
context.parse_error = None
except ConfigurationError as exc:
context.parse_error = exc
@then("a ConfigurationError should be raised mentioning entry_node")
def step_config_error_entry_node(context: Any) -> None:
assert context.parse_error is not None, "Expected a ConfigurationError to be raised"
msg = str(context.parse_error).lower()
assert "entry_node" in msg, (
f"Error should mention 'entry_node': {context.parse_error}"
)
@then("the graph route should have fewer edges than the raw input")
def step_fewer_edges(context: Any) -> None:
routes = context.reactive_config.routes
route = next(iter(routes.values()))
assert len(route.edges) < context.raw_edge_count, (
f"Expected fewer edges than {context.raw_edge_count}, got {len(route.edges)}"
)
assert len(route.edges) == 1, (
f"Expected exactly 1 valid edge, got {len(route.edges)}"
)
@then("the graph route should have only valid dict edges")
def step_only_valid_edges(context: Any) -> None:
routes = context.reactive_config.routes
route = next(iter(routes.values()))
assert len(route.edges) == 1, (
f"Expected 1 valid edge (non-dict filtered), got {len(route.edges)}"
)
# ── Coverage gap: v3_registry.py ─────────────────────────────────────────
@given("a v3 LLM actor YAML text with bare name")
def step_v3_llm_yaml_bare_name(context: Any) -> None:
context.v3_yaml_bare = yaml.safe_dump(
{
"name": "bare-actor", # no namespace slash — should get local/ prefix
"type": "llm",
"description": "A bare-named LLM actor",
"model": "gpt-4",
}
)
@given("a v3 LLM actor YAML text marked unsafe")
def step_v3_llm_yaml_unsafe(context: Any) -> None:
context.v3_yaml_unsafe = yaml.safe_dump(
{
"name": "local/unsafe-actor",
"type": "llm",
"description": "An unsafe LLM actor",
"model": "gpt-4",
"unsafe": True,
}
)
@when("I call ActorRegistry.add with the bare-name v3 YAML")
def step_call_registry_add_bare_name(context: Any) -> None:
context.result_actor = context.registry.add(context.v3_yaml_bare)
context.upsert_kwargs = context.mock_actor_service.upsert_actor.call_args
@when("I call ActorRegistry.add with the unsafe v3 YAML")
def step_call_registry_add_unsafe(context: Any) -> None:
try:
context.registry.add(context.v3_yaml_unsafe)
context.add_error = None
except ValidationError as exc:
context.add_error = exc
@when("I call ActorRegistry.add with the v3 YAML expecting error")
def step_call_registry_add_v3_expect_error(context: Any) -> None:
try:
context.registry.add(context.v3_yaml_text)
context.add_error = None
except ValidationError as exc:
context.add_error = exc
@when("I call ActorRegistry.add with the v3 graph YAML and compilation fails")
def step_call_registry_add_v3_graph_compile_fail(context: Any) -> None:
with patch(
"cleveragents.actor.v3_registry.compile_actor",
side_effect=ActorCompilationError("simulated compilation failure"),
):
context.result_actor = context.registry.add(context.v3_graph_yaml_text)
context.upsert_kwargs = context.mock_actor_service.upsert_actor.call_args
@then("the persisted actor name should be prefixed with local")
def step_actor_name_has_local_prefix(context: Any) -> None:
call_kwargs = context.upsert_kwargs
assert call_kwargs is not None, "upsert_actor was not called"
_, kwargs = call_kwargs
name = kwargs.get("name", "")
assert name.startswith("local/"), (
f"Expected name to start with 'local/', got '{name}'"
)
@then("a ValidationError should be raised mentioning unsafe")
def step_validation_error_unsafe(context: Any) -> None:
assert context.add_error is not None, "Expected a ValidationError to be raised"
msg = str(context.add_error).lower()
assert "unsafe" in msg, f"Error should mention 'unsafe': {context.add_error}"
@then("a ValidationError should be raised mentioning already exists")
def step_validation_error_already_exists(context: Any) -> None:
assert context.add_error is not None, "Expected a ValidationError to be raised"
msg = str(context.add_error).lower()
assert "already exists" in msg, (
f"Error should mention 'already exists': {context.add_error}"
)
@then("the actor should still be persisted without compiled metadata")
def step_actor_persisted_no_compiled_metadata(context: Any) -> None:
assert context.result_actor is not None, "Expected actor to be persisted"
call_kwargs = context.upsert_kwargs
assert call_kwargs is not None, "upsert_actor was not called"
_, kwargs = call_kwargs
# compiled_metadata should be None because compilation failed
assert kwargs.get("compiled_metadata") is None, (
f"Expected compiled_metadata=None after compilation failure, "
f"got {kwargs.get('compiled_metadata')}"
)
# ── Coverage gap: registry.py ─────────────────────────────────────────────
@when("I call ActorRegistry.get with a bare actor name")
def step_call_registry_get_bare(context: Any) -> None:
context.mock_actor_service.get_actor.side_effect = None
context.mock_actor_service.get_actor.return_value = _make_actor_ext(
name="local/bare-actor"
)
context.registry.get("bare-actor")
@then("the actor service should be queried with local-prefixed name")
def step_actor_service_queried_local(context: Any) -> None:
call_args = context.mock_actor_service.get_actor.call_args
assert call_args is not None, "get_actor was not called"
args, _ = call_args
queried_name = args[0] if args else ""
assert queried_name == "local/bare-actor", (
f"Expected 'local/bare-actor', got '{queried_name}'"
)
@when("I call ActorRegistry.add with a non-mapping YAML string")
def step_call_registry_add_non_mapping(context: Any) -> None:
# Patch load_yaml_text to return a list so the isinstance guard fires.
with patch(
"cleveragents.actor.registry.ActorConfiguration.load_yaml_text",
return_value=["not", "a", "dict"],
):
try:
context.registry.add("irrelevant")
context.add_error = None
except ValidationError as exc:
context.add_error = exc
@then("a ValidationError should be raised mentioning mapping")
def step_validation_error_mapping(context: Any) -> None:
assert context.add_error is not None, "Expected a ValidationError to be raised"
msg = str(context.add_error).lower()
assert "mapping" in msg, f"Error should mention 'mapping': {context.add_error}"
@given("a legacy v3 YAML text with invalid schema")
def step_legacy_v3_yaml_invalid_schema(context: Any) -> None:
# is_v3_yaml detects version starting with "3" or non-null type field.
# Provide a blob that triggers the v3 validation path but fails schema.
context.legacy_v3_yaml_invalid = yaml.safe_dump(
{
"name": "local/bad-v3",
"version": "3.0",
"type": "invalid_type_value", # not llm/graph/tool — fails ActorConfigSchema
"provider": "openai",
"model": "gpt-4",
}
)
@when("I call ActorRegistry.add with the legacy invalid v3 YAML")
def step_call_registry_add_legacy_invalid_v3(context: Any) -> None:
try:
context.registry.add(context.legacy_v3_yaml_invalid)
context.add_error = None
except ValidationError as exc:
context.add_error = exc
@then("a ValidationError should be raised mentioning invalid v3 actor")
def step_validation_error_invalid_v3(context: Any) -> None:
assert context.add_error is not None, "Expected a ValidationError to be raised"
msg = str(context.add_error).lower()
assert "invalid" in msg or "v3" in msg or "validation" in msg, (
f"Error should mention invalid v3 actor: {context.add_error}"
)
@given("a legacy non-v3 YAML text missing provider and model")
def step_legacy_non_v3_yaml_missing_fields(context: Any) -> None:
context.legacy_non_v3_yaml = yaml.safe_dump(
{
"name": "local/no-provider",
# no type field → not v3, no provider/model → should fail
}
)
@when("I call ActorRegistry.add with the legacy non-v3 YAML")
def step_call_registry_add_legacy_non_v3(context: Any) -> None:
try:
context.registry.add(context.legacy_non_v3_yaml)
context.add_error = None
except ValidationError as exc:
context.add_error = exc
@then("a ValidationError should be raised mentioning provider and model")
def step_validation_error_provider_model(context: Any) -> None:
assert context.add_error is not None, "Expected a ValidationError to be raised"
msg = str(context.add_error).lower()
assert "provider" in msg or "model" in msg, (
f"Error should mention 'provider' or 'model': {context.add_error}"
)
@given("a legacy actor YAML text marked unsafe")
def step_legacy_actor_yaml_unsafe(context: Any) -> None:
context.legacy_unsafe_yaml = yaml.safe_dump(
{
"name": "local/legacy-unsafe",
"provider": "openai",
"model": "gpt-4",
"unsafe": True,
}
)
@when("I call ActorRegistry.add with the legacy unsafe YAML")
def step_call_registry_add_legacy_unsafe(context: Any) -> None:
try:
context.registry.add(context.legacy_unsafe_yaml)
context.add_error = None
except ValidationError as exc:
context.add_error = exc
@when("I call ActorRegistry.upsert_actor with an invalid v3 config_blob")
def step_call_registry_upsert_invalid_v3(context: Any) -> None:
# Provide a blob that triggers is_v3_yaml() but fails ActorConfigSchema.
invalid_blob: dict[str, Any] = {
"name": "local/bad-v3",
"version": "3.0",
"type": "invalid_type_value",
"provider": "openai",
"model": "gpt-4",
}
try:
context.registry.upsert_actor(
name="local/bad-v3",
config_blob=invalid_blob,
provider="openai",
model="gpt-4",
)
context.add_error = None
except ValidationError as exc:
context.add_error = exc
# ── Coverage gap: config_parser.py ───────────────────────────────────────
@when("I merge a None dict into a base config")
def step_merge_none_dict(context: Any) -> None:
parser = ReactiveConfigParser()
base: dict[str, Any] = {"key": "value"}
parser._merge(base, None) # type: ignore[arg-type]
context.merge_base = base
@then("the base config should remain unchanged")
def step_base_config_unchanged(context: Any) -> None:
assert context.merge_base == {"key": "value"}, (
f"Expected base unchanged, got {context.merge_base}"
)
@given("a v3 LLM actor config dict with no model field")
def step_v3_llm_config_no_model(context: Any) -> None:
context.v3_config = {
"name": "local/no-model",
"type": "llm",
"description": "LLM actor without model",
# no model field
}
@then("a ConfigurationError should be raised mentioning model")
def step_config_error_model(context: Any) -> None:
assert context.parse_error is not None, "Expected a ConfigurationError to be raised"
msg = str(context.parse_error).lower()
assert "model" in msg, f"Error should mention 'model': {context.parse_error}"
@given("a v3 LLM actor config dict with lsp_capabilities and lsp_context_enrichment")
def step_v3_llm_config_lsp_caps(context: Any) -> None:
context.v3_config = {
"name": "local/test-llm",
"type": "llm",
"description": "LLM actor with LSP capabilities",
"model": "gpt-4",
"lsp_capabilities": ["hover", "completion"],
"lsp_context_enrichment": {"include_diagnostics": True},
}
@then("the agent config should contain lsp_capabilities")
def step_agent_config_has_lsp_caps(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
caps = agent.config.get("lsp_capabilities")
assert caps is not None, "Expected lsp_capabilities in agent config"
assert caps == ["hover", "completion"], f"Unexpected lsp_capabilities: {caps}"
@then("the agent config should contain lsp_context_enrichment")
def step_agent_config_has_lsp_enrichment(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
enrichment = agent.config.get("lsp_context_enrichment")
assert isinstance(enrichment, dict), (
f"Expected lsp_context_enrichment as dict, got {type(enrichment)}"
)
assert enrichment.get("include_diagnostics") is True
@given("a v3 graph actor config dict without route")
def step_v3_graph_config_no_route(context: Any) -> None:
context.v3_config = {
"name": "local/no-route-graph",
"type": "graph",
"description": "Graph actor without route",
"model": "gpt-4",
# no route key
}
@then("a ConfigurationError should be raised mentioning route")
def step_config_error_route(context: Any) -> None:
assert context.parse_error is not None, "Expected a ConfigurationError to be raised"
msg = str(context.parse_error).lower()
assert "route" in msg, f"Error should mention 'route': {context.parse_error}"
@given("a v3 graph actor config dict with malformed nodes")
def step_v3_graph_malformed_nodes(context: Any) -> None:
context.v3_config = {
"name": "local/test-graph",
"type": "graph",
"description": "Graph with malformed nodes",
"model": "gpt-4",
"route": {
"nodes": [
"not-a-dict", # non-dict node — should be skipped
{
"id": "",
"type": "agent",
"config": {},
}, # empty id — should be skipped
{
"id": "valid-node",
"type": "agent",
"name": "Valid",
"description": "Valid node",
"config": {},
},
],
"edges": [],
"entry_node": "valid-node",
},
}
@then("the reactive config should only have agents for valid nodes")
def step_reactive_only_valid_agents(context: Any) -> None:
agents = context.reactive_config.agents
assert "valid-node" in agents, (
f"Expected 'valid-node' in agents: {list(agents.keys())}"
)
# non-dict and empty-id nodes must not appear
assert len(agents) == 1, (
f"Expected exactly 1 agent (only valid-node), got {list(agents.keys())}"
)
@given("a v3 graph actor config dict with skills and lsp")
def step_v3_graph_config_with_skills_lsp(context: Any) -> None:
context.v3_config = {
"name": "local/test-graph",
"type": "graph",
"description": "Graph actor with skills and lsp",
"model": "gpt-4",
"skills": ["local/file-ops", "local/code-search"],
"lsp": ["local/pyright"],
"route": {
"nodes": [
{
"id": "planner",
"type": "agent",
"name": "Planner",
"description": "Plans",
"config": {},
},
{
"id": "executor",
"type": "tool",
"name": "Executor",
"description": "Executes",
"config": {},
},
],
"edges": [{"from_node": "planner", "to_node": "executor"}],
"entry_node": "planner",
},
}
@given("a v3 graph actor config dict with skills and lsp as dict")
def step_v3_graph_config_with_skills_lsp_dict(context: Any) -> None:
context.v3_config = {
"name": "local/test-graph",
"type": "graph",
"description": "Graph actor with skills and lsp as dict",
"model": "gpt-4",
"skills": ["local/file-ops"],
"lsp": {"auto": True, "languages": ["python"]}, # dict form — hits line 330
"route": {
"nodes": [
{
"id": "planner",
"type": "agent",
"name": "Planner",
"description": "Plans",
"config": {},
},
{
"id": "executor",
"type": "tool",
"name": "Executor",
"description": "Executes",
"config": {},
},
],
"edges": [{"from_node": "planner", "to_node": "executor"}],
"entry_node": "planner",
},
}
@then("each graph node agent should have skills propagated")
def step_graph_nodes_have_skills(context: Any) -> None:
agents = context.reactive_config.agents
assert "planner" in agents, f"Missing 'planner' agent: {list(agents.keys())}"
assert "executor" in agents, f"Missing 'executor' agent: {list(agents.keys())}"
for node_id, agent in agents.items():
skills = agent.config.get("skills", [])
assert "local/file-ops" in skills, (
f"Node '{node_id}' missing 'local/file-ops' in skills: {skills}"
)
@then("each graph node agent should have lsp propagated")
def step_graph_nodes_have_lsp(context: Any) -> None:
agents = context.reactive_config.agents
for node_id, agent in agents.items():
lsp = agent.config.get("lsp")
assert lsp is not None, f"Node '{node_id}' missing lsp binding"
assert "local/pyright" in lsp, (
f"Node '{node_id}' missing 'local/pyright' in lsp: {lsp}"
)
@then("each graph node agent should have lsp dict propagated")
def step_graph_nodes_have_lsp_dict(context: Any) -> None:
agents = context.reactive_config.agents
assert len(agents) >= 1, "Expected at least one agent"
for node_id, agent in agents.items():
lsp = agent.config.get("lsp")
assert isinstance(lsp, dict), (
f"Node '{node_id}' expected lsp as dict, got {type(lsp)}: {lsp}"
)
assert lsp.get("auto") is True, (
f"Node '{node_id}' expected lsp.auto=True, got {lsp}"
)
+486
View File
@@ -0,0 +1,486 @@
# pyright: reportRedeclaration=false
"""Step definitions for v3 Actor YAML schema support (core scenarios).
Extended scenarios (tool actors, update mode, LSP dict, context
propagation) are in ``actor_v3_schema_extended_steps.py``.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import yaml
from behave import given, then, when
from cleveragents.actor.compiler import CompilationMetadata, CompiledActor
from cleveragents.actor.config import ActorConfiguration
from cleveragents.actor.registry import ActorRegistry
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.domain.models.core.actor import Actor
from cleveragents.reactive.config_parser import ReactiveConfigParser
def _make_actor(
*,
name: str = "local/test-actor",
provider: str = "custom",
model: str = "gpt-4",
config_blob: dict[str, Any] | None = None,
yaml_text: str | None = None,
compiled_metadata: dict[str, Any] | None = None,
graph_descriptor: dict[str, Any] | None = None,
) -> Actor:
blob = config_blob or {}
return Actor(
id=1,
name=name,
provider=provider,
model=model,
config_blob=blob,
config_hash=Actor.compute_hash(blob),
graph_descriptor=graph_descriptor,
unsafe=False,
is_built_in=False,
is_default=False,
yaml_text=yaml_text,
schema_version="1.0",
compiled_metadata=compiled_metadata,
)
# ── Given steps ──────────────────────────────────────────────────────────
@given('a v3 LLM actor blob with model "{model}"')
def step_v3_llm_blob(context: Any, model: str) -> None:
context.v3_blob = {
"name": "local/test-llm",
"type": "llm",
"description": "A test LLM actor",
"model": model,
}
@given("a v3 graph actor blob with a route block")
def step_v3_graph_blob(context: Any) -> None:
context.v3_blob = {
"name": "local/test-graph",
"type": "graph",
"description": "A test graph actor",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "planner",
"type": "agent",
"name": "Planner",
"description": "Plans tasks",
"config": {"model": "gpt-4"},
},
{
"id": "executor",
"type": "tool",
"name": "Executor",
"description": "Executes tasks",
"config": {"tool_name": "exec/run"},
},
],
"edges": [{"from_node": "planner", "to_node": "executor"}],
"entry_node": "planner",
"exit_nodes": ["executor"],
},
}
@given("a v2 actor blob with agents and routes")
def step_v2_blob(context: Any) -> None:
context.v2_blob = {
"agents": {
"main": {
"type": "llm",
"config": {
"provider": "openai",
"model": "gpt-4",
},
}
},
"routes": {},
}
@given("a mock actor service for v3 testing")
def step_mock_actor_service(context: Any) -> None:
mock_actor_service = MagicMock()
def upsert_side_effect(**kwargs: Any) -> Actor:
return _make_actor(
name=kwargs.get("name", "local/test"),
provider=kwargs.get("provider", "custom"),
model=kwargs.get("model", "gpt-4"),
config_blob=kwargs.get("config_blob"),
yaml_text=kwargs.get("yaml_text"),
compiled_metadata=kwargs.get("compiled_metadata"),
graph_descriptor=kwargs.get("graph_descriptor"),
)
mock_actor_service.upsert_actor.side_effect = upsert_side_effect
# C4: use NotFoundError instead of generic Exception.
mock_actor_service.get_actor.side_effect = NotFoundError(
"Actor not found", resource_type="actor", resource_id="unknown"
)
mock_provider_registry = MagicMock()
mock_provider_registry.get_configured_providers.return_value = []
mock_settings = MagicMock()
context.mock_actor_service = mock_actor_service
context.registry = ActorRegistry(
actor_service=mock_actor_service,
provider_registry=mock_provider_registry,
settings=mock_settings,
)
@given("a v3 LLM actor YAML text")
def step_v3_llm_yaml_text(context: Any) -> None:
context.v3_yaml_text = yaml.safe_dump(
{
"name": "local/test-llm",
"type": "llm",
"description": "A test LLM actor for v3 schema",
"model": "gpt-4",
"skills": ["local/file-ops", "local/code-search"],
"lsp": ["local/pyright"],
}
)
@given("a v3 actor YAML text missing description")
def step_v3_yaml_missing_desc(context: Any) -> None:
context.v3_yaml_invalid = yaml.safe_dump(
{
"name": "local/test-no-desc",
"type": "llm",
"model": "gpt-4",
}
)
@given("a v3 graph actor YAML text with route")
def step_v3_graph_yaml_text(context: Any) -> None:
context.v3_graph_yaml_text = yaml.safe_dump(
{
"name": "local/test-graph",
"type": "graph",
"description": "A test graph actor",
"model": "gpt-4",
"skills": ["local/file-ops"],
"route": {
"nodes": [
{
"id": "planner",
"type": "agent",
"name": "Planner",
"description": "Plans tasks",
"config": {"model": "gpt-4"},
},
{
"id": "executor",
"type": "tool",
"name": "Executor",
"description": "Executes tasks",
"config": {"tool_name": "exec/run"},
},
],
"edges": [{"from_node": "planner", "to_node": "executor"}],
"entry_node": "planner",
"exit_nodes": ["executor"],
},
}
)
@given("a v3 LLM actor config dict")
def step_v3_llm_config_dict(context: Any) -> None:
context.v3_config = {
"name": "local/test-llm",
"type": "llm",
"description": "A test LLM actor",
"model": "gpt-4",
"system_prompt": "You are helpful",
"skills": ["local/file-ops"],
"lsp": ["local/pyright"],
"tools": ["local/search"],
}
@given("a v3 graph actor config dict with route")
def step_v3_graph_config_dict(context: Any) -> None:
context.v3_config = {
"name": "local/test-graph",
"type": "graph",
"description": "A test graph actor",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "planner",
"type": "agent",
"name": "Planner",
"description": "Plans",
"config": {},
},
{
"id": "executor",
"type": "tool",
"name": "Executor",
"description": "Executes",
"config": {},
},
],
"edges": [{"from_node": "planner", "to_node": "executor"}],
"entry_node": "planner",
"exit_nodes": ["executor"],
},
}
@given("a v2 actor config dict with agents key")
def step_v2_config_dict(context: Any) -> None:
context.v2_config = {
"agents": {"main": {"type": "llm", "config": {"provider": "openai"}}},
}
@given("an existing actor in the mock service")
def step_existing_actor(context: Any) -> None:
"""Set up mock to return an existing actor for duplicate checks."""
context.mock_actor_service.get_actor.side_effect = None
context.mock_actor_service.get_actor.return_value = _make_actor(
name="local/test-llm",
)
# ── When steps ───────────────────────────────────────────────────────────
@when("I call ActorConfiguration.from_blob with the v3 blob")
def step_call_from_blob_v3(context: Any) -> None:
context.actor_config = ActorConfiguration.from_blob(
blob=context.v3_blob,
name="local/test",
)
@when("I call ActorConfiguration.from_blob with the v2 blob")
def step_call_from_blob_v2(context: Any) -> None:
context.actor_config = ActorConfiguration.from_blob(
blob=context.v2_blob,
name="local/test",
)
@when("I call ActorRegistry.add with the v3 YAML")
def step_call_registry_add_v3(context: Any) -> None:
context.result_actor = context.registry.add(context.v3_yaml_text)
context.upsert_kwargs = context.mock_actor_service.upsert_actor.call_args
@when("I call ActorRegistry.add with the invalid v3 YAML")
def step_call_registry_add_invalid_v3(context: Any) -> None:
try:
context.registry.add(context.v3_yaml_invalid)
context.add_error = None
except ValidationError as exc:
context.add_error = exc
@when("I call ActorRegistry.add with the v3 graph YAML")
def step_call_registry_add_v3_graph(context: Any) -> None:
# M16: mock compile_actor to return controlled metadata.
mock_metadata = CompilationMetadata(
node_ids=["planner", "executor"],
tool_nodes=["executor"],
lsp_bindings=[],
subgraph_refs={},
entry_node="planner",
exit_nodes=["executor"],
)
mock_compiled = CompiledActor(
name="local/test-graph",
nodes={},
edges=[],
entry_point="planner",
metadata=mock_metadata,
)
with patch(
"cleveragents.actor.compiler.compile_actor",
return_value=mock_compiled,
):
context.result_actor = context.registry.add(context.v3_graph_yaml_text)
context.upsert_kwargs = context.mock_actor_service.upsert_actor.call_args
@when("I parse the v3 config through ReactiveConfigParser")
def step_parse_v3_config(context: Any) -> None:
parser = ReactiveConfigParser()
context.reactive_config = parser._build(context.v3_config)
@when("I check if the config is v3 format")
def step_check_v3_format(context: Any) -> None:
context.is_v3 = ReactiveConfigParser._is_v3_format(context.v2_config)
# ── Then steps ───────────────────────────────────────────────────────────
@then('the configuration should have provider "{provider}"')
def step_config_has_provider(context: Any, provider: str) -> None:
assert context.actor_config.provider == provider, (
f"Expected provider '{provider}', got '{context.actor_config.provider}'"
)
@then('the configuration should have model "{model}"')
def step_config_has_model(context: Any, model: str) -> None:
assert context.actor_config.model == model, (
f"Expected model '{model}', got '{context.actor_config.model}'"
)
@then('the configuration should have a graph_descriptor with type "{gtype}"')
def step_config_has_graph_descriptor(context: Any, gtype: str) -> None:
gd = context.actor_config.graph_descriptor
assert gd is not None, "Expected graph_descriptor, got None"
assert gd.get("type") == gtype, (
f"Expected graph_descriptor type '{gtype}', got '{gd.get('type')}'"
)
# Verify key graph descriptor fields are present.
route = gd.get("route")
assert isinstance(route, dict), (
f"Expected graph_descriptor to contain 'route' dict, got {type(route)}"
)
assert "nodes" in route, f"Expected 'nodes' in route: {route}"
assert "edges" in route, f"Expected 'edges' in route: {route}"
assert "entry_node" in route, f"Expected 'entry_node' in route: {route}"
@then("the actor should be persisted with v3 fields in config_blob")
def step_actor_persisted_v3(context: Any) -> None:
call_kwargs = context.upsert_kwargs
assert call_kwargs is not None, "upsert_actor was not called"
_, kwargs = call_kwargs
blob = kwargs.get("config_blob", {})
assert blob.get("type") == "llm", (
f"Expected type 'llm' in config_blob, got '{blob.get('type')}'"
)
assert blob.get("description") == "A test LLM actor for v3 schema", (
f"Missing or wrong description: {blob.get('description')}"
)
@then("the persisted config_blob should contain skills")
def step_blob_has_skills(context: Any) -> None:
_, kwargs = context.upsert_kwargs
blob = kwargs.get("config_blob", {})
skills = blob.get("skills", [])
assert len(skills) == 2, f"Expected 2 skills, got {len(skills)}"
assert "local/file-ops" in skills
@then("the persisted config_blob should contain lsp bindings")
def step_blob_has_lsp(context: Any) -> None:
_, kwargs = context.upsert_kwargs
blob = kwargs.get("config_blob", {})
lsp = blob.get("lsp")
assert lsp is not None, "Expected lsp in config_blob"
assert "local/pyright" in lsp
@then("a ValidationError should be raised mentioning description")
def step_validation_error_description(context: Any) -> None:
assert context.add_error is not None, "Expected a ValidationError to be raised"
error_msg = str(context.add_error).lower()
assert "description" in error_msg, (
f"Error message should mention 'description': {context.add_error}"
)
@then("the actor should be persisted with compiled metadata")
def step_actor_has_compiled_metadata(context: Any) -> None:
_, kwargs = context.upsert_kwargs
compiled = kwargs.get("compiled_metadata")
assert compiled is not None, "Expected compiled_metadata, got None"
assert "node_ids" in compiled, (
f"compiled_metadata should contain node_ids: {compiled}"
)
@then("the graph_descriptor should contain route information")
def step_graph_descriptor_route(context: Any) -> None:
_, kwargs = context.upsert_kwargs
gd = kwargs.get("graph_descriptor")
assert gd is not None, "Expected graph_descriptor, got None"
assert "route" in gd, f"graph_descriptor should contain 'route': {gd}"
@then("the reactive config should have one agent")
def step_reactive_has_one_agent(context: Any) -> None:
agents = context.reactive_config.agents
assert len(agents) == 1, f"Expected 1 agent, got {len(agents)}"
@then("the agent config should contain the model")
def step_agent_config_has_model(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
assert agent.config.get("model") == "gpt-4", (
f"Agent model mismatch: {agent.config.get('model')}"
)
@then("the agent config should contain skills")
def step_agent_config_has_skills(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
skills = agent.config.get("skills", [])
assert "local/file-ops" in skills, f"Expected 'local/file-ops' in skills: {skills}"
@then("the reactive config should have agents for each node")
def step_reactive_has_node_agents(context: Any) -> None:
agents = context.reactive_config.agents
assert "planner" in agents, f"Missing 'planner' agent: {list(agents.keys())}"
assert "executor" in agents, f"Missing 'executor' agent: {list(agents.keys())}"
@then("the reactive config should have a graph route")
def step_reactive_has_graph_route(context: Any) -> None:
routes = context.reactive_config.routes
assert len(routes) >= 1, f"Expected at least 1 route, got {len(routes)}"
route = next(iter(routes.values()))
assert route.type.value == "graph", f"Expected graph route, got {route.type.value}"
# Nit: also check entry_point and edges.
assert route.entry_point == "planner", (
f"Expected entry_point 'planner', got '{route.entry_point}'"
)
assert len(route.edges) >= 1, f"Expected at least 1 edge, got {len(route.edges)}"
@then("it should not be detected as v3")
def step_not_v3(context: Any) -> None:
assert context.is_v3 is False, "Expected v2 data to NOT be detected as v3"
@then("the graph route edges should use source and target keys")
def step_graph_edges_use_source_target(context: Any) -> None:
routes = context.reactive_config.routes
route = next(iter(routes.values()))
for edge in route.edges:
assert "source" in edge, f"Edge missing 'source' key: {edge}"
assert "target" in edge, f"Edge missing 'target' key: {edge}"
assert "from" not in edge, f"Edge should not have 'from' key: {edge}"
assert "to" not in edge, f"Edge should not have 'to' key: {edge}"
+18 -4
View File
@@ -1286,6 +1286,9 @@ def step_assert_ckpt_match(context: Context) -> None:
@given("drcov3 multiple checkpoints exist for the plan")
def step_insert_multi_ckpts(context: Context) -> None:
# Use a fixed base time so ISO-8601 string ordering is deterministic
# regardless of clock resolution or CI environment timing.
base_time = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC)
context.drcov3_ckpt_ids = []
for i in range(3):
cid = _next_ulid()
@@ -1293,7 +1296,7 @@ def step_insert_multi_ckpts(context: Context) -> None:
ckpt = _make_checkpoint(
plan_id=context.drcov3_ckpt_plan_id,
checkpoint_id=cid,
created_at=datetime.now(UTC) + timedelta(seconds=i),
created_at=base_time + timedelta(seconds=i),
)
context.drcov3_ckpt_repo.create(ckpt)
@@ -1346,14 +1349,25 @@ def step_assert_ckpt_deleted(context: Context) -> None:
@given("drcov3 five checkpoints exist for prune test")
def step_create_five_ckpts(context: Context) -> None:
# Use a dedicated plan for the prune test to avoid interference
# from checkpoints created in earlier scenarios of this feature.
context.drcov3_prune_plan_id = _next_ulid()
session = context.drcov3_factory()
_ensure_plan_row(session, context.drcov3_prune_plan_id)
session.commit()
session.close()
# Use a fixed base time so ISO-8601 string ordering is deterministic
# regardless of clock resolution or CI environment timing.
base_time = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
context.drcov3_prune_ckpt_ids = []
for i in range(5):
cid = _next_ulid()
context.drcov3_prune_ckpt_ids.append(cid)
ckpt = _make_checkpoint(
plan_id=context.drcov3_ckpt_plan_id,
plan_id=context.drcov3_prune_plan_id,
checkpoint_id=cid,
created_at=datetime.now(UTC) + timedelta(seconds=i),
created_at=base_time + timedelta(seconds=i),
)
context.drcov3_ckpt_repo.create(ckpt)
@@ -1362,7 +1376,7 @@ def step_create_five_ckpts(context: Context) -> None:
def step_prune_ckpts(context: Context) -> None:
try:
context.drcov3_result = context.drcov3_ckpt_repo.prune(
context.drcov3_ckpt_plan_id, max_checkpoints=3
context.drcov3_prune_plan_id, max_checkpoints=3
)
except Exception as exc:
context.drcov3_error = exc
@@ -294,6 +294,77 @@ Reject v3 TOOL Actor With Unnamespaced Tool Name
Should Be Equal As Integers ${result.rc} 1
Should Contain ${result.stderr} must be namespaced
Register And Show v3 LLM Actor Via CLI
[Documentation] Register a v3 LLM actor and verify it is retrievable via show
[Tags] slow
${yaml_file}= Set Variable ${TEMPDIR}${/}show_llm.yaml
Create File ${yaml_file} name: local/show-test-llm\ntype: llm\ndescription: Show test LLM\nprovider: openai\nmodel: gpt-4\n
${add_result}= Run Process ${PYTHON} ${HELPER} add local/show-test-llm ${yaml_file} cwd=${WORKSPACE}
Should Be Equal As Integers ${add_result.rc} 0
Should Contain ${add_result.stdout} actor-add-success
${show_result}= Run Process ${PYTHON} ${HELPER} show local/show-test-llm cwd=${WORKSPACE}
Log ${show_result.stdout}
Log ${show_result.stderr}
Should Be Equal As Integers ${show_result.rc} 0
Should Contain ${show_result.stdout} actor-show-success
Should Contain ${show_result.stdout} llm
Should Contain ${show_result.stdout} gpt-4
Build ReactiveConfig From v3 LLM Actor Config
[Documentation] Verify that _build_from_v3() correctly synthesises a ReactiveConfig from a v3 LLM blob
[Tags] slow
${yaml_file}= Set Variable ${TEMPDIR}${/}build_v3_llm.yaml
${yaml_content}= Catenate SEPARATOR=\n
... name: local/build-test-llm
... type: llm
... description: Build test LLM
... provider: openai
... model: openai/gpt-4
... system_prompt: You are helpful
... skills:
... ${SPACE}${SPACE}- local/file-ops
Create File ${yaml_file} ${yaml_content}
${result}= Run Process ${PYTHON} ${HELPER} build-v3-config ${yaml_file} cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} build-v3-config-success
Build ReactiveConfig From v3 GRAPH Actor Config
[Documentation] Verify that _build_from_v3() correctly synthesises a ReactiveConfig from a v3 graph blob
[Tags] slow
${yaml_file}= Set Variable ${TEMPDIR}${/}build_v3_graph.yaml
${yaml_content}= Catenate SEPARATOR=\n
... name: local/build-test-graph
... type: graph
... description: Build test graph
... provider: openai
... model: gpt-4
... route:
... ${SPACE}${SPACE}nodes:
... ${SPACE}${SPACE}${SPACE}${SPACE}- id: node_a
... ${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}type: agent
... ${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}name: Node A
... ${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}description: First node
... ${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}config: {}
... ${SPACE}${SPACE}${SPACE}${SPACE}- id: node_b
... ${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}type: agent
... ${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}name: Node B
... ${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}description: Second node
... ${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}config: {}
... ${SPACE}${SPACE}edges:
... ${SPACE}${SPACE}${SPACE}${SPACE}- from_node: node_a
... ${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}${SPACE}to_node: node_b
... ${SPACE}${SPACE}entry_node: node_a
... ${SPACE}${SPACE}exit_nodes:
... ${SPACE}${SPACE}${SPACE}${SPACE}- node_b
Create File ${yaml_file} ${yaml_content}
${result}= Run Process ${PYTHON} ${HELPER} build-v3-config ${yaml_file} cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} build-v3-config-success
Reject v3 GRAPH Actor With Unreachable Node
[Documentation] Reject v3 GRAPH actor with unreachable node via agents actor add
[Tags] slow
+208 -6
View File
@@ -99,23 +99,225 @@ def update_actor(actor_name: str, config_file: str) -> int:
return 1
def show_actor(actor_name: str) -> int:
"""Test showing an actor via the real CLI binary.
Invokes ``agents actor show <actor_name> --format json`` as a
subprocess to verify the actor was persisted and is retrievable.
Args:
actor_name: The actor name (e.g., local/test-llm)
Returns:
0 on success, 1 on failure or timeout
"""
try:
result = subprocess.run(
["agents", "actor", "show", actor_name, "--format", "json"],
capture_output=True,
text=True,
timeout=60,
)
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
print(f"actor-show-error: command failed: {exc}", file=sys.stderr)
return 1
if result.returncode == 0:
print(f"actor-show-success: {result.stdout.strip()}")
return 0
else:
output = result.stdout + result.stderr
print(f"actor-show-error: {output.strip()}", file=sys.stderr)
return 1
def build_v3_reactive_config(config_file: str) -> int:
"""Test that a v3 actor config blob can be parsed by ReactiveConfigParser.
This exercises the ``_build_from_v3()`` execution path that converts
a v3 ``ActorConfigSchema`` blob into a ``ReactiveConfig`` with
synthesised agents and routes.
Args:
config_file: Path to the YAML config file
Returns:
0 on success, 1 on failure
"""
_src = str(Path(__file__).resolve().parents[0].parent / "src")
if _src not in sys.path:
sys.path.insert(0, _src)
try:
import yaml as _yaml
from cleveragents.reactive.config_parser import ReactiveConfigParser
except ImportError as exc:
print(f"build-v3-config-error: import failed: {exc}", file=sys.stderr)
return 1
config_path = Path(config_file)
if not config_path.exists():
print(
f"build-v3-config-error: Config file not found: {config_file}",
file=sys.stderr,
)
return 1
text = config_path.read_text(encoding="utf-8")
blob = _yaml.safe_load(text) or {}
parser = ReactiveConfigParser()
try:
rc = parser._build(blob)
except Exception as exc:
print(f"build-v3-config-error: _build failed: {exc}", file=sys.stderr)
return 1
actor_type = blob.get("type", "").lower()
if actor_type == "llm":
if len(rc.agents) != 1:
print(
f"build-v3-config-error: expected 1 agent, got {len(rc.agents)}",
file=sys.stderr,
)
return 1
agent = next(iter(rc.agents.values()))
if not agent.config.get("model"):
print(
"build-v3-config-error: agent config missing model",
file=sys.stderr,
)
return 1
if not agent.config.get("provider"):
print(
"build-v3-config-error: agent config missing provider",
file=sys.stderr,
)
return 1
# Validate skills propagation when present in source blob.
src_skills = blob.get("skills") or []
if src_skills and not agent.config.get("skills"):
print(
"build-v3-config-error: agent config missing skills",
file=sys.stderr,
)
return 1
# Validate system_prompt propagation when present in source blob.
if blob.get("system_prompt") and not agent.config.get("system_prompt"):
print(
"build-v3-config-error: agent config missing system_prompt",
file=sys.stderr,
)
return 1
# Validate provider inference from model string.
model_str = blob.get("model", "")
if "/" in str(model_str):
expected_provider = str(model_str).split("/", 1)[0]
actual_provider = agent.config.get("provider", "")
if actual_provider != expected_provider:
print(
f"build-v3-config-error: expected provider "
f"'{expected_provider}', got '{actual_provider}'",
file=sys.stderr,
)
return 1
print("build-v3-config-success")
elif actor_type == "graph":
if len(rc.agents) < 1:
print(
f"build-v3-config-error: expected >=1 agent, got {len(rc.agents)}",
file=sys.stderr,
)
return 1
if len(rc.routes) < 1:
print(
f"build-v3-config-error: expected >=1 route, got {len(rc.routes)}",
file=sys.stderr,
)
return 1
route = next(iter(rc.routes.values()))
if route.type.value != "graph":
print(
f"build-v3-config-error: expected graph route, got {route.type.value}",
file=sys.stderr,
)
return 1
# Validate entry_point matches source blob.
route_blob = blob.get("route") or {}
expected_entry = route_blob.get("entry_node", "start")
if route.entry_point != expected_entry:
print(
f"build-v3-config-error: expected entry_point "
f"'{expected_entry}', got '{route.entry_point}'",
file=sys.stderr,
)
return 1
# Validate edge count matches source blob.
expected_edges = len(route_blob.get("edges") or [])
if len(route.edges) != expected_edges:
print(
f"build-v3-config-error: expected {expected_edges} "
f"edges, got {len(route.edges)}",
file=sys.stderr,
)
return 1
# Validate node IDs are present in agents dict.
for node in route_blob.get("nodes") or []:
nid = node.get("id", "") if isinstance(node, dict) else ""
if nid and nid not in rc.agents:
print(
f"build-v3-config-error: node '{nid}' not in agents",
file=sys.stderr,
)
return 1
print("build-v3-config-success")
else:
print(
f"build-v3-config-error: unsupported type '{actor_type}'",
file=sys.stderr,
)
return 1
return 0
def main() -> int:
"""Main entry point."""
if len(sys.argv) < 4:
if len(sys.argv) < 2:
print(
"Usage: helper_actor_add_v3_schema_validation.py"
" <command> <actor_name> <config_file>"
" <command> [<actor_name>] [<config_file>]"
)
return 1
command = sys.argv[1]
actor_name = sys.argv[2]
config_file = sys.argv[3]
if command in ("add", "update", "show") and len(sys.argv) < 3:
print(f"Error: '{command}' requires <actor_name>", file=sys.stderr)
return 1
if command in ("add", "update") and len(sys.argv) < 4:
print(
f"Error: '{command}' requires <actor_name> <config_file>",
file=sys.stderr,
)
return 1
if command == "add":
return add_actor(actor_name, config_file)
return add_actor(sys.argv[2], sys.argv[3])
elif command == "update":
return update_actor(actor_name, config_file)
return update_actor(sys.argv[2], sys.argv[3])
elif command == "show":
return show_actor(sys.argv[2])
elif command == "build-v3-config":
if len(sys.argv) < 3:
print(
"Error: 'build-v3-config' requires <config_file>",
file=sys.stderr,
)
return 1
return build_v3_reactive_config(sys.argv[2])
else:
print(f"Unknown command: {command}", file=sys.stderr)
return 1
+92 -156
View File
@@ -1,19 +1,15 @@
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any, Literal, cast
from pydantic import BaseModel, Field
from cleveragents.actor.yaml_template_engine import YAMLTemplateEngine
from cleveragents.actor.utils import infer_provider_from_model
from cleveragents.actor.yaml_loader import load_yaml_text
try: # Optional dependency; present in dev/test extras
import yaml
except Exception: # pragma: no cover - defensive optional import
yaml = None
#: v3 actor types that signal the ``ActorConfigSchema`` format.
_V3_ACTOR_TYPES: frozenset[str] = frozenset({"llm", "graph", "tool"})
class ActorConfiguration(BaseModel):
@@ -42,158 +38,17 @@ class ActorConfiguration(BaseModel):
Returns:
Parsed configuration dictionary.
"""
try:
data = json.loads(text)
except json.JSONDecodeError:
data = ActorConfiguration._load_v2_yaml_content(text)
else:
if data is None:
return {}
if not isinstance(data, dict):
raise ValueError("Config must be a JSON or YAML object")
return cast(dict[str, Any], data)
return data
return load_yaml_text(text)
@staticmethod
def load_blob_from_file(config_path: Path) -> dict[str, Any]:
"""Load a configuration blob from a JSON or YAML file (v2 parity)."""
path = config_path.expanduser()
if not path.exists():
raise ValueError(f"Config file not found: {path}")
text = path.read_text()
try:
data = json.loads(text)
except json.JSONDecodeError:
data = ActorConfiguration._load_v2_yaml_content(text)
else:
if data is None:
return {}
if not isinstance(data, dict):
raise ValueError("Config must be a JSON or YAML object")
return cast(dict[str, Any], data)
return data
@staticmethod
def _load_v2_yaml_content(text: str) -> dict[str, Any]:
if yaml is None:
raise ValueError("PyYAML is required for YAML actor configs")
raw: Any
try:
if "{%" in text or "{{" in text:
protected = re.sub(r"{{", "<<<TEMPLATE_START>>>", text)
protected = re.sub(r"}}", "<<<TEMPLATE_END>>>", protected)
protected = re.sub(r"{%", "<<<BLOCK_START>>>", protected)
protected = re.sub(r"%}", "<<<BLOCK_END>>>", protected)
engine = YAMLTemplateEngine()
minimal_context: dict[str, dict[str, Any]] = {
"context": {
"paper_details": {
"topic": "",
"length": "",
"audience": "",
"publication": "",
"format": "",
"other": "",
},
"brainstorming_summary": "",
"vetting_sources": list[str](),
"table_of_contents": "",
"deep_research_sources": "",
"current_section_to_write": "",
"paper_content": dict[str, Any](),
"final_paper_text": "",
"proofread_paper": "",
}
}
raw = engine.load_string(protected, context=minimal_context)
raw = ActorConfiguration._restore_template_syntax(raw)
else:
raw = yaml.safe_load(text)
except Exception as exc: # pragma: no cover - defensive
raise ValueError(f"Failed to parse config: {exc}") from exc
if raw is None:
raw = {}
if not isinstance(raw, dict):
raise ValueError("Config must be a JSON or YAML object")
interpolated = ActorConfiguration._interpolate_env_vars(raw)
if not isinstance(interpolated, dict):
raise ValueError("Config must be a JSON or YAML object")
return cast(dict[str, Any], interpolated)
@staticmethod
def _restore_template_syntax(config: Any) -> Any:
if isinstance(config, dict):
config_dict = cast(dict[str, Any], config)
restored: dict[str, Any] = {}
for key, value in config_dict.items():
if key == "system_prompt" and isinstance(value, str):
updated = value.replace("<<<TEMPLATE_START>>>", "{{").replace(
"<<<TEMPLATE_END>>>", "}}"
)
updated = updated.replace("<<<BLOCK_START>>>", "{%")
updated = updated.replace("<<<BLOCK_END>>>", "%}")
restored[key] = updated
else:
restored[key] = ActorConfiguration._restore_template_syntax(value)
return restored
if isinstance(config, list):
config_list = cast(list[Any], config)
return [
ActorConfiguration._restore_template_syntax(item)
for item in config_list
]
return config
@staticmethod
def _interpolate_env_vars(config: Any) -> Any:
if isinstance(config, dict):
config_dict = cast(dict[str, Any], config)
return {
k: ActorConfiguration._interpolate_env_vars(v)
for k, v in config_dict.items()
}
if isinstance(config, list):
config_list = cast(list[Any], config)
return [ActorConfiguration._interpolate_env_vars(i) for i in config_list]
if isinstance(config, str):
env_var_pattern = r"\${([A-Za-z0-9_]+)(?::([^}]*))?\}"
def replace_env_var(match: re.Match[str]) -> str:
env_var = match.group(1)
default_value = match.group(2) if match.group(2) is not None else None
env_value = os.environ.get(env_var)
if env_value is None:
if default_value is not None:
if default_value.lower() in {"true", "false"}:
return str(default_value.lower() == "true")
if default_value.lstrip("-").isdigit():
return default_value
return default_value
raise ValueError(f"Environment variable '{env_var}' is not set")
return env_value
substituted = re.sub(env_var_pattern, replace_env_var, config)
lowered = substituted.lower()
if lowered in {"true", "false"}:
return lowered == "true"
if substituted.lstrip("-").isdigit():
return int(substituted)
if (
substituted.lstrip("-").replace(".", "", 1).isdigit()
and substituted.count(".") == 1
):
return float(substituted)
return substituted
return config
return load_yaml_text(text)
@classmethod
def from_file(
@@ -231,21 +86,37 @@ class ActorConfiguration(BaseModel):
default_options: dict[str, Any] | None = None,
option_overrides: dict[str, Any] | None = None,
) -> ActorConfiguration:
"""Coerce an incoming config blob plus CLI overrides into a validated model."""
"""Coerce an incoming config blob plus CLI overrides into a validated model.
Supports both the legacy v2 ``agents/routes`` YAML layout and the v3
``ActorConfigSchema`` format (identified by a top-level ``type`` key
whose value is one of ``llm``, ``graph``, or ``tool``).
"""
data: dict[str, Any] = dict(blob) if isinstance(blob, dict) else {}
# Try v3 extraction first (presence of 'type' with a v3 value).
v3_provider, v3_model, v3_graph, v3_unsafe = cls._extract_v3_actor(data)
# Fall back to v2 extraction when v3 didn't match.
v2_provider, v2_model, v2_graph, v2_unsafe = cls._extract_v2_actor(data)
v2_options = cls._extract_v2_options(data)
resolved_provider = (
provider or data.get("provider") or data.get("provider_type") or v2_provider
provider
or data.get("provider")
or data.get("provider_type")
or v3_provider
or v2_provider
)
resolved_model = (
model or data.get("model") or data.get("model_id") or v3_model or v2_model
)
resolved_model = model or data.get("model") or data.get("model_id") or v2_model
resolved_graph = (
graph_descriptor
or data.get("graph_descriptor")
or data.get("graph")
or v3_graph
or v2_graph
)
@@ -265,14 +136,21 @@ class ActorConfiguration(BaseModel):
top_unsafe_raw = data.get("unsafe", False)
resolved_unsafe = (
(top_unsafe_raw is True or top_unsafe_raw == 1) or v2_unsafe or unsafe
(top_unsafe_raw is True or top_unsafe_raw == 1)
or v3_unsafe
or v2_unsafe
or unsafe
)
if not resolved_provider:
raise ValueError("provider is required")
if not resolved_model:
raise ValueError("model is required")
raise ValueError(
"model is required. Tool actors must be registered via "
"ActorRegistry.add(), not from_blob(). "
"Use 'agents actor add' to register tool actors."
)
graph_blob = (
cast(dict[str, Any], resolved_graph)
@@ -289,6 +167,7 @@ class ActorConfiguration(BaseModel):
unsafe=resolved_unsafe,
)
@staticmethod
@staticmethod
def _resolve_actors_map(
data: dict[str, Any],
@@ -309,6 +188,63 @@ class ActorConfiguration(BaseModel):
return actors_val, "actors"
return data.get("agents"), "agents"
@staticmethod
def _extract_v3_actor(
data: dict[str, Any],
) -> tuple[str | None, str | None, dict[str, Any] | None, bool]:
"""Derive provider/model/graph from the v3 Actor YAML schema format.
The v3 schema is identified by a top-level ``type`` key whose value
is one of ``llm``, ``graph``, or ``tool``. The model is taken from
the top-level ``model`` key. Because v3 YAML does not carry a
``provider`` field the provider is inferred from the model string:
* If the model contains ``/`` the prefix is used as provider
(e.g. ``openai/gpt-4`` provider ``openai``).
* Otherwise ``"custom"`` is used as a fallback.
For ``type: graph`` actors the ``route`` block is wrapped into a
graph descriptor.
Returns:
``(provider, model, graph_descriptor, unsafe)`` any component
may be ``None`` if the data does not look like v3.
"""
actor_type = data.get("type")
if not isinstance(actor_type, str):
return None, None, None, False
if actor_type.lower() not in _V3_ACTOR_TYPES:
return None, None, None, False
model_value = data.get("model")
# C2: model is optional for TOOL actors — only reject when model
# is absent for types that require it (LLM, GRAPH).
if not model_value or not isinstance(model_value, str):
if actor_type.lower() != "tool":
return None, None, None, False
# TOOL actors may omit model; use empty string as sentinel.
model_value = ""
# Infer provider from model string or default to "custom".
provider_value = (
infer_provider_from_model(model_value) if model_value else "custom"
)
unsafe_flag = bool(data.get("unsafe", False))
# Build a graph descriptor for graph actors from the route block.
graph_descriptor: dict[str, Any] | None = None
if actor_type.lower() == "graph":
route_raw = data.get("route")
if isinstance(route_raw, dict):
graph_descriptor = {
"type": "graph",
"route": route_raw,
"model": model_value,
}
return provider_value, model_value, graph_descriptor, unsafe_flag
@staticmethod
def _extract_v2_actor(
data: dict[str, Any],
+172
View File
@@ -0,0 +1,172 @@
"""Legacy (v2) actor registration logic extracted from ``ActorRegistry``.
This module handles the v2 blob registration path, including provider/model
extraction, unsafe flag enforcement, and nested option merging. It is used
by :class:`ActorRegistry` to keep the main registry module under the 500-line
limit.
"""
from __future__ import annotations
from typing import Any
import pydantic
from cleveragents.actor.config import ActorConfiguration
from cleveragents.actor.schema import ActorConfigSchema, is_v3_yaml
from cleveragents.application.services.actor_service import ActorService
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.domain.models.core import Actor
def add_legacy(
blob: dict[str, Any],
*,
yaml_text: str,
update: bool,
schema_version: str,
compiled_metadata: dict[str, Any] | None,
unsafe: bool = False,
allow_unsafe: bool = False,
actor_service: ActorService,
ensure_namespaced: callable, # type: ignore[type-arg]
) -> Actor:
"""Register an actor from a legacy (v2) blob.
Args:
blob: Parsed YAML blob.
yaml_text: Original YAML source text.
update: When ``True`` allow overwriting an existing actor.
schema_version: Schema version string.
compiled_metadata: Optional compiler-produced metadata dict.
unsafe: Asserts that the actor IS unsafe.
allow_unsafe: Permits registration of an already-unsafe actor.
actor_service: The actor persistence service.
ensure_namespaced: Function to namespace actor names.
Returns:
The persisted ``Actor`` domain object.
"""
name_raw: str = blob.get("name", "")
if not name_raw:
raise ValidationError("Actor YAML must include a 'name' field.")
# ── Validate v3 YAML via ActorConfigSchema if detected ──────────────────
# This ensures v3 actors are validated against the full schema, including
# cycle detection, required fields, and enum validation.
blob_is_v3 = is_v3_yaml(blob)
if blob_is_v3:
try:
ActorConfigSchema.model_validate(blob)
except pydantic.ValidationError as exc:
raise ValidationError(f"Invalid v3 actor configuration: {exc}") from exc
name = ensure_namespaced(name_raw)
provider_raw = blob.get("provider") or blob.get("provider_type", "")
model_raw = blob.get("model") or blob.get("model_id", "")
# ── Guard: reject multi-actor YAML ─────────────────────────────────
# ``add()`` is designed for single-actor YAML files. Provider, model,
# unsafe, and graph extraction all operate on the *first* entry only,
# so a multi-actor map would silently discard later entries (including
# their ``unsafe`` flags — a spec-compliance and security concern).
# Multi-actor configurations must be split and registered individually.
# Uses ``_resolve_actors_map()`` for consistent ``actors:``/``agents:``
# resolution across all call sites.
actors_map, _ = ActorConfiguration._resolve_actors_map(blob)
if isinstance(actors_map, dict) and len(actors_map) > 1:
raise ValidationError(
"registry.add() only supports single-actor YAML files. "
"Multi-actor configurations must be registered individually."
)
# Always extract from the nested ``actors:``/``agents:`` map so that
# ``unsafe`` and ``graph_descriptor`` are captured even when top-level
# ``provider``/``model`` are present. This matches the behaviour of
# ``from_blob()`` which unconditionally calls ``_extract_v2_actor()``.
nested_provider, nested_model, nested_graph, nested_unsafe = (
ActorConfiguration._extract_v2_actor(blob)
)
if not provider_raw and nested_provider:
provider_raw = nested_provider
if not model_raw and nested_model:
model_raw = nested_model
if not blob_is_v3 and (not provider_raw or not model_raw):
raise ValidationError("Actor YAML must include 'provider' and 'model' fields.")
# For v3 actors without provider/model (e.g. TOOL actors), provider_raw
# and model_raw may be empty strings here. This is expected — the legacy
# canonicalization path in upsert_actor() / from_blob() will reject
# provider-less TOOL actors. See follow-up issue #9971.
provider = str(provider_raw) if provider_raw else ""
model = str(model_raw) if model_raw else ""
top_unsafe_raw = blob.get("unsafe", False)
# Accept only boolean ``True`` or integer ``1`` as unsafe. Note:
# ``top_unsafe_raw == 1`` also matches ``1.0`` (float) due to
# Python's numeric equality rules — this is fail-safe (treats 1.0
# as unsafe).
#
# ``unsafe`` (the parameter) is an *assertion* — "this actor IS
# unsafe" (maps to the spec's "CLI --unsafe flag").
# ``allow_unsafe`` is a *permission* — "I PERMIT an already-unsafe
# actor to be registered" but does NOT itself make the actor unsafe.
# Therefore only ``unsafe`` participates in the stored value; the
# spec rule is: "the result is true if *any* of: top-level unsafe,
# nested unsafe, or the CLI --unsafe flag is true."
effective_unsafe = (
(top_unsafe_raw is True or top_unsafe_raw == 1) or nested_unsafe or unsafe
)
if effective_unsafe and not (unsafe or allow_unsafe):
raise ValidationError(
"Actor configuration is marked unsafe; pass unsafe=True "
"or allow_unsafe=True to confirm."
)
if not update:
try:
actor_service.get_actor(name)
except NotFoundError:
pass # Actor does not exist yet — proceed with add
else:
raise ValidationError(
f"Actor '{name}' already exists. Pass update=True to overwrite."
)
# ── Extract nested options so they are not silently discarded ─────
# ``from_blob()`` calls ``_extract_v2_options()`` internally, but
# ``add()`` bypasses ``from_blob()``. Without this call, options
# nested inside ``actors.<name>.config.options`` would be lost.
v2_options = ActorConfiguration._extract_v2_options(blob)
config_blob: dict[str, Any] = dict(blob)
config_blob.setdefault("source", "yaml")
if v2_options is not None:
existing_options = config_blob.get("options")
if existing_options is None or not isinstance(existing_options, dict):
# No valid top-level options dict — use nested options directly.
config_blob["options"] = v2_options
else:
# Both top-level and nested options exist. Match ``from_blob()``
# merge semantics: nested options as base, top-level overrides.
merged = dict(v2_options)
merged.update(existing_options)
config_blob["options"] = merged
top_graph_raw = blob.get("graph_descriptor")
top_graph = top_graph_raw if top_graph_raw is not None else blob.get("graph")
resolved_graph = top_graph if top_graph is not None else nested_graph
return actor_service.upsert_actor(
name=name,
provider=provider,
model=model,
config_blob=config_blob,
graph_descriptor=resolved_graph,
unsafe=effective_unsafe,
set_default=False,
is_built_in=False,
yaml_text=yaml_text,
schema_version=schema_version,
compiled_metadata=compiled_metadata,
)
+40 -125
View File
@@ -2,16 +2,19 @@
from __future__ import annotations
import logging
from dataclasses import asdict
from typing import Any
import pydantic
from cleveragents.actor.config import ActorConfiguration
from cleveragents.actor.legacy_registry import add_legacy
from cleveragents.actor.schema import ActorConfigSchema, is_v3_yaml
from cleveragents.actor.v3_registry import add_v3, is_v3_blob
from cleveragents.application.services.actor_service import ActorService
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core import Actor
from cleveragents.providers.registry import (
ProviderCapabilities,
@@ -19,6 +22,8 @@ from cleveragents.providers.registry import (
ProviderRegistry,
)
logger = logging.getLogger(__name__)
class ActorRegistry:
"""Coordinate actor configuration parsing and built-in generation.
@@ -194,10 +199,12 @@ class ActorRegistry:
The YAML is parsed into an ``ActorConfiguration``, validated, and
persisted alongside the original *yaml_text* and *schema_version*.
For v3 YAML files (detected by any non-null ``type`` field or a
``version`` starting with ``'3'``), the configuration is validated
using ``ActorConfigSchema`` to ensure proper schema compliance,
including cycle detection for GRAPH actors.
When the YAML matches the v3 ``ActorConfigSchema`` (detected by a
top-level ``type`` key of ``llm``, ``graph``, or ``tool``) the full
schema is validated, ``description`` is enforced, and ``skills`` /
``lsp`` bindings are stored in the config blob. For ``type: graph``
actors the route is additionally compiled and the compilation
metadata is persisted.
.. note::
@@ -226,6 +233,7 @@ class ActorRegistry:
schema_version: Explicit schema version; defaults to
``DEFAULT_SCHEMA_VERSION``.
compiled_metadata: Optional compiler-produced metadata dict.
allow_unsafe: When ``True`` permit actors marked ``unsafe``.
Returns:
The persisted ``Actor`` domain object.
@@ -238,138 +246,45 @@ class ActorRegistry:
self.ensure_built_in_actors()
blob = ActorConfiguration.load_yaml_text(yaml_text)
name_raw: str = blob.get("name", "")
if not name_raw:
raise ValidationError("Actor YAML must include a 'name' field.")
# ── Validate v3 YAML via ActorConfigSchema if detected ──────────────────
# This ensures v3 actors are validated against the full schema, including
# cycle detection, required fields, and enum validation.
blob_is_v3 = is_v3_yaml(blob)
if blob_is_v3:
try:
ActorConfigSchema.model_validate(blob)
except pydantic.ValidationError as exc:
raise ValidationError(f"Invalid v3 actor configuration: {exc}") from exc
name = self._ensure_namespaced(name_raw)
provider_raw = blob.get("provider") or blob.get("provider_type", "")
model_raw = blob.get("model") or blob.get("model_id", "")
# ── Guard: reject multi-actor YAML ─────────────────────────────────
# ``add()`` is designed for single-actor YAML files. Provider, model,
# unsafe, and graph extraction all operate on the *first* entry only,
# so a multi-actor map would silently discard later entries (including
# their ``unsafe`` flags — a spec-compliance and security concern).
# Multi-actor configurations must be split and registered individually.
# Uses ``_resolve_actors_map()`` for consistent ``actors:``/``agents:``
# resolution across all call sites.
actors_map, _ = ActorConfiguration._resolve_actors_map(blob)
if isinstance(actors_map, dict) and len(actors_map) > 1:
raise ValidationError(
"registry.add() only supports single-actor YAML files. "
"Multi-actor configurations must be registered individually."
)
# Always extract from the nested ``actors:``/``agents:`` map so that
# ``unsafe`` and ``graph_descriptor`` are captured even when top-level
# ``provider``/``model`` are present. This matches the behaviour of
# ``from_blob()`` which unconditionally calls ``_extract_v2_actor()``.
nested_provider, nested_model, nested_graph, nested_unsafe = (
ActorConfiguration._extract_v2_actor(blob)
)
if not provider_raw and nested_provider:
provider_raw = nested_provider
if not model_raw and nested_model:
model_raw = nested_model
if not blob_is_v3 and (not provider_raw or not model_raw):
raise ValidationError(
"Actor YAML must include 'provider' and 'model' fields."
)
# For v3 actors without provider/model (e.g. TOOL actors), provider_raw
# and model_raw may be empty strings here. This is expected — the legacy
# canonicalization path in upsert_actor() / from_blob() will reject
# provider-less TOOL actors. See follow-up issue #9971.
provider = str(provider_raw) if provider_raw else ""
model = str(model_raw) if model_raw else ""
top_unsafe_raw = blob.get("unsafe", False)
# Accept only boolean ``True`` or integer ``1`` as unsafe. Note:
# ``top_unsafe_raw == 1`` also matches ``1.0`` (float) due to
# Python's numeric equality rules — this is fail-safe (treats 1.0
# as unsafe).
#
# ``unsafe`` (the parameter) is an *assertion* — "this actor IS
# unsafe" (maps to the spec's "CLI --unsafe flag").
# ``allow_unsafe`` is a *permission* — "I PERMIT an already-unsafe
# actor to be registered" but does NOT itself make the actor unsafe.
# Therefore only ``unsafe`` participates in the stored value; the
# spec rule is: "the result is true if *any* of: top-level unsafe,
# nested unsafe, or the CLI --unsafe flag is true."
effective_unsafe = (
(top_unsafe_raw is True or top_unsafe_raw == 1) or nested_unsafe or unsafe
)
if effective_unsafe and not (unsafe or allow_unsafe):
raise ValidationError(
"Actor configuration is marked unsafe; pass unsafe=True "
"or allow_unsafe=True to confirm."
)
# Check for existing actor when not updating
if not update:
try:
self._actor_service.get_actor(name)
except NotFoundError:
pass # Actor does not exist yet — proceed with add
else:
raise ValidationError(
f"Actor '{name}' already exists. Pass update=True to overwrite."
)
# ── Extract nested options so they are not silently discarded ─────
# ``from_blob()`` calls ``_extract_v2_options()`` internally, but
# ``add()`` bypasses ``from_blob()``. Without this call, options
# nested inside ``actors.<name>.config.options`` would be lost.
v2_options = ActorConfiguration._extract_v2_options(blob)
if not isinstance(blob, dict):
raise ValidationError("Actor YAML must be a mapping.")
version = schema_version or self.DEFAULT_SCHEMA_VERSION
config_blob: dict[str, Any] = dict(blob)
config_blob.setdefault("source", "yaml")
if v2_options is not None:
existing_options = config_blob.get("options")
if existing_options is None or not isinstance(existing_options, dict):
# No valid top-level options dict — use nested options directly.
config_blob["options"] = v2_options
else:
# Both top-level and nested options exist. Match ``from_blob()``
# merge semantics: nested options as base, top-level overrides.
merged = dict(v2_options)
merged.update(existing_options)
config_blob["options"] = merged
top_graph_raw = blob.get("graph_descriptor")
top_graph = top_graph_raw if top_graph_raw is not None else blob.get("graph")
resolved_graph = top_graph if top_graph is not None else nested_graph
# ----------------------------------------------------------
# v3 path: validate against ActorConfigSchema when detected
# ----------------------------------------------------------
if is_v3_blob(blob):
return add_v3(
blob,
yaml_text=yaml_text,
update=update,
schema_version=version,
compiled_metadata=compiled_metadata,
actor_service=self._actor_service,
allow_unsafe=allow_unsafe,
)
return self._actor_service.upsert_actor(
name=name,
provider=provider,
model=model,
config_blob=config_blob,
graph_descriptor=resolved_graph,
unsafe=effective_unsafe,
set_default=False,
is_built_in=False,
# ----------------------------------------------------------
# Legacy (v2) path: flat provider/model extraction
# ----------------------------------------------------------
return add_legacy(
blob,
yaml_text=yaml_text,
update=update,
schema_version=version,
compiled_metadata=compiled_metadata,
unsafe=unsafe,
allow_unsafe=allow_unsafe,
actor_service=self._actor_service,
ensure_namespaced=self._ensure_namespaced,
)
# ------------------------------------------------------------------
# Legacy upsert (preserved for backward compatibility)
# ------------------------------------------------------------------
# Legacy upsert (preserved for backward compatibility)
# ------------------------------------------------------------------
def upsert_actor(
self,
+19
View File
@@ -0,0 +1,19 @@
"""Shared utility functions for the actor subsystem."""
from __future__ import annotations
#: Sentinel model value for tool actors that have no real model.
TOOL_ACTOR_SENTINEL: str = "__tool_actor__"
def infer_provider_from_model(model: str) -> str:
"""Infer a provider identifier from a model string.
If the model contains ``/`` the prefix is used as provider
(e.g. ``openai/gpt-4`` ``openai``). Otherwise ``"custom"``
is returned as a fallback.
"""
if "/" in model:
prefix = model.split("/", 1)[0]
return prefix if prefix else "custom"
return "custom"
+167
View File
@@ -0,0 +1,167 @@
"""v3 Actor registration logic extracted from ``ActorRegistry``.
This module handles the v3 ``ActorConfigSchema`` registration path,
including schema validation, provider inference, graph compilation,
and unsafe flag enforcement. It is used by :class:`ActorRegistry`
to keep the main registry module under the 500-line limit.
"""
from __future__ import annotations
import copy
import logging
from typing import Any
from pydantic import ValidationError as PydanticValidationError
from cleveragents.actor.compiler import ActorCompilationError, compile_actor
from cleveragents.actor.config import _V3_ACTOR_TYPES
from cleveragents.actor.schema import ActorConfigSchema
from cleveragents.actor.utils import TOOL_ACTOR_SENTINEL, infer_provider_from_model
from cleveragents.application.services.actor_service import ActorService
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.domain.models.core import Actor
logger = logging.getLogger(__name__)
def is_v3_blob(blob: dict[str, Any]) -> bool:
"""Return ``True`` when *blob* looks like a v3 ``ActorConfigSchema``."""
actor_type = blob.get("type")
return isinstance(actor_type, str) and actor_type.lower() in _V3_ACTOR_TYPES
def add_v3(
blob: dict[str, Any],
*,
yaml_text: str,
update: bool,
schema_version: str,
compiled_metadata: dict[str, Any] | None,
actor_service: ActorService,
allow_unsafe: bool = False,
) -> Actor:
"""Register an actor from a v3 ``ActorConfigSchema`` blob.
Validates the full v3 schema, infers ``provider`` from the model
string, persists ``skills`` / ``lsp`` / ``description`` in the
config blob, and compiles ``type: graph`` actors.
Args:
blob: Parsed YAML blob matching the v3 schema.
yaml_text: Original YAML source text.
update: When ``True`` allow overwriting an existing actor.
schema_version: Schema version string.
compiled_metadata: Optional pre-computed compilation metadata.
When provided for graph actors, compilation is skipped.
actor_service: The actor persistence service.
allow_unsafe: When ``True`` permit actors marked ``unsafe``.
Returns:
The persisted ``Actor`` domain object.
Raises:
ValidationError: When the YAML is invalid, the actor already
exists and *update* is ``False``, or the actor is unsafe
and *allow_unsafe* is ``False``.
"""
# m9: deep copy blob immediately to avoid mutating the caller's dict.
data: dict[str, Any] = copy.deepcopy(blob)
# Ensure name is namespaced before schema validation.
name_raw = data.get("name", "")
if isinstance(name_raw, str) and name_raw and "/" not in name_raw:
data["name"] = f"local/{name_raw}"
# Infer provider before schema validation — the schema now requires
# provider for LLM and GRAPH actors (added by #5869).
if not data.get("provider"):
model_raw = data.get("model") or ""
data["provider"] = (
infer_provider_from_model(model_raw) if model_raw else "custom"
)
try:
schema = ActorConfigSchema.model_validate(data)
except PydanticValidationError as exc:
field_errors: list[str] = []
for err in exc.errors():
path = ".".join(str(loc) for loc in err["loc"])
field_errors.append(f" {path}: {err['msg']}")
raise ValidationError(
"v3 Actor YAML schema validation failed:\n" + "\n".join(field_errors)
) from exc
name = schema.name
# C2: model is optional for TOOL actors — let validate_type_requirements
# handle type-specific model requirements instead of rejecting here.
# The Actor domain model requires min_length=1 for the model field,
# so use TOOL_ACTOR_SENTINEL as an unambiguous sentinel for tool actors
# without a model. This avoids collision with the actor type name
# "tool" which could confuse downstream LLM routing logic.
model = schema.model or (TOOL_ACTOR_SENTINEL if schema.type.value == "tool" else "")
# M11: enforce unsafe flag.
unsafe_flag = bool(data.get("unsafe", False))
if unsafe_flag and not allow_unsafe:
raise ValidationError(
"Actor configuration is marked unsafe; re-run with --unsafe to confirm."
)
# Provider was already inferred and injected into data above.
provider = str(data.get("provider") or "custom")
# Check for existing actor when not updating.
# M3: catch only NotFoundError, not generic Exception.
if not update:
try:
actor_service.get_actor(name)
raise ValidationError(
f"Actor '{name}' already exists. Pass update=True to overwrite."
)
except NotFoundError:
pass # Expected for new actors
config_blob: dict[str, Any] = data
config_blob.setdefault("source", "yaml")
config_blob["provider"] = provider
config_blob["model"] = model
# Compile graph actors and capture metadata.
# M10: skip compilation when compiled_metadata is already provided.
graph_descriptor = data.get("graph_descriptor")
if (
schema.type.value == "graph"
and schema.route is not None
and compiled_metadata is None
):
# M4: narrow exception to ActorCompilationError.
try:
compiled = compile_actor(schema)
compiled_metadata = compiled.metadata.model_dump(mode="json")
graph_descriptor = {
"type": "graph",
"route": schema.route.model_dump(mode="json"),
"model": model,
"entry_point": compiled.entry_point,
}
except ActorCompilationError as compile_exc:
logger.warning(
"v3 graph compilation failed: %s",
compile_exc,
)
# Still persist the actor; compilation metadata is optional.
return actor_service.upsert_actor(
name=name,
provider=provider,
model=model,
config_blob=config_blob,
graph_descriptor=graph_descriptor,
unsafe=unsafe_flag,
set_default=False,
is_built_in=False,
yaml_text=yaml_text,
schema_version=schema_version,
compiled_metadata=compiled_metadata,
)
+161
View File
@@ -0,0 +1,161 @@
"""YAML loading, template processing, and environment variable interpolation.
This module handles all YAML parsing, Jinja2 template preprocessing,
and ``${ENV_VAR}`` interpolation for actor configuration files. It is
extracted from ``config.py`` to keep that module under the 500-line limit.
"""
from __future__ import annotations
import json
import os
import re
from typing import Any, cast
from cleveragents.actor.yaml_template_engine import YAMLTemplateEngine
try: # Optional dependency; present in dev/test extras
import yaml
except Exception: # pragma: no cover - defensive optional import
yaml = None
def load_yaml_text(text: str) -> dict[str, Any]:
"""Parse YAML or JSON text into a configuration blob.
Args:
text: Raw YAML or JSON string.
Returns:
Parsed configuration dictionary.
"""
try:
data = json.loads(text)
except json.JSONDecodeError:
data = _load_yaml_content(text)
else:
if data is None:
return {}
if not isinstance(data, dict):
raise ValueError("Config must be a JSON or YAML object")
return cast(dict[str, Any], data)
return data
def _load_yaml_content(text: str) -> dict[str, Any]:
"""Parse YAML text with Jinja2 template support and env var interpolation."""
if yaml is None:
raise ValueError("PyYAML is required for YAML actor configs")
raw: Any
try:
if "{%" in text or "{{" in text:
protected = re.sub(r"{{", "<<<TEMPLATE_START>>>", text)
protected = re.sub(r"}}", "<<<TEMPLATE_END>>>", protected)
protected = re.sub(r"{%", "<<<BLOCK_START>>>", protected)
protected = re.sub(r"%}", "<<<BLOCK_END>>>", protected)
engine = YAMLTemplateEngine()
minimal_context: dict[str, dict[str, Any]] = {
"context": {
"paper_details": {
"topic": "",
"length": "",
"audience": "",
"publication": "",
"format": "",
"other": "",
},
"brainstorming_summary": "",
"vetting_sources": list[str](),
"table_of_contents": "",
"deep_research_sources": "",
"current_section_to_write": "",
"paper_content": dict[str, Any](),
"final_paper_text": "",
"proofread_paper": "",
}
}
raw = engine.load_string(protected, context=minimal_context)
raw = _restore_template_syntax(raw)
else:
raw = yaml.safe_load(text)
except Exception as exc: # pragma: no cover - defensive
raise ValueError(f"Failed to parse config: {exc}") from exc
if raw is None:
raw = {}
if not isinstance(raw, dict):
raise ValueError("Config must be a JSON or YAML object")
interpolated = interpolate_env_vars(raw)
if not isinstance(interpolated, dict):
raise ValueError("Config must be a JSON or YAML object")
return cast(dict[str, Any], interpolated)
def _restore_template_syntax(config: Any) -> Any:
"""Restore Jinja2 template syntax after YAML parsing."""
if isinstance(config, dict):
config_dict = cast(dict[str, Any], config)
restored: dict[str, Any] = {}
for key, value in config_dict.items():
if key == "system_prompt" and isinstance(value, str):
updated = value.replace("<<<TEMPLATE_START>>>", "{{").replace(
"<<<TEMPLATE_END>>>", "}}"
)
updated = updated.replace("<<<BLOCK_START>>>", "{%")
updated = updated.replace("<<<BLOCK_END>>>", "%}")
restored[key] = updated
else:
restored[key] = _restore_template_syntax(value)
return restored
if isinstance(config, list):
config_list = cast(list[Any], config)
return [_restore_template_syntax(item) for item in config_list]
return config
def interpolate_env_vars(config: Any) -> Any:
"""Recursively interpolate ``${ENV_VAR}`` and ``${ENV_VAR:default}`` patterns.
Supports type coercion for boolean, integer, and float defaults.
"""
if isinstance(config, dict):
config_dict = cast(dict[str, Any], config)
return {k: interpolate_env_vars(v) for k, v in config_dict.items()}
if isinstance(config, list):
config_list = cast(list[Any], config)
return [interpolate_env_vars(i) for i in config_list]
if isinstance(config, str):
env_var_pattern = r"\${([A-Za-z0-9_]+)(?::([^}]*))?\}"
def replace_env_var(match: re.Match[str]) -> str:
env_var = match.group(1)
default_value = match.group(2) if match.group(2) is not None else None
env_value = os.environ.get(env_var)
if env_value is None:
if default_value is not None:
if default_value.lower() in {"true", "false"}:
return str(default_value.lower() == "true")
if default_value.lstrip("-").isdigit():
return default_value
return default_value
raise ValueError(f"Environment variable '{env_var}' is not set")
return env_value
substituted = re.sub(env_var_pattern, replace_env_var, config)
lowered = substituted.lower()
if lowered in {"true", "false"}:
return lowered == "true"
if substituted.lstrip("-").isdigit():
return int(substituted)
if (
substituted.lstrip("-").replace(".", "", 1).isdigit()
and substituted.count(".") == 1
):
return float(substituted)
return substituted
return config
+221
View File
@@ -7,6 +7,7 @@ entries (stream/graph). Template rendering is intentionally simplified.
from __future__ import annotations
import logging
import os
import re
from pathlib import Path
@@ -15,10 +16,14 @@ from typing import Any
import yaml
from pydantic import BaseModel, Field
from cleveragents.actor.config import _V3_ACTOR_TYPES
from cleveragents.actor.utils import infer_provider_from_model
from cleveragents.core.exceptions import ConfigurationError
from cleveragents.reactive.route import BridgeConfig, RouteConfig, RouteType
from cleveragents.reactive.stream_router import StreamType
logger = logging.getLogger(__name__)
class AgentConfig(BaseModel):
name: str
@@ -101,8 +106,19 @@ class ReactiveConfigParser:
value = self.env_pattern.sub(repl, value)
return value
@staticmethod
def _is_v3_format(data: dict[str, Any]) -> bool:
"""Return ``True`` when *data* matches the v3 ``ActorConfigSchema``."""
actor_type = data.get("type")
return isinstance(actor_type, str) and actor_type.lower() in _V3_ACTOR_TYPES
def _build(self, data: dict[str, Any]) -> ReactiveConfig:
rc = ReactiveConfig()
# v3 Actor YAML: synthesise a reactive agent from the top-level config.
if ReactiveConfigParser._is_v3_format(data):
return ReactiveConfigParser._build_from_v3(data, rc)
agents_data = data.get("agents") or data.get("actors") or {}
for name, agent_data in (agents_data or {}).items():
rc.agents[name] = AgentConfig(
@@ -175,3 +191,208 @@ class ReactiveConfigParser:
rc.template_engine = data.get("template_engine", rc.template_engine)
rc.prompts = data.get("prompts", {}) or {}
return rc
@staticmethod
def _build_from_v3(
data: dict[str, Any],
rc: ReactiveConfig,
) -> ReactiveConfig:
"""Synthesise a ``ReactiveConfig`` from a v3 ``ActorConfigSchema`` blob.
For ``type: llm`` actors a single reactive agent is created with the
model, system prompt, and tool references from the v3 config. For
``type: graph`` actors the route nodes are mapped to reactive agents
and the edges are mapped to graph routes. Skills and LSP bindings
are propagated via the agent config so the runtime can attach them.
All v3 fields ``context_view``, ``memory``, ``context``,
``env_vars``, ``response_format``, ``lsp_capabilities``, and
``lsp_context_enrichment`` are propagated into the agent config
so the runtime can consume them.
"""
# m4: guard against None-to-string coercion for all three fields.
actor_type = str(data.get("type") or "llm").lower()
actor_name = str(data.get("name") or "v3_actor")
model = str(data.get("model") or "")
# m8: validate that model is non-empty for types that require it.
if not model and actor_type in ("llm", "graph"):
raise ConfigurationError(
f"v3 actor '{actor_name}' of type '{actor_type}' requires a "
f"non-empty 'model' field."
)
# Infer provider from model string (m11: shared utility).
provider = infer_provider_from_model(model)
# Collect v3 metadata for runtime attachment.
# m12: validate element types for skills list.
raw_skills = data.get("skills", []) or []
skills: list[str] = (
[str(s) for s in raw_skills] if isinstance(raw_skills, list) else []
)
lsp_raw = data.get("lsp")
lsp_bindings: list[str] | dict[str, Any] | None = None
if isinstance(lsp_raw, list):
lsp_bindings = [str(s) for s in lsp_raw]
elif isinstance(lsp_raw, dict):
lsp_bindings = dict(lsp_raw)
agent_config: dict[str, Any] = {
"provider": provider,
"model": model,
"system_prompt": data.get("system_prompt", ""),
}
if skills:
agent_config["skills"] = skills
if lsp_bindings is not None:
agent_config["lsp"] = lsp_bindings
# Attach tool references for tool-bearing actors.
tools_raw = data.get("tools", []) or []
if tools_raw:
agent_config["tools"] = tools_raw
# M6: propagate context_view.
context_view = data.get("context_view")
if context_view is not None:
agent_config["context_view"] = context_view
# M7: propagate memory and context configuration objects.
memory_raw = data.get("memory")
if isinstance(memory_raw, dict):
agent_config["memory"] = memory_raw
context_raw = data.get("context")
if isinstance(context_raw, dict):
agent_config["context"] = context_raw
# M8: propagate lsp_capabilities and lsp_context_enrichment.
lsp_caps = data.get("lsp_capabilities")
if lsp_caps is not None:
agent_config["lsp_capabilities"] = lsp_caps
lsp_enrichment = data.get("lsp_context_enrichment")
if isinstance(lsp_enrichment, dict):
agent_config["lsp_context_enrichment"] = lsp_enrichment
# m2: propagate env_vars.
env_vars = data.get("env_vars")
if isinstance(env_vars, dict):
agent_config["env_vars"] = env_vars
# m3: propagate response_format.
response_format = data.get("response_format")
if isinstance(response_format, dict):
agent_config["response_format"] = response_format
if actor_type in ("llm", "tool"):
# Single-agent synthesis.
rc.agents[actor_name] = AgentConfig(
name=actor_name,
type=actor_type,
config=agent_config,
)
elif actor_type == "graph":
# Map route nodes → agents and edges → graph routes.
route_raw = data.get("route")
if not isinstance(route_raw, dict):
# m7: graph actor without route is a configuration error.
raise ConfigurationError(
f"v3 graph actor '{actor_name}' requires a 'route' "
f"mapping but none was provided."
)
nodes_list = route_raw.get("nodes") or []
edges_list = route_raw.get("edges") or []
entry_node = route_raw.get("entry_node") or "start"
nodes_map: dict[str, dict[str, Any]] = {}
for node in nodes_list:
if not isinstance(node, dict):
continue
node_id = str(node.get("id") or "")
if not node_id:
continue
# C3: guard against config: null producing TypeError.
raw_config = node.get("config")
node_config = dict(raw_config) if isinstance(raw_config, dict) else {}
node_config.setdefault("model", model)
node_config.setdefault("provider", provider)
# M9: copy skills list to avoid shared mutable reference.
if skills:
node_config.setdefault("skills", list(skills))
# M5: propagate LSP bindings to graph nodes (defensive copy).
if lsp_bindings is not None:
lsp_copy = (
list(lsp_bindings)
if isinstance(lsp_bindings, list)
else dict(lsp_bindings)
)
node_config.setdefault("lsp", lsp_copy)
nodes_map[node_id] = node_config
# Create a reactive agent for each graph node.
rc.agents[node_id] = AgentConfig(
name=node_id,
type=str(node.get("type") or "agent"),
config=node_config,
)
# m5: validate entry_node against nodes_map.
if entry_node not in nodes_map:
raise ConfigurationError(
f"v3 graph actor '{actor_name}': entry_node "
f"'{entry_node}' not found in route nodes "
f"{sorted(nodes_map.keys())}."
)
# C1: use "source"/"target" keys matching RouteConfig.to_graph_config().
edge_entries: list[dict[str, Any]] = []
for edge in edges_list:
if not isinstance(edge, dict):
logger.warning(
"v3 graph actor %s: skipping non-dict edge entry: %r",
actor_name,
edge,
)
continue
from_node = edge.get("from_node", "")
to_node = edge.get("to_node", "")
# m6: skip edges with empty source or target.
if not from_node or not to_node:
logger.warning(
"v3 graph actor %s: skipping edge with missing "
"from_node=%r or to_node=%r",
actor_name,
from_node,
to_node,
)
continue
edge_entries.append(
{
"source": from_node,
"target": to_node,
"condition": edge.get("condition"),
}
)
# m1: propagate exit_nodes into route metadata.
exit_nodes = route_raw.get("exit_nodes", [])
route_metadata: dict[str, Any] = {
"v3_actor": actor_name,
"skills": skills,
}
if exit_nodes:
route_metadata["exit_nodes"] = exit_nodes
rc.routes[f"{actor_name}_graph"] = RouteConfig(
name=f"{actor_name}_graph",
type=RouteType.GRAPH,
nodes=nodes_map,
edges=edge_entries,
entry_point=entry_node,
metadata=route_metadata,
)
return rc