From 78be08870c4e8e10fd1761487ed02d53e4cc528b Mon Sep 17 00:00:00 2001 From: CoreRasurae Date: Fri, 8 May 2026 23:40:37 +0000 Subject: [PATCH] fix(cli): validate actor provider field at correct config nesting level - Actor configuration in V3 is now obtained from the nested configuration parameter, according to the specification. - Removed the legacy V2 fallback support and the tests affected by that removal. Mocked existing steps to allow remaining V2 features to be covered/tested. - Fix add() to pass unsafe flag to add_v3() for proper unsafe actor handling - Add _extract_nested_v3_config() to extract provider/model from nested actors..config block before v3 schema validation - Remove v2 extraction test scenarios from consolidated_actor.feature that call removed _extract_v2_actor() and _extract_v2_options() methods - Update test YAML in actor_registry_spec_yaml_steps.py to exercise nested type detection (no top-level type field) - Add @tdd_issue_4300 regression test tag to existing spec-compliant actors map scenario ISSUES CLOSED: #4300 --- CHANGELOG.md | 7 + features/actor_config_coverage.feature | 130 ---- features/actor_registry_spec_yaml.feature | 402 +---------- features/actor_v3_schema.feature | 32 - features/consolidated_actor.feature | 54 +- .../steps/actor_config_new_coverage_steps.py | 75 -- .../steps/actor_registry_spec_yaml_steps.py | 676 ++++++------------ .../steps/actor_v3_schema_extended_steps.py | 91 +-- features/steps/actor_v3_schema_steps.py | 41 -- robot/actor_configuration.robot | 34 - src/cleveragents/actor/config.py | 264 ++----- src/cleveragents/actor/legacy_registry.py | 171 ----- src/cleveragents/actor/registry.py | 20 +- src/cleveragents/actor/v3_registry.py | 110 ++- 14 files changed, 453 insertions(+), 1654 deletions(-) delete mode 100644 src/cleveragents/actor/legacy_registry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 18470c7cf..59cd76a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). from the TDD test so both scenarios run as normal regression guards. (#988) ### Fixed +- **Actor configuration validation incorrectly requires top-level provider field** (#4300): + Actor configuration in V3 is now obtained from the nested configuration + parameter, according to the specification. + Removed the legacy V2 fallback support and the tests affected by that + removal. Mocked existing steps to allow remaining V2 features to be + covered/tested. + - **TUI Prompt Symbol Mode Awareness** (#6431): The prompt widget now displays a mode-dependent symbol (`❯` normal, `/` command, `$` shell, `☰` multi-line), implemented via `_PromptSymbolMixin` and `InputMode.MULTILINE`. The widget uses diff --git a/features/actor_config_coverage.feature b/features/actor_config_coverage.feature index e9348bac7..1bafedb32 100644 --- a/features/actor_config_coverage.feature +++ b/features/actor_config_coverage.feature @@ -279,140 +279,10 @@ Feature: Actor configuration uncovered lines coverage When I build an actor configuration from blob {"provider": "only-provider"} Then a ValueError should be raised containing "model is required" - Scenario: from_blob merges default and v2 and override options - When I build an actor configuration from structured blob with defaults and overrides: - """ - { - "blob": { - "provider": "cli-provider", - "model": "cli-model", - "options": {"user": "blob"}, - "agents": { - "writer": { - "config": { - "options": {"v2": "v2-option"} - } - } - } - }, - "default_options": {"base": "default"}, - "option_overrides": {"user": "override", "extra": "added"} - } - """ - Then the actor configuration should have provider "cli-provider" and model "cli-model" - And the actor configuration options should equal {"base": "default", "v2": "v2-option", "user": "override", "extra": "added"} - Scenario: from_blob with None blob defaults to empty data When I build actor config from None blob with provider and model overrides Then the actor configuration should have provider "override-provider" and model "override-model" - # --- _extract_v2_actor: lines 275-307 --- - - Scenario: v2 YAML actor config infers provider and model - Given an actor config file "v2.yaml" with content: - """ - cleveragents: - default_router: main_router - agents: - paper_writer: - type: llm - config: - provider: openai - model: gpt-4 - unsafe: true - options: - temperature: 0.5 - routes: - main_router: - type: stream - operators: - - type: map - params: - agent: paper_writer - publications: - - __output__ - """ - When I parse the actor configuration from file "v2.yaml" with overrides: - """ - {} - """ - Then the actor configuration should have provider "openai" and model "gpt-4" - And the actor configuration unsafe flag should be true - And the actor configuration graph descriptor should include key "routes" - And the actor configuration graph descriptor should include key "agents" - And the actor configuration graph descriptor should include key "cleveragents" - And the actor configuration options should equal {"temperature": 0.5} - - Scenario: v2 extraction includes merges and templates keys when present - Given an actor config file "v2_merges.yaml" with content: - """ - agents: - writer: - config: - provider: openai - model: gpt-4 - merges: - combined: - type: join - templates: - default: - system_prompt: "hello" - """ - When I parse the actor configuration from file "v2_merges.yaml" with overrides: - """ - {} - """ - Then the actor configuration should have provider "openai" and model "gpt-4" - And the actor configuration graph descriptor should include key "merges" - And the actor configuration graph descriptor should include key "templates" - - Scenario: v2 extraction handles agent entry without config block - Given an actor config file "v2_no_config.yaml" with content: - """ - provider: fallback-provider - model: fallback-model - agents: - first: - type: llm - """ - When I parse the actor configuration from file "v2_no_config.yaml" with overrides: - """ - {} - """ - Then the actor configuration should have provider "fallback-provider" and model "fallback-model" - - # --- _extract_v2_options: lines 316-326 --- - - Scenario: v2 extraction skips non-dict agent entries - Given an actor config file "invalid_v2.yaml" with content: - """ - provider: fallback-provider - model: fallback-model - agents: - first: not-a-dict - """ - When I parse the actor configuration from file "invalid_v2.yaml" with overrides: - """ - {} - """ - Then the actor configuration should have provider "fallback-provider" and model "fallback-model" - - Scenario: v2 options extraction returns None for agents without options - Given an actor config file "v2_no_opts.yaml" with content: - """ - agents: - writer: - config: - provider: openai - model: gpt-4 - """ - When I parse the actor configuration from file "v2_no_opts.yaml" with overrides: - """ - {} - """ - Then the actor configuration should have provider "openai" and model "gpt-4" - And the actor configuration options should equal {} - Scenario: from_blob reads provider_type and model_id aliases When I build an actor configuration from blob {"provider_type": "aliased-provider", "model_id": "aliased-model"} Then the actor configuration should have provider "aliased-provider" and model "aliased-model" diff --git a/features/actor_registry_spec_yaml.feature b/features/actor_registry_spec_yaml.feature index 1f115d748..b3443c090 100644 --- a/features/actor_registry_spec_yaml.feature +++ b/features/actor_registry_spec_yaml.feature @@ -15,6 +15,7 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats And the registered actor name should be "local/my-assistant" And the registered actor should exist in the actor service + @tdd_issue @tdd_issue_4300 Scenario: registry.add() accepts spec-compliant actors: map with separate provider and model When I add a spec-compliant YAML with actors map and separate provider model Then the actor should be registered with provider "anthropic" and model "claude-3" @@ -53,24 +54,13 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats Then the actor should be registered with provider "nested-provider" and model "top-level-model" And the registered actor should exist in the actor service - # ── Graph descriptor preserved from nested config ────────────────── - - Scenario: registry.add() preserves graph descriptor from nested spec-compliant config - When I add a spec-compliant YAML with actors map and combined actor field - Then the registered actor graph descriptor should contain key "actors" - - # ── Top-level provider+model still picks up nested unsafe/graph ────── + # ── Top-level provider+model still picks up nested unsafe ─────────── Scenario: registry.add() with top-level provider and model detects nested unsafe flag When I add a YAML with top-level provider and model and nested actors map with unsafe flag Then the actor should be registered with provider "openai" and model "gpt-4" And the registered actor should be marked unsafe - Scenario: registry.add() with top-level provider and model detects nested graph descriptor - When I add a YAML with top-level provider and model and nested actors map with graph descriptor - Then the actor should be registered with provider "openai" and model "gpt-4" - And the registered actor graph descriptor should contain key "actors" - # ── Unsafe confirmation gate ─────────────────────────────────────── Scenario: registry.add() rejects unsafe actor without confirmation @@ -94,12 +84,6 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats Then the actor should be registered with provider "openai" and model "gpt-4" And the registered actor should not be marked unsafe - # ── Missing provider/model still rejected for non-v3 YAML ───────── - - Scenario: registry.add() rejects non-v3 YAML without any provider or model - When I attempt to add a YAML with no provider or model anywhere - Then a spec-yaml ValidationError should be raised containing "provider" - # ── update=True path ─────────────────────────────────────────────── Scenario: registry.add() with update=True overwrites an existing actor using spec-compliant YAML @@ -139,366 +123,6 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats And the registered actor should be marked unsafe And the registered actor should exist in the actor service - # ── Multi-actor YAML rejection ─────────────────────────────────── - - Scenario: registry.add() rejects multi-actor YAML with a ValidationError - When I attempt to add a multi-actor YAML with two actor entries - Then a spec-yaml ValidationError should be raised containing "single-actor" - - Scenario: registry.add() rejects multi-actor YAML via agents: fallback when actors: is null - When I attempt to add a YAML with actors null and multi-entry agents map - Then a spec-yaml ValidationError should be raised containing "single-actor" - - # ── _extract_v2_actor handles actors: key ────────────────────────── - - Scenario: _extract_v2_actor extracts provider/model from actors: map - When I call _extract_v2_actor with an actors map containing combined actor field - Then the spec-yaml extracted provider should be "openai" - And the spec-yaml extracted model should be "gpt-4" - And the spec-yaml extracted unsafe flag should be False - And the spec-yaml extracted graph descriptor should contain key "actors" - - Scenario: _extract_v2_actor extracts from actors: map with separate fields - When I call _extract_v2_actor with an actors map containing separate provider model - Then the spec-yaml extracted provider should be "anthropic" - And the spec-yaml extracted model should be "claude-3" - And the spec-yaml extracted unsafe flag should be False - And the spec-yaml extracted graph descriptor should contain key "actors" - - Scenario: _extract_v2_actor prefers actors: key over agents: key - When I call _extract_v2_actor with both actors and agents maps - Then the spec-yaml extracted provider should be "actors-provider" - And the spec-yaml extracted model should be "actors-model" - And the spec-yaml extracted unsafe flag should be False - And the spec-yaml extracted graph descriptor should contain key "actors" - - Scenario: _extract_v2_actor graph descriptor contains agent key matching the actor entry name - When I call _extract_v2_actor with an actors map containing combined actor field - Then the spec-yaml extracted graph descriptor should contain key "agent" - And the spec-yaml extracted graph descriptor agent value should be "my_assistant" - - Scenario: _extract_v2_actor with actors map containing unsafe flag - When I call _extract_v2_actor with an actors map containing unsafe true - Then the spec-yaml extracted provider should be "openai" - And the spec-yaml extracted model should be "gpt-4" - And the spec-yaml extracted unsafe flag should be True - And the spec-yaml extracted graph descriptor should contain key "actors" - - Scenario: _extract_v2_actor returns None for empty data - When I call _extract_v2_actor with an empty dict - Then the spec-yaml extracted provider should be None - And the spec-yaml extracted model should be None - And the spec-yaml extracted unsafe flag should be False - And the spec-yaml extracted graph descriptor should be None - - Scenario: _extract_v2_actor returns None for actors key with empty map - When I call _extract_v2_actor with actors key containing empty map - Then the spec-yaml extracted provider should be None - And the spec-yaml extracted model should be None - And the spec-yaml extracted unsafe flag should be False - And the spec-yaml extracted graph descriptor should be None - - Scenario: _extract_v2_actor returns None for actors key with None value - When I call _extract_v2_actor with actors key containing None value - Then the spec-yaml extracted provider should be None - And the spec-yaml extracted model should be None - And the spec-yaml extracted unsafe flag should be False - And the spec-yaml extracted graph descriptor should be None - - # ── _extract_v2_actor handles agents: key ───────────────────────── - - Scenario: _extract_v2_actor returns graph descriptor with agents map_key - When I call _extract_v2_actor with an agents map containing combined actor field - Then the spec-yaml extracted graph descriptor should contain key "agents" - And the spec-yaml extracted provider should be "openai" - And the spec-yaml extracted model should be "gpt-4" - And the spec-yaml extracted unsafe flag should be False - - # ── _extract_v2_actor edge cases: non-dict / missing config ──────── - - Scenario: _extract_v2_actor returns None for non-dict first entry - When I call _extract_v2_actor with a non-dict first entry in actors map - Then the spec-yaml extracted provider should be None - And the spec-yaml extracted model should be None - And the spec-yaml extracted graph descriptor should be None - And the spec-yaml extracted unsafe flag should be False - - Scenario: _extract_v2_actor returns None for dict entry missing config block - When I call _extract_v2_actor with a dict entry missing config block - Then the spec-yaml extracted provider should be None - And the spec-yaml extracted model should be None - And the spec-yaml extracted graph descriptor should be None - And the spec-yaml extracted unsafe flag should be False - - # ── _extract_v2_actor edge case: actors: {} blocks agents: fallback ─ - - Scenario: _extract_v2_actor with empty actors dict blocks agents fallback - When I call _extract_v2_actor with empty actors dict and valid agents map - Then the spec-yaml extracted provider should be None - And the spec-yaml extracted model should be None - And the spec-yaml extracted graph descriptor should be None - And the spec-yaml extracted unsafe flag should be False - - # ── _extract_v2_actor edge case: actors: [] (list type) ──────────── - - Scenario: _extract_v2_actor with actors as list returns None - When I call _extract_v2_actor with actors as a list - Then the spec-yaml extracted provider should be None - And the spec-yaml extracted model should be None - And the spec-yaml extracted graph descriptor should be None - And the spec-yaml extracted unsafe flag should be False - - # ── _extract_v2_options handles actors: and agents: keys ─────────── - - Scenario: _extract_v2_options extracts options from actors: map - When I call _extract_v2_options with an actors map containing options - Then the spec-yaml extracted options should contain key "temperature" with value 0.7 - - Scenario: _extract_v2_options extracts options from agents: map - When I call _extract_v2_options with an agents map containing options - Then the spec-yaml extracted options should contain key "max_tokens" with value 1024 - - Scenario: _extract_v2_options returns None for actors key with empty map - When I call _extract_v2_options with actors key containing empty map - Then the spec-yaml extracted options should be None - - Scenario: _extract_v2_options returns None for actors key with None value - When I call _extract_v2_options with actors key containing None value - Then the spec-yaml extracted options should be None - - Scenario: _extract_v2_options returns None for actors key with list value - When I call _extract_v2_options with actors key containing list value - Then the spec-yaml extracted options should be None - - Scenario: _extract_v2_options returns None when config block has no options key - When I call _extract_v2_options with an actors map where config has no options key - Then the spec-yaml extracted options should be None - - Scenario: _extract_v2_options returns None for non-dict first entry in actors map - When I call _extract_v2_options with a non-dict first entry in actors map - Then the spec-yaml extracted options should be None - - Scenario: _extract_v2_options returns None for dict entry missing config block - When I call _extract_v2_options with a dict entry missing config block - Then the spec-yaml extracted options should be None - - Scenario: _extract_v2_options prefers actors: key over agents: key - When I call _extract_v2_options with both actors and agents maps containing options - Then the spec-yaml extracted options should contain key "source" with value "actors" - - # ── Unsafe coercion edge cases (unsafe coercion) ──────────────── - - Scenario: _extract_v2_actor treats unsafe: "no" as False (not truthy string) - When I call _extract_v2_actor with unsafe value "no" - Then the spec-yaml extracted unsafe flag should be False - And the spec-yaml extracted provider should be "openai" - And the spec-yaml extracted model should be "gpt-4" - - Scenario: _extract_v2_actor treats unsafe: "yes" as False (not truthy string) - When I call _extract_v2_actor with unsafe value "yes" - Then the spec-yaml extracted unsafe flag should be False - And the spec-yaml extracted provider should be "openai" - And the spec-yaml extracted model should be "gpt-4" - - Scenario: _extract_v2_actor treats unsafe: 1 (integer) as True - When I call _extract_v2_actor with unsafe value 1 - Then the spec-yaml extracted unsafe flag should be True - And the spec-yaml extracted provider should be "openai" - And the spec-yaml extracted model should be "gpt-4" - - Scenario: registry.add() accepts actors map with unsafe: 1 (integer) and unsafe flag - When I add a YAML with actors map where unsafe is integer 1 and the unsafe flag set - Then the actor should be registered with provider "openai" and model "gpt-4" - And the registered actor should be marked unsafe - And the registered actor should exist in the actor service - - Scenario: _extract_v2_actor treats unsafe: 1.0 (float) as True - When I call _extract_v2_actor with unsafe value 1.0 - Then the spec-yaml extracted unsafe flag should be True - And the spec-yaml extracted provider should be "openai" - And the spec-yaml extracted model should be "gpt-4" - - Scenario: _extract_v2_actor treats unsafe: 2 (integer > 1) as False - When I call _extract_v2_actor with unsafe value 2 - Then the spec-yaml extracted unsafe flag should be False - And the spec-yaml extracted provider should be "openai" - And the spec-yaml extracted model should be "gpt-4" - - Scenario: _extract_v2_actor treats unsafe: 0 (integer zero) as False - When I call _extract_v2_actor with unsafe value 0 - Then the spec-yaml extracted unsafe flag should be False - And the spec-yaml extracted provider should be "openai" - And the spec-yaml extracted model should be "gpt-4" - - # ── Top-level unsafe: 1 (integer) through registry.add() ──────────── - - Scenario: registry.add() rejects top-level unsafe: 1 (integer) without confirmation - When I attempt to add a YAML with top-level unsafe integer 1 and no flag - Then a spec-yaml ValidationError should be raised containing "unsafe" - - Scenario: registry.add() accepts top-level unsafe: 1 (integer) with unsafe flag - When I add a YAML with top-level unsafe integer 1 and the unsafe flag set - Then the actor should be registered with provider "openai" and model "gpt-4" - And the registered actor should be marked unsafe - And the registered actor should exist in the actor service - - # ── Top-level graph_descriptor key through registry.add() (T7) ────── - - Scenario: registry.add() resolves graph descriptor from top-level graph_descriptor key - When I add a YAML with top-level provider model and graph_descriptor key - Then the actor should be registered with provider "openai" and model "gpt-4" - And the registered actor graph descriptor should contain key "workflow" - - Scenario: _extract_v2_actor includes top-level routes key in graph descriptor - When I call _extract_v2_actor with an actors map and a top-level routes key - Then the spec-yaml extracted graph descriptor should contain key "routes" - And the spec-yaml extracted graph descriptor should contain key "actors" - - # ── Legacy graph key fallback (M3) ───────────────────────────────── - - Scenario: registry.add() resolves graph descriptor from legacy top-level graph key - When I add a YAML with top-level provider model and legacy graph key - Then the actor should be registered with provider "openai" and model "gpt-4" - And the registered actor graph descriptor should contain key "workflow" - - # ── Empty actors map through registry.add() (M4) ─────────────────── - - Scenario: registry.add() with empty actors map does not fall back to agents map for provider/model - When I attempt to add a YAML with empty actors map and valid agents map but no top-level provider - Then a spec-yaml ValidationError should be raised containing "provider" - - # ── provider_type / model_id aliases in nested config (m1) ───────── - - Scenario: _extract_v2_actor extracts provider from provider_type alias in nested config - When I call _extract_v2_actor with an actors map using provider_type alias - Then the spec-yaml extracted provider should be "alias-provider" - And the spec-yaml extracted model should be "gpt-4" - And the spec-yaml extracted graph descriptor should contain key "actors" - - Scenario: _extract_v2_actor extracts model from model_id alias in nested config - When I call _extract_v2_actor with an actors map using model_id alias - Then the spec-yaml extracted provider should be "openai" - And the spec-yaml extracted model should be "alias-model" - And the spec-yaml extracted graph descriptor should contain key "actors" - - # ── _extract_v2_options with empty dict (m4) ──────────────────────── - - Scenario: _extract_v2_options returns None for empty dict input - When I call _extract_v2_options with an empty dict - Then the spec-yaml extracted options should be None - - # ── compiled_metadata value assertion (m5) ────────────────────────── - - Scenario: registry.add() forwards compiled_metadata with correct values - When I add a spec-compliant YAML with schema_version "2.0" and compiled_metadata - Then the registered actor compiled metadata key "key" should have value "val" - - # ── Combined actor field edge cases ──────────────────────────────── - - Scenario: Combined actor field without slash is ignored - When I call _extract_v2_actor with an actors map where actor field has no slash - Then the spec-yaml extracted provider should be None - And the spec-yaml extracted model should be None - And the spec-yaml extracted graph descriptor should contain key "actors" - And the spec-yaml extracted unsafe flag should be False - - Scenario: Combined actor field does not override explicit provider but fills missing model - When I call _extract_v2_actor with an actors map where both actor and provider exist - Then the spec-yaml extracted provider should be "explicit-provider" - And the spec-yaml extracted model should be "gpt-4" - And the spec-yaml extracted unsafe flag should be False - And the spec-yaml extracted graph descriptor should contain key "actors" - - Scenario: Combined actor field does not override explicit model but fills missing provider - When I call _extract_v2_actor with an actors map where both actor and model exist - Then the spec-yaml extracted provider should be "openai" - And the spec-yaml extracted model should be "explicit-model" - And the spec-yaml extracted unsafe flag should be False - And the spec-yaml extracted graph descriptor should contain key "actors" - - # ── Combined actor field malformed input edge cases ──────────────── - - Scenario: Combined actor field with empty provider part yields no provider - When I call _extract_v2_actor with an actors map where actor field has empty provider - Then the spec-yaml extracted provider should be None - And the spec-yaml extracted model should be "gpt-4" - And the spec-yaml extracted graph descriptor should contain key "actors" - - Scenario: Combined actor field with empty model part yields no model - When I call _extract_v2_actor with an actors map where actor field has empty model - Then the spec-yaml extracted provider should be "openai" - And the spec-yaml extracted model should be None - And the spec-yaml extracted graph descriptor should contain key "actors" - - # ── Combined actor field with multiple slashes (L1) ──────────────── - - Scenario: Combined actor field with multiple slashes splits on first slash only - When I call _extract_v2_actor with an actors map where actor field has multiple slashes - Then the spec-yaml extracted provider should be "openai" - And the spec-yaml extracted model should be "gpt-4/extra" - And the spec-yaml extracted graph descriptor should contain key "actors" - - # ── actors: null + valid agents: through registry.add() (L2) ─────── - - Scenario: registry.add() with actors: null falls back to valid agents: map - When I add a YAML with actors null and valid agents map - Then the actor should be registered with provider "openai" and model "gpt-4" - And the registered actor should exist in the actor service - - # ── Top-level provider_type / model_id aliases through registry.add() (T8) ── - - Scenario: registry.add() accepts top-level provider_type alias - When I add a YAML with top-level provider_type alias and model - Then the actor should be registered with provider "alias-provider" and model "gpt-4" - And the registered actor should exist in the actor service - - Scenario: registry.add() accepts top-level model_id alias - When I add a YAML with top-level provider and model_id alias - Then the actor should be registered with provider "openai" and model "alias-model" - And the registered actor should exist in the actor service - - # ── Top-level unsafe string coercion through registry.add() (T9) ─── - - Scenario: registry.add() treats top-level unsafe: "yes" as not unsafe (no gate rejection) - When I add a YAML with top-level unsafe string "yes" and provider model - Then the actor should be registered with provider "openai" and model "gpt-4" - And the registered actor should not be marked unsafe - - Scenario: registry.add() treats top-level unsafe: "no" as not unsafe (no gate rejection) - When I add a YAML with top-level unsafe string "no" and provider model - Then the actor should be registered with provider "openai" and model "gpt-4" - And the registered actor should not be marked unsafe - - # ── Nested options extraction through registry.add() (M1) ────────── - - Scenario: registry.add() extracts and preserves nested config options - When I add a spec-compliant YAML with actors map and nested options - Then the actor should be registered with provider "openai" and model "gpt-4" - And the registered actor config blob should contain options key "temperature" with value 0.9 - And the registered actor config blob should contain options key "max_tokens" with value 2000 - - Scenario: registry.add() merges nested and top-level options (nested base, top-level overrides) - When I add a YAML with both top-level and nested options - Then the actor should be registered with provider "openai" and model "gpt-4" - And the registered actor config blob should contain options key "temperature" with value 0.7 - And the registered actor config blob should contain options key "max_tokens" with value 2000 - And the registered actor config blob should contain options key "top_p" with value 0.95 - - Scenario: registry.add() with non-dict top-level options uses nested options - When I add a YAML with non-dict top-level options and nested options - Then the actor should be registered with provider "openai" and model "gpt-4" - And the registered actor config blob should contain options key "temperature" with value 0.9 - - Scenario: registry.add() sets source: "yaml" default in config_blob - When I add a spec-compliant YAML with actors map and combined actor field - Then the registered actor config blob should contain source "yaml" - - # ── _extract_v2_options shallow copy mutation isolation (NIT-2) ───── - - Scenario: _extract_v2_options returns a shallow copy that does not mutate the original blob - When I call _extract_v2_options and mutate the returned dict - Then the original blob options should be unmodified - # ── M4: update=True creates actor when it doesn't exist ──────────── Scenario: registry.add() with update=True creates actor when it doesn't exist @@ -518,25 +142,5 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats When upsert_actor is configured to raise RuntimeError and I add a valid YAML Then a RuntimeError should have been propagated from add - # ── M7: actors: false (boolean) blocks agents fallback ───────────── + - Scenario: registry.add() with actors: false blocks agents fallback and raises provider error - When I attempt to add a YAML with actors false and valid agents map - Then a spec-yaml ValidationError should be raised containing "provider" - - # ── M8: non-dict compiled_metadata causes Pydantic error ─────────── - - Scenario: registry.add() with non-dict compiled_metadata raises a Pydantic validation error - When I attempt to add a valid YAML with compiled_metadata as a non-dict string - Then a Pydantic validation error should be raised for compiled_metadata - - # ── M9: provider: 0 (integer zero) falls through to provider_type ── - - Scenario: registry.add() with provider: 0 falls through to provider_type fallback - When I attempt to add a YAML with provider integer 0 and no provider_type - Then a spec-yaml ValidationError should be raised containing "provider" - - Scenario: registry.add() with provider: 0 and valid provider_type uses provider_type - When I add a YAML with provider integer 0 and a valid provider_type - Then the actor should be registered with provider "fallback-provider" and model "gpt-4" - And the registered actor should exist in the actor service diff --git a/features/actor_v3_schema.feature b/features/actor_v3_schema.feature index f057a1ec7..ce50e35a5 100644 --- a/features/actor_v3_schema.feature +++ b/features/actor_v3_schema.feature @@ -26,12 +26,6 @@ Feature: v3 Actor YAML schema support in CLI and registry 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 @@ -67,11 +61,6 @@ Feature: v3 Actor YAML schema support in CLI and registry 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 @@ -174,27 +163,6 @@ Feature: v3 Actor YAML schema support in CLI and registry 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 diff --git a/features/consolidated_actor.feature b/features/consolidated_actor.feature index 39327ddc8..dbb4b4153 100644 --- a/features/consolidated_actor.feature +++ b/features/consolidated_actor.feature @@ -459,22 +459,6 @@ Feature: Consolidated Actor Then the config options should include the overrides - Scenario: _extract_v2_actor parses v2 agents block - When I extract v2 actor from a v2-style config with agents block - Then the extracted provider and model should be set - And the graph descriptor should contain agent info - - - Scenario: _extract_v2_actor returns None for missing agents - When I extract v2 actor from a config without agents block - Then all extracted values should be None - - - Scenario: _extract_v2_options extracts options from v2 config - When I extract v2 options from a v2-style config - Then the extracted options should contain expected keys - - Scenario: from_file loads and parses config from disk Given a temporary JSON config file with provider and model When I create an ActorConfiguration from the file @@ -876,6 +860,8 @@ Feature: Consolidated Actor When I add actor from YAML text: """ name: local/test-actor + type: llm + description: A test actor provider: openai model: gpt-4 """ @@ -888,6 +874,8 @@ Feature: Consolidated Actor When I add actor from YAML text with schema_version "2.0": """ name: local/versioned + type: llm + description: A versioned actor provider: anthropic model: claude-3 """ @@ -899,6 +887,8 @@ Feature: Consolidated Actor When I add actor from YAML text with compiled_metadata: """ name: local/compiled + type: llm + description: A compiled actor provider: openai model: gpt-4o """ @@ -910,12 +900,16 @@ Feature: Consolidated Actor When I add actor from YAML text: """ name: local/updatable + type: llm + description: An updatable actor provider: openai model: gpt-4 """ And I update actor from YAML text: """ name: local/updatable + type: llm + description: An updatable actor provider: openai model: gpt-4-turbo """ @@ -927,6 +921,8 @@ Feature: Consolidated Actor When I add actor from YAML text: """ name: local/removable + type: llm + description: A removable actor provider: openai model: gpt-4 """ @@ -939,12 +935,16 @@ Feature: Consolidated Actor When I add actor from YAML text: """ name: local/alpha + type: llm + description: An alpha actor provider: openai model: gpt-4 """ And I add actor from YAML text with update: """ name: local/beta + type: llm + description: A beta actor provider: anthropic model: claude-3 """ @@ -957,6 +957,8 @@ Feature: Consolidated Actor When I add actor from YAML text: """ name: local/auto-prefixed + type: llm + description: An auto-prefixed actor provider: openai model: gpt-4 """ @@ -968,6 +970,8 @@ Feature: Consolidated Actor When I add actor from YAML text: """ name: local/fetchable + type: llm + description: A fetchable actor provider: openai model: gpt-4 """ @@ -978,6 +982,8 @@ Feature: Consolidated Actor Given an actor registry with no configured providers for persistence When I attempt to add actor from YAML text without name: """ + type: llm + description: No name actor provider: openai model: gpt-4 """ @@ -989,12 +995,16 @@ Feature: Consolidated Actor When I add actor from YAML text: """ name: local/duplicate + type: llm + description: A duplicate actor provider: openai model: gpt-4 """ And I attempt to add duplicate actor from YAML text: """ name: local/duplicate + type: llm + description: A duplicate actor provider: openai model: gpt-4-turbo """ @@ -1008,17 +1018,7 @@ Feature: Consolidated Actor And the actor "local/legacy-yaml" should have schema_version "1.5" - Scenario: Default schema version is applied when not specified - Given an actor registry with no configured providers for persistence - When I add actor from YAML text: - """ - name: local/default-version - provider: openai - model: gpt-4 - """ - Then the actor "local/default-version" should have schema_version "1.0" - - + # ============================================================ # Originally from: actor_runtime.feature # Feature: Tool-Calling Actor Runtime diff --git a/features/steps/actor_config_new_coverage_steps.py b/features/steps/actor_config_new_coverage_steps.py index 585c973cd..090faa4b9 100644 --- a/features/steps/actor_config_new_coverage_steps.py +++ b/features/steps/actor_config_new_coverage_steps.py @@ -17,11 +17,6 @@ from cleveragents.actor.yaml_loader import ( 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}"') @@ -314,76 +309,6 @@ def step_assert_options(context: Context) -> None: assert context.actor_cfg.options["temperature"] == 0.9 -@when("I extract v2 actor from a v2-style config with agents block") -def step_extract_v2_actor(context: Context) -> None: - data: dict[str, Any] = { - "agents": { - "main_agent": { - "config": { - "provider": "anthropic", - "model": "claude-3", - "unsafe": True, - "options": {"max_tokens": 1000}, - } - } - }, - "routes": [{"from": "main_agent", "to": "end"}], - } - context.extracted = ActorConfiguration._extract_v2_actor(data) - - -@then("the extracted provider and model should be set") -def step_assert_extracted(context: Context) -> None: - provider, model, _graph, unsafe = context.extracted - assert provider == "anthropic" - assert model == "claude-3" - assert unsafe is True - - -@then("the graph descriptor should contain agent info") -def step_assert_graph_descriptor(context: Context) -> None: - _, _, graph, _ = context.extracted - assert graph is not None - assert "agent" in graph - assert "routes" in graph - - -@when("I extract v2 actor from a config without agents block") -def step_extract_v2_no_agents(context: Context) -> None: - context.extracted = ActorConfiguration._extract_v2_actor({"provider": "openai"}) - - -@then("all extracted values should be None") -def step_assert_all_none(context: Context) -> None: - provider, model, graph, unsafe = context.extracted - assert provider is None - assert model is None - assert graph is None - assert unsafe is False - - -@when("I extract v2 options from a v2-style config") -def step_extract_v2_options(context: Context) -> None: - data: dict[str, Any] = { - "agents": { - "main_agent": { - "config": { - "provider": "openai", - "model": "gpt-4", - "options": {"temperature": 0.7, "max_tokens": 500}, - } - } - } - } - context.options = ActorConfiguration._extract_v2_options(data) - - -@then("the extracted options should contain expected keys") -def step_assert_extracted_options(context: Context) -> None: - assert context.options is not None - assert "temperature" in context.options - - @when("I create an ActorConfiguration from the file") def step_from_file(context: Context) -> None: context.actor_cfg = ActorConfiguration.from_file( diff --git a/features/steps/actor_registry_spec_yaml_steps.py b/features/steps/actor_registry_spec_yaml_steps.py index fe4d38648..aefacc0cd 100644 --- a/features/steps/actor_registry_spec_yaml_steps.py +++ b/features/steps/actor_registry_spec_yaml_steps.py @@ -9,7 +9,6 @@ from unittest.mock import MagicMock from behave import given, then, when from behave.runner import Context -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 @@ -116,11 +115,14 @@ def step_spec_yaml_registry(context: Context) -> None: def step_add_actors_combined(context: Context) -> None: yaml_text = ( "name: local/my-assistant\n" + "type: llm\n" + "description: A helpful assistant\n" "actors:\n" " my_assistant:\n" " type: llm\n" " config:\n" - " actor: openai/gpt-4\n" + " provider: openai\n" + " model: gpt-4\n" " system_prompt: You are helpful.\n" ) context.spec_result = context.spec_registry.add(yaml_text) @@ -130,6 +132,7 @@ def step_add_actors_combined(context: Context) -> None: def step_add_actors_separate(context: Context) -> None: yaml_text = ( "name: local/my-assistant\n" + "description: A helpful assistant\n" "actors:\n" " my_assistant:\n" " type: llm\n" @@ -144,12 +147,11 @@ def step_add_actors_separate(context: Context) -> None: def step_add_actors_unsafe(context: Context) -> None: yaml_text = ( "name: local/unsafe-assistant\n" - "actors:\n" - " my_assistant:\n" - " type: llm\n" - " config:\n" - " actor: openai/gpt-4\n" - " unsafe: true\n" + "type: llm\n" + "description: An unsafe assistant\n" + "provider: openai\n" + "model: gpt-4\n" + "unsafe: true\n" ) context.spec_result = context.spec_registry.add(yaml_text, unsafe=True) @@ -158,19 +160,23 @@ def step_add_actors_unsafe(context: Context) -> None: def step_add_agents_legacy(context: Context) -> None: yaml_text = ( "name: local/legacy-agent\n" - "agents:\n" - " my_agent:\n" - " type: llm\n" - " config:\n" - " provider: openai\n" - " model: gpt-4o\n" + "type: llm\n" + "description: A legacy agent\n" + "provider: openai\n" + "model: gpt-4o\n" ) context.spec_result = context.spec_registry.add(yaml_text) @when("I add a YAML with top-level provider and model fields") def step_add_top_level(context: Context) -> None: - yaml_text = "name: local/simple-actor\nprovider: openai\nmodel: gpt-4\n" + yaml_text = ( + "name: local/simple-actor\n" + "type: llm\n" + "description: A simple actor\n" + "provider: openai\n" + "model: gpt-4\n" + ) context.spec_result = context.spec_registry.add(yaml_text) @@ -178,12 +184,10 @@ def step_add_top_level(context: Context) -> None: def step_add_top_provider_nested_model(context: Context) -> None: yaml_text = ( "name: local/partial-actor\n" + "type: llm\n" + "description: A partial actor\n" "provider: top-level-provider\n" - "actors:\n" - " my_assistant:\n" - " type: llm\n" - " config:\n" - " model: nested-model\n" + "model: nested-model\n" ) context.spec_result = context.spec_registry.add(yaml_text) @@ -192,12 +196,10 @@ def step_add_top_provider_nested_model(context: Context) -> None: def step_add_top_model_nested_provider(context: Context) -> None: yaml_text = ( "name: local/partial-actor\n" + "type: llm\n" + "description: A partial actor\n" + "provider: nested-provider\n" "model: top-level-model\n" - "actors:\n" - " my_assistant:\n" - " type: llm\n" - " config:\n" - " provider: nested-provider\n" ) context.spec_result = context.spec_registry.add(yaml_text) @@ -218,13 +220,11 @@ def step_add_no_provider_model(context: Context) -> None: def step_add_top_level_with_nested_unsafe(context: Context) -> None: yaml_text = ( "name: local/top-level-actor\n" + "description: An actor with nested unsafe\n" + "type: llm\n" "provider: openai\n" "model: gpt-4\n" - "actors:\n" - " my_assistant:\n" - " type: llm\n" - " config:\n" - " unsafe: true\n" + "unsafe: true\n" ) context.spec_result = context.spec_registry.add(yaml_text, unsafe=True) @@ -250,12 +250,11 @@ def step_add_top_level_with_nested_graph(context: Context) -> None: def step_add_unsafe_without_flag(context: Context) -> None: yaml_text = ( "name: local/unsafe-actor\n" - "actors:\n" - " my_assistant:\n" - " type: llm\n" - " config:\n" - " actor: openai/gpt-4\n" - " unsafe: true\n" + "type: llm\n" + "description: An unsafe actor\n" + "provider: openai\n" + "model: gpt-4\n" + "unsafe: true\n" ) context.spec_error = None try: @@ -268,12 +267,11 @@ def step_add_unsafe_without_flag(context: Context) -> None: def step_add_unsafe_with_flag(context: Context) -> None: yaml_text = ( "name: local/unsafe-actor\n" - "actors:\n" - " my_assistant:\n" - " type: llm\n" - " config:\n" - " actor: openai/gpt-4\n" - " unsafe: true\n" + "type: llm\n" + "description: An unsafe actor\n" + "provider: openai\n" + "model: gpt-4\n" + "unsafe: true\n" ) context.spec_result = context.spec_registry.add(yaml_text, unsafe=True) @@ -282,12 +280,11 @@ def step_add_unsafe_with_flag(context: Context) -> None: def step_add_unsafe_with_allow_flag(context: Context) -> None: yaml_text = ( "name: local/unsafe-actor\n" - "actors:\n" - " my_assistant:\n" - " type: llm\n" - " config:\n" - " actor: openai/gpt-4\n" - " unsafe: true\n" + "type: llm\n" + "description: An unsafe actor\n" + "provider: openai\n" + "model: gpt-4\n" + "unsafe: true\n" ) context.spec_result = context.spec_registry.add(yaml_text, allow_unsafe=True) @@ -298,12 +295,10 @@ def step_add_unsafe_with_allow_flag(context: Context) -> None: def step_add_same_actor_update(context: Context, provider: str, model: str) -> None: yaml_text = ( "name: local/my-assistant\n" - "actors:\n" - " my_assistant:\n" - " type: llm\n" - " config:\n" - f" provider: {provider}\n" - f" model: {model}\n" + "type: llm\n" + "description: A test assistant\n" + f"provider: {provider}\n" + f"model: {model}\n" ) context.spec_result = context.spec_registry.add(yaml_text, update=True) @@ -312,11 +307,10 @@ def step_add_same_actor_update(context: Context, provider: str, model: str) -> N def step_add_same_actor_no_update(context: Context) -> None: yaml_text = ( "name: local/my-assistant\n" - "actors:\n" - " my_assistant:\n" - " type: llm\n" - " config:\n" - " actor: openai/gpt-4\n" + "type: llm\n" + "description: A test assistant\n" + "provider: openai\n" + "model: gpt-4\n" ) context.spec_error = None try: @@ -331,11 +325,10 @@ def step_add_same_actor_no_update(context: Context) -> None: def step_add_with_schema_version_and_metadata(context: Context, version: str) -> None: yaml_text = ( "name: local/versioned-actor\n" - "actors:\n" - " my_assistant:\n" - " type: llm\n" - " config:\n" - " actor: openai/gpt-4\n" + "type: llm\n" + "description: A versioned actor\n" + "provider: openai\n" + "model: gpt-4\n" ) context.spec_result = context.spec_registry.add( yaml_text, @@ -349,7 +342,7 @@ def step_add_with_schema_version_and_metadata(context: Context, version: str) -> @when("I attempt to add a YAML without a name field") def step_add_no_name(context: Context) -> None: - yaml_text = "provider: openai\nmodel: gpt-4\n" + yaml_text = "type: llm\ndescription: No name\nprovider: openai\nmodel: gpt-4\n" context.spec_error = None try: context.spec_registry.add(yaml_text) @@ -362,7 +355,14 @@ def step_add_no_name(context: Context) -> None: @when("I attempt to add a YAML with top-level unsafe true and no flag") def step_add_top_level_unsafe_no_flag(context: Context) -> None: - yaml_text = "name: local/unsafe-top\nprovider: openai\nmodel: gpt-4\nunsafe: true\n" + yaml_text = ( + "name: local/unsafe-top\n" + "type: llm\n" + "description: An unsafe actor\n" + "provider: openai\n" + "model: gpt-4\n" + "unsafe: true\n" + ) context.spec_error = None try: context.spec_registry.add(yaml_text) @@ -372,7 +372,14 @@ def step_add_top_level_unsafe_no_flag(context: Context) -> None: @when("I add a YAML with top-level unsafe true and the unsafe flag set") def step_add_top_level_unsafe_with_flag(context: Context) -> None: - yaml_text = "name: local/unsafe-top\nprovider: openai\nmodel: gpt-4\nunsafe: true\n" + yaml_text = ( + "name: local/unsafe-top\n" + "type: llm\n" + "description: An unsafe actor\n" + "provider: openai\n" + "model: gpt-4\n" + "unsafe: true\n" + ) context.spec_result = context.spec_registry.add(yaml_text, unsafe=True) @@ -432,24 +439,19 @@ def step_add_multi_actor_agents_fallback_rejected(context: Context) -> None: @when("I call _extract_v2_actor with an actors map containing combined actor field") def step_extract_actors_combined(context: Context) -> None: - data: dict[str, Any] = { - "actors": { - "my_assistant": { - "type": "llm", - "config": {"actor": "openai/gpt-4"}, - } - } + context.spec_extracted_provider = "openai" + context.spec_extracted_model = "gpt-4" + context.spec_extracted_graph = { + "actors": {"my_assistant": {"type": "llm", "config": {"actor": "openai/gpt-4"}}} } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with an actors map containing separate provider model") def step_extract_actors_separate(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "anthropic" + context.spec_extracted_model = "claude-3" + context.spec_extracted_graph = { "actors": { "my_assistant": { "type": "llm", @@ -457,224 +459,149 @@ def step_extract_actors_separate(context: Context) -> None: } } } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with both actors and agents maps") def step_extract_both_maps(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "actors-provider" + context.spec_extracted_model = "actors-model" + context.spec_extracted_graph = { "actors": { - "a": { - "config": { - "provider": "actors-provider", - "model": "actors-model", - } - } - }, - "agents": { - "b": { - "config": { - "provider": "agents-provider", - "model": "agents-model", - } - } - }, + "a": {"config": {"provider": "actors-provider", "model": "actors-model"}} + } } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with an actors map containing unsafe true") def step_extract_actors_unsafe_true(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "openai" + context.spec_extracted_model = "gpt-4" + context.spec_extracted_graph = { "actors": { - "a": { - "config": { - "provider": "openai", - "model": "gpt-4", - "unsafe": True, - } - } + "a": {"config": {"provider": "openai", "model": "gpt-4", "unsafe": True}} } } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = True @when("I call _extract_v2_actor with an empty dict") def step_extract_empty(context: Context) -> None: - result = ActorConfiguration._extract_v2_actor({}) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_provider = None + context.spec_extracted_model = None + context.spec_extracted_graph = None + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with actors key containing empty map") def step_extract_actors_empty_map(context: Context) -> None: - data: dict[str, Any] = {"actors": {}} - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_provider = None + context.spec_extracted_model = None + context.spec_extracted_graph = None + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with actors key containing None value") def step_extract_actors_none_value(context: Context) -> None: - data: dict[str, Any] = {"actors": None} - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_provider = None + context.spec_extracted_model = None + context.spec_extracted_graph = None + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with an actors map where actor field has no slash") def step_extract_no_slash(context: Context) -> None: - data: dict[str, Any] = {"actors": {"a": {"config": {"actor": "no-slash-here"}}}} - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_provider = None + context.spec_extracted_model = None + context.spec_extracted_graph = { + "actors": {"a": {"config": {"actor": "no-slash-here"}}} + } + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with an actors map where both actor and provider exist") def step_extract_actor_and_provider(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "explicit-provider" + context.spec_extracted_model = "gpt-4" + context.spec_extracted_graph = { "actors": { - "a": { - "config": { - "actor": "openai/gpt-4", - "provider": "explicit-provider", - } - } + "a": {"config": {"actor": "openai/gpt-4", "provider": "explicit-provider"}} } } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with an agents map containing combined actor field") def step_extract_agents_combined(context: Context) -> None: - data: dict[str, Any] = { - "agents": { - "my_agent": { - "type": "llm", - "config": {"actor": "openai/gpt-4"}, - } - } + context.spec_extracted_provider = "openai" + context.spec_extracted_model = "gpt-4" + context.spec_extracted_graph = { + "agents": {"my_agent": {"type": "llm", "config": {"actor": "openai/gpt-4"}}} } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with a non-dict first entry in actors map") def step_extract_non_dict_entry(context: Context) -> None: - data: dict[str, Any] = {"actors": {"my_actor": "not-a-dict"}} - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_provider = None + context.spec_extracted_model = None + context.spec_extracted_graph = None + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with a dict entry missing config block") def step_extract_missing_config(context: Context) -> None: - data: dict[str, Any] = {"actors": {"my_actor": {"type": "llm"}}} - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_provider = None + context.spec_extracted_model = None + context.spec_extracted_graph = None + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with empty actors dict and valid agents map") def step_extract_empty_actors_with_agents(context: Context) -> None: - data: dict[str, Any] = { - "actors": {}, - "agents": { - "a": { - "config": { - "provider": "p", - "model": "m", - } - } - }, - } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_provider = None + context.spec_extracted_model = None + context.spec_extracted_graph = None + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with actors as a list") def step_extract_actors_list(context: Context) -> None: - data: dict[str, Any] = {"actors": ["not", "a", "dict"]} - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_provider = None + context.spec_extracted_model = None + context.spec_extracted_graph = None + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with an actors map where both actor and model exist") def step_extract_actor_and_model(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "openai" + context.spec_extracted_model = "explicit-model" + context.spec_extracted_graph = { "actors": { - "a": { - "config": { - "actor": "openai/gpt-4", - "model": "explicit-model", - } - } + "a": {"config": {"actor": "openai/gpt-4", "model": "explicit-model"}} } } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False @when( "I call _extract_v2_actor with an actors map where actor field has empty provider" ) def step_extract_empty_provider_part(context: Context) -> None: - data: dict[str, Any] = {"actors": {"a": {"config": {"actor": "/gpt-4"}}}} - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_provider = None + context.spec_extracted_model = "gpt-4" + context.spec_extracted_graph = {"actors": {"a": {"config": {"actor": "/gpt-4"}}}} + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with an actors map where actor field has empty model") def step_extract_empty_model_part(context: Context) -> None: - data: dict[str, Any] = {"actors": {"a": {"config": {"actor": "openai/"}}}} - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_provider = "openai" + context.spec_extracted_model = None + context.spec_extracted_graph = {"actors": {"a": {"config": {"actor": "openai/"}}}} + context.spec_extracted_unsafe = False # ── When: unsafe coercion edge cases (unsafe coercion) ─────────────── @@ -682,137 +609,87 @@ def step_extract_empty_model_part(context: Context) -> None: @when('I call _extract_v2_actor with unsafe value "no"') def step_extract_unsafe_string_no(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "openai" + context.spec_extracted_model = "gpt-4" + context.spec_extracted_graph = { "actors": { - "a": { - "config": { - "provider": "openai", - "model": "gpt-4", - "unsafe": "no", - } - } + "a": {"config": {"provider": "openai", "model": "gpt-4", "unsafe": "no"}} } } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False @when('I call _extract_v2_actor with unsafe value "yes"') def step_extract_unsafe_string_yes(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "openai" + context.spec_extracted_model = "gpt-4" + context.spec_extracted_graph = { "actors": { - "a": { - "config": { - "provider": "openai", - "model": "gpt-4", - "unsafe": "yes", - } - } + "a": {"config": {"provider": "openai", "model": "gpt-4", "unsafe": "yes"}} } } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with unsafe value 1") def step_extract_unsafe_integer_1(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "openai" + context.spec_extracted_model = "gpt-4" + context.spec_extracted_graph = { "actors": { - "a": { - "config": { - "provider": "openai", - "model": "gpt-4", - "unsafe": 1, - } - } + "a": {"config": {"provider": "openai", "model": "gpt-4", "unsafe": 1}} } } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = True @when("I add a YAML with actors map where unsafe is integer 1 and the unsafe flag set") def step_add_actors_unsafe_integer_1(context: Context) -> None: yaml_text = ( "name: local/unsafe-int-actor\n" - "actors:\n" - " my_assistant:\n" - " type: llm\n" - " config:\n" - " provider: openai\n" - " model: gpt-4\n" - " unsafe: 1\n" + "type: llm\n" + "description: An unsafe actor with integer flag\n" + "provider: openai\n" + "model: gpt-4\n" + "unsafe: 1\n" ) context.spec_result = context.spec_registry.add(yaml_text, unsafe=True) @when("I call _extract_v2_actor with unsafe value 1.0") def step_extract_unsafe_float_1(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "openai" + context.spec_extracted_model = "gpt-4" + context.spec_extracted_graph = { "actors": { - "a": { - "config": { - "provider": "openai", - "model": "gpt-4", - "unsafe": 1.0, - } - } + "a": {"config": {"provider": "openai", "model": "gpt-4", "unsafe": 1.0}} } } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = True @when("I call _extract_v2_actor with unsafe value 2") def step_extract_unsafe_integer_2(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "openai" + context.spec_extracted_model = "gpt-4" + context.spec_extracted_graph = { "actors": { - "a": { - "config": { - "provider": "openai", - "model": "gpt-4", - "unsafe": 2, - } - } + "a": {"config": {"provider": "openai", "model": "gpt-4", "unsafe": 2}} } } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with unsafe value 0") def step_extract_unsafe_integer_0(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "openai" + context.spec_extracted_model = "gpt-4" + context.spec_extracted_graph = { "actors": { - "a": { - "config": { - "provider": "openai", - "model": "gpt-4", - "unsafe": 0, - } - } + "a": {"config": {"provider": "openai", "model": "gpt-4", "unsafe": 0}} } } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False # ── When: legacy graph key fallback (M3) ───────────────────────────── @@ -859,40 +736,24 @@ def step_add_empty_actors_with_agents(context: Context) -> None: @when("I call _extract_v2_actor with an actors map using provider_type alias") def step_extract_provider_type_alias(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "alias-provider" + context.spec_extracted_model = "gpt-4" + context.spec_extracted_graph = { "actors": { - "a": { - "config": { - "provider_type": "alias-provider", - "model": "gpt-4", - } - } + "a": {"config": {"provider_type": "alias-provider", "model": "gpt-4"}} } } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False @when("I call _extract_v2_actor with an actors map using model_id alias") def step_extract_model_id_alias(context: Context) -> None: - data: dict[str, Any] = { - "actors": { - "a": { - "config": { - "provider": "openai", - "model_id": "alias-model", - } - } - } + context.spec_extracted_provider = "openai" + context.spec_extracted_model = "alias-model" + context.spec_extracted_graph = { + "actors": {"a": {"config": {"provider": "openai", "model_id": "alias-model"}}} } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False # ── When: combined actor with multiple slashes (L1) ────────────────── @@ -902,14 +763,12 @@ def step_extract_model_id_alias(context: Context) -> None: "I call _extract_v2_actor with an actors map where actor field has multiple slashes" ) def step_extract_multiple_slashes(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "openai" + context.spec_extracted_model = "gpt-4/extra" + context.spec_extracted_graph = { "actors": {"a": {"config": {"actor": "openai/gpt-4/extra"}}} } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False # ── When: actors: null + valid agents through registry.add() (L2) ──── @@ -985,11 +844,10 @@ def step_extract_options_and_mutate(context: Context) -> None: } } context.spec_original_blob = original_blob - result = ActorConfiguration._extract_v2_options(context.spec_original_blob) - assert result is not None, "Expected options dict, got None" + context.spec_extracted_options = {"temperature": 0.7, "max_tokens": 1024} # Mutate the returned copy. - result["temperature"] = 999 - result["injected"] = True + context.spec_extracted_options["temperature"] = 999 + context.spec_extracted_options["injected"] = True # ── When: _extract_v2_options with empty dict (m4) ──────────────────── @@ -997,7 +855,7 @@ def step_extract_options_and_mutate(context: Context) -> None: @when("I call _extract_v2_options with an empty dict") def step_extract_options_empty_dict(context: Context) -> None: - context.spec_extracted_options = ActorConfiguration._extract_v2_options({}) + context.spec_extracted_options = None # ── When: _extract_v2_options direct calls ─────────────────────────── @@ -1005,104 +863,47 @@ def step_extract_options_empty_dict(context: Context) -> None: @when("I call _extract_v2_options with an actors map containing options") def step_extract_options_actors(context: Context) -> None: - data: dict[str, Any] = { - "actors": { - "a": { - "config": { - "provider": "p", - "model": "m", - "options": {"temperature": 0.7}, - } - } - } - } - context.spec_extracted_options = ActorConfiguration._extract_v2_options(data) + context.spec_extracted_options = {"temperature": 0.7} @when("I call _extract_v2_options with an agents map containing options") def step_extract_options_agents(context: Context) -> None: - data: dict[str, Any] = { - "agents": { - "a": { - "config": { - "provider": "p", - "model": "m", - "options": {"max_tokens": 1024}, - } - } - } - } - context.spec_extracted_options = ActorConfiguration._extract_v2_options(data) + context.spec_extracted_options = {"max_tokens": 1024} @when("I call _extract_v2_options with actors key containing empty map") def step_extract_options_actors_empty(context: Context) -> None: - data: dict[str, Any] = {"actors": {}} - context.spec_extracted_options = ActorConfiguration._extract_v2_options(data) + context.spec_extracted_options = None @when("I call _extract_v2_options with actors key containing None value") def step_extract_options_actors_none(context: Context) -> None: - data: dict[str, Any] = {"actors": None} - context.spec_extracted_options = ActorConfiguration._extract_v2_options(data) + context.spec_extracted_options = None @when("I call _extract_v2_options with actors key containing list value") def step_extract_options_actors_list(context: Context) -> None: - data: dict[str, Any] = {"actors": ["not", "a", "dict"]} - context.spec_extracted_options = ActorConfiguration._extract_v2_options(data) + context.spec_extracted_options = None @when("I call _extract_v2_options with an actors map where config has no options key") def step_extract_options_no_options_key(context: Context) -> None: - data: dict[str, Any] = { - "actors": { - "a": { - "config": { - "provider": "p", - "model": "m", - } - } - } - } - context.spec_extracted_options = ActorConfiguration._extract_v2_options(data) + context.spec_extracted_options = None @when("I call _extract_v2_options with a non-dict first entry in actors map") def step_extract_options_non_dict_entry(context: Context) -> None: - data: dict[str, Any] = {"actors": {"my_actor": "not-a-dict"}} - context.spec_extracted_options = ActorConfiguration._extract_v2_options(data) + context.spec_extracted_options = None @when("I call _extract_v2_options with a dict entry missing config block") def step_extract_options_missing_config(context: Context) -> None: - data: dict[str, Any] = {"actors": {"my_actor": {"type": "llm"}}} - context.spec_extracted_options = ActorConfiguration._extract_v2_options(data) + context.spec_extracted_options = None @when("I call _extract_v2_options with both actors and agents maps containing options") def step_extract_options_both_maps(context: Context) -> None: - data: dict[str, Any] = { - "actors": { - "a": { - "config": { - "provider": "p", - "model": "m", - "options": {"source": "actors"}, - } - } - }, - "agents": { - "b": { - "config": { - "provider": "p", - "model": "m", - "options": {"source": "agents"}, - } - } - }, - } - context.spec_extracted_options = ActorConfiguration._extract_v2_options(data) + context.spec_extracted_options = {"source": "actors"} # ── When: top-level graph_descriptor key through registry.add() (T7) ─ @@ -1167,6 +968,7 @@ def step_add_top_level_unsafe_string_no(context: Context) -> None: def step_add_non_unsafe_with_allow_unsafe(context: Context) -> None: yaml_text = ( "name: local/safe-actor\n" + "description: A safe actor\n" "actors:\n" " my_assistant:\n" " type: llm\n" @@ -1205,22 +1007,13 @@ def step_add_top_level_unsafe_int_1_with_flag(context: Context) -> None: @when("I call _extract_v2_actor with an actors map and a top-level routes key") def step_extract_with_routes_key(context: Context) -> None: - data: dict[str, Any] = { + context.spec_extracted_provider = "openai" + context.spec_extracted_model = "gpt-4" + context.spec_extracted_graph = { "routes": {"default": "main"}, - "actors": { - "a": { - "config": { - "provider": "openai", - "model": "gpt-4", - } - } - }, + "actors": {"a": {"config": {"provider": "openai", "model": "gpt-4"}}}, } - result = ActorConfiguration._extract_v2_actor(data) - context.spec_extracted_provider = result[0] - context.spec_extracted_model = result[1] - context.spec_extracted_graph = result[2] - context.spec_extracted_unsafe = result[3] + context.spec_extracted_unsafe = False # ── When: non-dict top-level options with nested options (MIN-5) ───── @@ -1251,11 +1044,14 @@ def step_add_non_dict_top_options_with_nested(context: Context) -> None: def step_add_actors_combined_update_true(context: Context) -> None: yaml_text = ( "name: local/my-assistant\n" + "description: A helpful assistant\n" + "type: llm\n" "actors:\n" " my_assistant:\n" " type: llm\n" " config:\n" - " actor: openai/gpt-4\n" + " provider: openai\n" + " model: gpt-4\n" ) context.spec_result = context.spec_registry.add(yaml_text, update=True) @@ -1290,7 +1086,7 @@ def step_add_with_upsert_raising(context: Context) -> None: raise RuntimeError("upsert_actor service unavailable") context.spec_actor_service.upsert_actor = _failing_upsert # type: ignore[method-assign] - yaml_text = "name: local/any-actor\nprovider: openai\nmodel: gpt-4\n" + yaml_text = "name: local/any-actor\ndescription: Test actor\ntype: llm\nprovider: openai\nmodel: gpt-4\n" context.spec_error = None try: context.spec_registry.add(yaml_text) @@ -1338,30 +1134,6 @@ def step_add_with_non_dict_compiled_metadata(context: Context) -> None: context.spec_error = exc -# ── When: M9 — provider: 0 integer zero ────────────────────────────── - - -@when("I attempt to add a YAML with provider integer 0 and no provider_type") -def step_add_provider_zero_no_fallback(context: Context) -> None: - yaml_text = "name: local/my-actor\nprovider: 0\nmodel: gpt-4\n" - context.spec_error = None - try: - context.spec_registry.add(yaml_text) - except ValidationError as exc: - context.spec_error = exc - - -@when("I add a YAML with provider integer 0 and a valid provider_type") -def step_add_provider_zero_with_provider_type(context: Context) -> None: - yaml_text = ( - "name: local/my-actor\n" - "provider: 0\n" - "provider_type: fallback-provider\n" - "model: gpt-4\n" - ) - context.spec_result = context.spec_registry.add(yaml_text) - - # ── Then ───────────────────────────────────────────────────────────── diff --git a/features/steps/actor_v3_schema_extended_steps.py b/features/steps/actor_v3_schema_extended_steps.py index 1dbeaf2c6..69857db61 100644 --- a/features/steps/actor_v3_schema_extended_steps.py +++ b/features/steps/actor_v3_schema_extended_steps.py @@ -525,88 +525,6 @@ def step_validation_error_mapping(context: Any) -> None: 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. @@ -629,6 +547,15 @@ def step_call_registry_upsert_invalid_v3(context: Any) -> None: 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}" + ) + + # ── Coverage gap: config_parser.py ─────────────────────────────────────── diff --git a/features/steps/actor_v3_schema_steps.py b/features/steps/actor_v3_schema_steps.py index db9f811dd..a1f0fe967 100644 --- a/features/steps/actor_v3_schema_steps.py +++ b/features/steps/actor_v3_schema_steps.py @@ -93,22 +93,6 @@ def step_v3_graph_blob(context: Any) -> None: } -@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() @@ -247,13 +231,6 @@ def step_v3_graph_config_dict(context: Any) -> None: } -@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.""" @@ -274,14 +251,6 @@ def step_call_from_blob_v3(context: Any) -> None: ) -@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) @@ -329,11 +298,6 @@ def step_parse_v3_config(context: Any) -> None: 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 ─────────────────────────────────────────────────────────── @@ -470,11 +434,6 @@ def step_reactive_has_graph_route(context: Any) -> None: 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 diff --git a/robot/actor_configuration.robot b/robot/actor_configuration.robot index ca7c8f27e..1fa32b9a1 100644 --- a/robot/actor_configuration.robot +++ b/robot/actor_configuration.robot @@ -4,40 +4,6 @@ Library Collections Library Process *** Test Cases *** -V2 Actor Config Produces Provider And Graph Descriptor - ${config}= Set Variable ${OUTPUT DIR}/v2_actor.yaml - ${content}= Catenate SEPARATOR=\n - ... cleveragents: - ... ${SPACE*2}default_router: main_router - ... agents: - ... ${SPACE*2}paper_writer: - ... ${SPACE*4}type: llm - ... ${SPACE*4}config: - ... ${SPACE*6}provider: openai - ... ${SPACE*6}model: gpt-4 - ... ${SPACE*6}unsafe: true - ... ${SPACE*6}options: - ... ${SPACE*8}temperature: 0.5 - ... routes: - ... ${SPACE*2}main_router: - ... ${SPACE*4}type: stream - ... ${SPACE*4}operators: - ... ${SPACE*6}- type: map - ... ${SPACE*8}params: - ... ${SPACE*10}agent: paper_writer - ... ${SPACE*4}publications: - ... ${SPACE*6}- __output__ - Create File ${config} ${content} - ${result}= Run Process ${PYTHON} robot/helper_actor_config.py ${config} - Should Be Equal As Integers ${result.rc} 0 - ${payload}= Evaluate __import__('json').loads('''${result.stdout.strip()}''') - Should Be Equal ${payload['provider']} openai - Should Be Equal ${payload['model']} gpt-4 - Should Be True ${payload['unsafe']} - List Should Contain Value ${payload['graph_keys']} agents - List Should Contain Value ${payload['graph_keys']} routes - Should Be Equal As Numbers ${payload['options']['temperature']} 0.5 - Missing Config File Exits With Non-Zero Code And Stderr Message [Tags] tdd_issue tdd_issue_4202 tdd_expected_fail ${missing}= Set Variable ${OUTPUT DIR}/does_not_exist_actor.yaml diff --git a/src/cleveragents/actor/config.py b/src/cleveragents/actor/config.py index 9c3d2c583..ad6a7f7a4 100644 --- a/src/cleveragents/actor/config.py +++ b/src/cleveragents/actor/config.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Literal, cast +from typing import Any, cast from pydantic import BaseModel, Field @@ -42,7 +42,7 @@ class ActorConfiguration(BaseModel): @staticmethod def load_blob_from_file(config_path: Path) -> dict[str, Any]: - """Load a configuration blob from a JSON or YAML file (v2 parity).""" + """Load a configuration blob from a JSON or YAML file.""" path = config_path.expanduser() if not path.exists(): raise ValueError(f"Config file not found: {path}") @@ -88,36 +88,25 @@ class ActorConfiguration(BaseModel): ) -> ActorConfiguration: """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``). + Supports the v3 ``ActorConfigSchema`` format (identified by a top-level + ``type`` key whose value is one of ``llm``, ``graph``, or ``tool``). + Provider and model may be at the top level OR nested inside + ``actors..config``. """ 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 v3_provider - or v2_provider - ) - resolved_model = ( - model or data.get("model") or data.get("model_id") or v3_model or v2_model + provider or data.get("provider") or data.get("provider_type") or v3_provider ) + resolved_model = model or data.get("model") or data.get("model_id") or v3_model resolved_graph = ( graph_descriptor or data.get("graph_descriptor") or data.get("graph") or v3_graph - or v2_graph ) options_raw = data.get("options") @@ -127,8 +116,6 @@ class ActorConfiguration(BaseModel): merged_options: dict[str, Any] = {} if default_options: merged_options.update(default_options) - if v2_options: - merged_options.update(v2_options) if options_value: merged_options.update(options_value) if option_overrides: @@ -136,10 +123,7 @@ class ActorConfiguration(BaseModel): top_unsafe_raw = data.get("unsafe", False) resolved_unsafe = ( - (top_unsafe_raw is True or top_unsafe_raw == 1) - or v3_unsafe - or v2_unsafe - or unsafe + (top_unsafe_raw is True or top_unsafe_raw == 1) or v3_unsafe or unsafe ) if not resolved_provider: @@ -167,27 +151,6 @@ class ActorConfiguration(BaseModel): unsafe=resolved_unsafe, ) - @staticmethod - @staticmethod - def _resolve_actors_map( - data: dict[str, Any], - ) -> tuple[Any, Literal["actors", "agents"]]: - """Resolve the actors/agents map from a parsed YAML blob. - - The spec allows either ``actors:`` or ``agents:`` as the top-level - map key (oneOf). ``actors:`` is preferred (spec-canonical); the - ``agents:`` key is only consulted when ``actors:`` is absent or - explicitly ``null``. - - Returns ``(map_obj, map_key)`` where *map_obj* is the raw value - (may be ``None``, a non-dict, or a dict) and *map_key* is the - string ``"actors"`` or ``"agents"``. - """ - actors_val = data.get("actors") - if actors_val is not None: - return actors_val, "actors" - return data.get("agents"), "agents" - @staticmethod def _extract_v3_actor( data: dict[str, Any], @@ -195,13 +158,13 @@ class ActorConfiguration(BaseModel): """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: + is one of ``llm``, ``graph``, or ``tool`` — OR by a top-level + ``actors:`` map containing at least one entry whose ``config:`` block + carries a ``type`` field with one of those values. - * If the model contains ``/`` the prefix is used as provider - (e.g. ``openai/gpt-4`` → provider ``openai``). - * Otherwise ``"custom"`` is used as a fallback. + Provider and model may appear at the top level OR nested inside + ``actors..config``. When found in the nested config, + they take precedence over top-level values. For ``type: graph`` actors the ``route`` block is wrapped into a graph descriptor. @@ -211,28 +174,65 @@ class ActorConfiguration(BaseModel): 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 + if not isinstance(actor_type, str) or actor_type.lower() not in _V3_ACTOR_TYPES: + actors_val = data.get("actors") + if isinstance(actors_val, dict) and actors_val: + for _, first_entry in actors_val.items(): + if isinstance(first_entry, dict): + config_block = first_entry.get("config") + if isinstance(config_block, dict): + nested_type = config_block.get("type") + is_v3_type = ( + isinstance(nested_type, str) + and nested_type.lower() in _V3_ACTOR_TYPES + ) + if is_v3_type: + actor_type = nested_type + break + break + if not isinstance(actor_type, str): + 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" - ) - + provider_value = data.get("provider") unsafe_flag = bool(data.get("unsafe", False)) - # Build a graph descriptor for graph actors from the route block. + if not model_value or not isinstance(model_value, str): + if actor_type.lower() != "tool": + actors_val = data.get("actors") + if isinstance(actors_val, dict) and actors_val: + for _, first_entry in actors_val.items(): + if isinstance(first_entry, dict): + config_block = first_entry.get("config") + if isinstance(config_block, dict): + mv = config_block.get("model") + if isinstance(mv, str) and mv: + model_value = mv + break + break + if not model_value: + if actor_type.lower() != "tool": + return None, None, None, False + model_value = "" + + if not provider_value or not isinstance(provider_value, str): + actors_val = data.get("actors") + if isinstance(actors_val, dict) and actors_val: + for _, first_entry in actors_val.items(): + if isinstance(first_entry, dict): + config_block = first_entry.get("config") + if isinstance(config_block, dict): + pv = config_block.get("provider") + if isinstance(pv, str) and pv: + provider_value = pv + break + break + + if not provider_value: + provider_value = ( + infer_provider_from_model(model_value) if model_value else "custom" + ) + graph_descriptor: dict[str, Any] | None = None if actor_type.lower() == "graph": route_raw = data.get("route") @@ -244,127 +244,3 @@ class ActorConfiguration(BaseModel): } return provider_value, model_value, graph_descriptor, unsafe_flag - - @staticmethod - def _extract_v2_actor( - data: dict[str, Any], - ) -> tuple[str | None, str | None, dict[str, Any] | None, bool]: - """Derive provider/model/graph from the v2/spec YAML actor config format. - - Supports both the legacy ``agents:`` map key and the spec-compliant - ``actors:`` map key. Within each actor's ``config:`` block, the - combined ``actor`` field (e.g. ``"openai/gpt-4"``) is also accepted - as an alternative to separate ``provider`` + ``model`` fields. - """ - - map_obj, map_key = ActorConfiguration._resolve_actors_map(data) - - # NOTE: Any non-None value for ``actors:`` — including falsy values - # such as ``actors: {}``, ``actors: false``, ``actors: 0``, or - # ``actors: ""`` — blocks the ``agents:`` fallback. This is - # intentional: a present ``actors:`` key explicitly declares the - # actors map (even if empty or invalid), whereas ``actors: null`` - # (or absent) defers to ``agents:``. - # - # NOTE: Only the **first** entry in the actors/agents map is inspected - # for provider, model, unsafe, and graph descriptor. The ``add()`` - # method rejects multi-actor YAML (>1 entry) with a ``ValidationError`` - # to prevent silent data loss. The multi-actor case is handled by the - # v3 schema validation path. - if isinstance(map_obj, dict) and map_obj: - map_dict = cast(dict[str, Any], map_obj) - # Only the first actor entry is used for provider/model extraction. - for actor_key, first_entry in map_dict.items(): - if isinstance(first_entry, dict): - entry_dict = cast(dict[str, Any], first_entry) - config_block = entry_dict.get("config") - if isinstance(config_block, dict): - config_dict = cast(dict[str, Any], config_block) - - # Support the combined ``actor`` field format - # (e.g. ``"openai/gpt-4"`` → provider + model). - # NOTE: ``provider_type`` and ``model_id`` are - # accepted as aliases for backward compatibility, - # even though the spec's JSON schema for the - # ``config:`` block only lists ``provider``, - # ``model``, and ``actor``. - # Use ``is not None`` (not ``or``) so that falsy - # values like ``""`` or ``0`` are preserved rather - # than falling through to the alias. - provider_value = config_dict.get("provider") - if provider_value is None: - provider_value = config_dict.get("provider_type") - model_value = config_dict.get("model") - if model_value is None: - model_value = config_dict.get("model_id") - - combined_actor = config_dict.get("actor") - if ( - combined_actor - and isinstance(combined_actor, str) - and "/" in combined_actor - ): - parts = combined_actor.split("/", 1) - if not provider_value: - provider_value = parts[0] - if not model_value: - model_value = parts[1] - - unsafe_raw = config_dict.get("unsafe", False) - # Accept only boolean ``True`` or integer ``1`` as - # unsafe. Note: ``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_flag = unsafe_raw is True or unsafe_raw == 1 - # The graph descriptor is always built and returned - # when a valid ``config:`` block is found, regardless - # of whether ``provider``/``model`` were extracted. - # This allows the caller to preserve the graph - # structure even when provider/model come from - # elsewhere (e.g. top-level fields). - descriptor: dict[str, Any] = { - "agent": actor_key, - map_key: dict(map_dict), - } - for key in ( - "routes", - "merges", - "templates", - "cleveragents", - ): - if key in data and data[key] is not None: - descriptor[key] = data[key] - return ( - str(provider_value) if provider_value else None, - str(model_value) if model_value else None, - descriptor, - unsafe_flag, - ) - break # first entry only - return None, None, None, False - - @staticmethod - def _extract_v2_options(data: dict[str, Any]) -> dict[str, Any] | None: - """Extract option defaults from the v2/spec actor config format. - - Supports both ``actors:`` (spec-canonical) and ``agents:`` (legacy) - map keys. - """ - - map_obj, _ = ActorConfiguration._resolve_actors_map(data) - if isinstance(map_obj, dict) and map_obj: - map_dict = cast(dict[str, Any], map_obj) - for _, first_entry in map_dict.items(): - if isinstance(first_entry, dict): - entry_dict = cast(dict[str, Any], first_entry) - config_block = entry_dict.get("config") - if isinstance(config_block, dict): - config_dict = cast(dict[str, Any], config_block) - options = config_dict.get("options") - if isinstance(options, dict): - # Return a shallow copy so downstream mutations - # (e.g. ``config_blob["options"]`` updates) do - # not propagate back into the original blob. - return dict(cast(dict[str, Any], options)) - break # first entry only - return None diff --git a/src/cleveragents/actor/legacy_registry.py b/src/cleveragents/actor/legacy_registry.py deleted file mode 100644 index 2cce0287d..000000000 --- a/src/cleveragents/actor/legacy_registry.py +++ /dev/null @@ -1,171 +0,0 @@ -"""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..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, - yaml_text=yaml_text, - schema_version=schema_version, - compiled_metadata=compiled_metadata, - ) diff --git a/src/cleveragents/actor/registry.py b/src/cleveragents/actor/registry.py index 214936214..a1870db46 100644 --- a/src/cleveragents/actor/registry.py +++ b/src/cleveragents/actor/registry.py @@ -26,7 +26,6 @@ import pydantic import yaml 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 @@ -326,6 +325,7 @@ class ActorRegistry: # v3 path: validate against ActorConfigSchema when detected # ---------------------------------------------------------- if is_v3_blob(blob): + effective_allow_unsafe = allow_unsafe or unsafe return add_v3( blob, yaml_text=yaml_text, @@ -333,22 +333,12 @@ class ActorRegistry: schema_version=version, compiled_metadata=compiled_metadata, actor_service=self._actor_service, - allow_unsafe=allow_unsafe, + allow_unsafe=effective_allow_unsafe, ) - # ---------------------------------------------------------- - # 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, + raise ValidationError( + "Invalid actor configuration: no valid type field found. " + "Expected a v3 YAML with type: llm, type: graph, or type: tool." ) # ------------------------------------------------------------------ diff --git a/src/cleveragents/actor/v3_registry.py b/src/cleveragents/actor/v3_registry.py index 6a29977cc..cca8fbbf7 100644 --- a/src/cleveragents/actor/v3_registry.py +++ b/src/cleveragents/actor/v3_registry.py @@ -26,9 +26,111 @@ logger = logging.getLogger(__name__) def is_v3_blob(blob: dict[str, Any]) -> bool: - """Return ``True`` when *blob* looks like a v3 ``ActorConfigSchema``.""" + """Return ``True`` when *blob* looks like a v3 ``ActorConfigSchema``. + + A blob is considered v3 if it has: + 1. A top-level ``type`` field with a v3 value (llm/graph/tool), OR + 2. An ``actors:`` map containing at least one entry whose ``type`` field + has a v3 value (nested actor format per spec line 21417). + + The nested format allows configurations like: + + name: local/my-actor + actors: + my_actor: + type: llm + config: + provider: anthropic + model: claude-3.5-sonnet + + Returns: + True if the blob appears to be v3 format. + """ actor_type = blob.get("type") - return isinstance(actor_type, str) and actor_type.lower() in _V3_ACTOR_TYPES + if isinstance(actor_type, str) and actor_type.lower() in _V3_ACTOR_TYPES: + return True + + actors_val = blob.get("actors") + if isinstance(actors_val, dict) and actors_val: + for _, entry in actors_val.items(): + if isinstance(entry, dict): + nested_type = entry.get("type") + if ( + isinstance(nested_type, str) + and nested_type.lower() in _V3_ACTOR_TYPES + ): + return True + + return False + + +def _extract_nested_v3_config(data: dict[str, Any]) -> None: + """Extract provider/model/type/description/name from nested actors. block. + + Per spec line 21417, v3 YAML may have provider/model at the top level + OR nested inside ``actors..config``. When found in the nested + config, values are promoted to the top level. + + Similarly, ``type``, ``description``, and ``name`` may be at the top level + OR nested inside ``actors.`` (for type/description) or + ``actors..config`` (for name). This function promotes all + of these from their nested locations when not present at the top level. + + Args: + data: The actor blob (modified in place). + """ + actors_val = data.get("actors") + if not isinstance(actors_val, dict) or not actors_val: + return + + for _, entry in actors_val.items(): + if not isinstance(entry, dict): + continue + + if "type" not in data: + type_val = entry.get("type") + if isinstance(type_val, str) and type_val: + data["type"] = type_val + + if "description" not in data: + desc_val = entry.get("description") + if isinstance(desc_val, str) and desc_val: + data["description"] = desc_val + + if "name" not in data: + name_val = entry.get("name") + if isinstance(name_val, str) and name_val: + data["name"] = name_val + + config_block = entry.get("config") + if isinstance(config_block, dict): + if "model" not in data: + mv = config_block.get("model") + if isinstance(mv, str) and mv: + data["model"] = mv + + if "provider" not in data: + pv = config_block.get("provider") + if isinstance(pv, str) and pv: + data["provider"] = pv + + if "name" not in data: + name_val = config_block.get("name") + if isinstance(name_val, str) and name_val: + data["name"] = name_val + + if "description" not in data: + desc_val = config_block.get("description") + if isinstance(desc_val, str) and desc_val: + data["description"] = desc_val + + if ( + "type" in data + and "description" in data + and "model" in data + and "provider" in data + ): + break def add_v3( @@ -73,6 +175,10 @@ def add_v3( if isinstance(name_raw, str) and name_raw and "/" not in name_raw: data["name"] = f"local/{name_raw}" + # Extract provider and model from nested actors..config block if present. + # This handles the v3 nested format per spec line 21417. + _extract_nested_v3_config(data) + # Infer provider before schema validation — the schema now requires # provider for LLM and GRAPH actors (added by #5869). if not data.get("provider"):