fix(actor): support v3 Actor YAML schema in CLI registration and execution #9921
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user