diff --git a/CHANGELOG.md b/CHANGELOG.md index bc61e3e2d..46bd4bb64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). validate-then-write approach: all model validation occurs in Phase 1, and state mutations only happen in Phase 2 after all validations succeed. +- **ActorRegistry.add() spec-compliant YAML support** (#4466): The registry now + accepts actor YAML using the spec's `actors:` map format with nested `config:` + blocks, in addition to the legacy top-level `provider`/`model` format. The + `unsafe` flag and graph descriptor from nested config are now correctly + preserved during registration. Multi-actor YAML (>1 entry in `actors:`/`agents:` + map) is now rejected by `add()` with a `ValidationError`. Nested + `config.options` are now correctly preserved. The `unsafe` coercion now uses + strict `is True or == 1` instead of `bool()` to prevent truthy non-boolean + YAML values (e.g. `unsafe: "no"`) from being treated as unsafe. + - **UKO Runtime Layer 2 (Paradigm) Indexing** (#9351): Added missing `rdf:type uko-oo:Class` triple emission in `PythonAnalyzer._extract_class()` so that Python class definitions are now correctly classified at layer 2 (paradigm/OO) diff --git a/features/actor_registry_spec_yaml.feature b/features/actor_registry_spec_yaml.feature new file mode 100644 index 000000000..1f115d748 --- /dev/null +++ b/features/actor_registry_spec_yaml.feature @@ -0,0 +1,542 @@ +@tdd_issue @tdd_issue_4466 +Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats + As a developer following the specification + I want the actor registry to accept YAML using the actors: map format + So that spec-compliant actor definitions can be registered without error + + Background: + Given a spec-yaml actor registry with no providers + + # ── actors: map with combined actor field ────────────────────────── + + Scenario: registry.add() accepts spec-compliant actors: map with combined actor field + When I add a spec-compliant YAML with actors map and combined actor field + Then the actor should be registered with provider "openai" and model "gpt-4" + And the registered actor name should be "local/my-assistant" + And the registered actor should exist in the actor service + + 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" + And the registered actor should exist in the actor service + + # ── actors: map with unsafe flag ─────────────────────────────────── + + Scenario: registry.add() preserves unsafe flag from nested spec-compliant config + When I add a spec-compliant YAML with actors map and unsafe flag + 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 + + # ── agents: map (legacy) still works ─────────────────────────────── + + Scenario: registry.add() continues to accept legacy agents: map format + When I add a YAML with legacy agents map format + Then the actor should be registered with provider "openai" and model "gpt-4o" + + # ── Top-level provider/model still works ─────────────────────────── + + Scenario: registry.add() continues to accept top-level provider and model + When I add a YAML with top-level provider and model fields + Then the actor should be registered with provider "openai" and model "gpt-4" + And the registered actor should not be marked unsafe + + # ── Partial top-level + nested fallback ──────────────────────────── + + Scenario: registry.add() with top-level provider only extracts model from nested actors map + When I add a YAML with top-level provider only and model in nested actors map + Then the actor should be registered with provider "top-level-provider" and model "nested-model" + And the registered actor should exist in the actor service + + Scenario: registry.add() with top-level model only extracts provider from nested actors map + When I add a YAML with top-level model only and provider in nested actors map + 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 ────── + + 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 + When I attempt to add an unsafe YAML without the unsafe flag + Then a spec-yaml ValidationError should be raised containing "unsafe" + + Scenario: registry.add() accepts unsafe actor with unsafe flag + When I add an unsafe YAML with 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: registry.add() accepts unsafe actor with allow_unsafe flag + When I add an unsafe YAML with the allow_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: registry.add() with allow_unsafe=True on non-unsafe YAML does not mark actor unsafe + When I add a non-unsafe YAML with allow_unsafe flag set + 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 + When I add a spec-compliant YAML with actors map and combined actor field + And I add the same actor again with update=True and provider "anthropic" and model "claude-3" + Then the actor should be registered with provider "anthropic" and model "claude-3" + And the registered actor should exist in the actor service + + Scenario: registry.add() without update=True raises when actor already exists + When I add a spec-compliant YAML with actors map and combined actor field + And I attempt to add the same actor again without update=True + Then a spec-yaml ValidationError should be raised containing "already exists" + + # ── schema_version and compiled_metadata parameters ──────────────── + + Scenario: registry.add() forwards schema_version and compiled_metadata to upsert_actor + When I add a spec-compliant YAML with schema_version "2.0" and compiled_metadata + Then the actor should be registered with provider "openai" and model "gpt-4" + And the registered actor schema version should be "2.0" + And the registered actor compiled metadata should contain key "key" + + # ── registry.add() rejects YAML without a name field ──────────────── + + Scenario: registry.add() rejects YAML without a name field + When I attempt to add a YAML without a name field + Then a spec-yaml ValidationError should be raised containing "name" + + # ── Top-level unsafe: true in add() ───────────────────────────────── + + Scenario: registry.add() rejects top-level unsafe YAML without confirmation + When I attempt to add a YAML with top-level unsafe true and no flag + Then a spec-yaml ValidationError should be raised containing "unsafe" + + Scenario: registry.add() accepts top-level unsafe YAML with unsafe flag + When I add a YAML with top-level unsafe true 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 + + # ── 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 + When I add a spec-compliant YAML with actors map and combined actor field with update=True + Then the actor should be registered with provider "openai" and model "gpt-4" + And the registered actor should exist in the actor service + + # ── M5: v3 TOOL actor without provider/model propagates to upsert ── + + Scenario: registry.add() with v3 TOOL actor without provider or model raises an error from upsert_actor + When I attempt to add a v3 TOOL YAML without provider or model + Then an error should be raised from upsert_actor + + # ── M6: upsert_actor raises exception — error propagates ─────────── + + Scenario: registry.add() propagates exception raised by upsert_actor + 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/steps/actor_registry_spec_yaml_steps.py b/features/steps/actor_registry_spec_yaml_steps.py new file mode 100644 index 000000000..fe4d38648 --- /dev/null +++ b/features/steps/actor_registry_spec_yaml_steps.py @@ -0,0 +1,1651 @@ +"""Step definitions for spec-compliant actor YAML format tests.""" + +from __future__ import annotations + +import ast +from typing import Any +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 + + +# TODO(#10832): Deduplicate — shared copies exist in actor_registry_steps.py +# and actor_registry_persistence_steps.py. +class _StubActorService: + """Minimal actor service stub for registry tests.""" + + def __init__(self) -> None: + self.actors: dict[str, Actor] = {} + self.default_actor_name: str | None = None + + def upsert_actor( + self, + *, + name: str, + provider: str, + model: str, + config_blob: dict[str, Any] | None = None, + graph_descriptor: dict[str, Any] | None = None, + unsafe: bool = False, + set_default: bool = False, + is_built_in: bool = False, + yaml_text: str | None = None, + schema_version: str | None = None, + compiled_metadata: dict[str, Any] | None = None, + ) -> Actor: + blob = config_blob or {} + actor = Actor( + id=None, + name=name, + provider=provider, + model=model, + config_blob=blob, + config_hash=Actor.compute_hash(blob), + graph_descriptor=graph_descriptor, + yaml_text=yaml_text, + schema_version=schema_version or "1.0", + compiled_metadata=compiled_metadata, + unsafe=unsafe, + is_built_in=is_built_in, + is_default=False, + ) + self.actors[name] = actor + if set_default: + self.default_actor_name = name + return actor + + def get_default_actor(self) -> Actor | None: + if self.default_actor_name and self.default_actor_name in self.actors: + return self.actors[self.default_actor_name] + return None + + def set_default_actor(self, name: str) -> Actor: + actor = self.actors.get(name) + if actor is None: + raise ValueError(f"Actor {name!r} does not exist") + self.default_actor_name = name + return actor + + def get_actor(self, name: str) -> Actor: + actor = self.actors.get(name) + if actor is None: + raise NotFoundError(f"Actor {name!r} not found") + return actor + + def list_actors(self) -> list[Actor]: + return list(self.actors.values()) + + def remove_actor(self, name: str) -> None: + self.actors.pop(name, None) + + +def _make_registry_no_providers(context: Context) -> None: + """Build an ActorRegistry with no configured providers.""" + context.spec_actor_service = _StubActorService() + provider_reg = MagicMock() + provider_reg.get_configured_providers.return_value = [] + settings = MagicMock() + settings.resolve_provider_defaults.return_value = MagicMock( + provider=None, model=None + ) + context.spec_registry = ActorRegistry( + actor_service=context.spec_actor_service, + provider_registry=provider_reg, + settings=settings, + ) + + +# ── Given ──────────────────────────────────────────────────────────── + + +@given("a spec-yaml actor registry with no providers") +def step_spec_yaml_registry(context: Context) -> None: + _make_registry_no_providers(context) + + +# ── When: registry.add() ──────────────────────────────────────────── + + +@when("I add a spec-compliant YAML with actors map and combined actor field") +def step_add_actors_combined(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" + " system_prompt: You are helpful.\n" + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +@when("I add a spec-compliant YAML with actors map and separate provider model") +def step_add_actors_separate(context: Context) -> None: + yaml_text = ( + "name: local/my-assistant\n" + "actors:\n" + " my_assistant:\n" + " type: llm\n" + " config:\n" + " provider: anthropic\n" + " model: claude-3\n" + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +@when("I add a spec-compliant YAML with actors map and unsafe flag") +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" + ) + context.spec_result = context.spec_registry.add(yaml_text, unsafe=True) + + +@when("I add a YAML with legacy agents map format") +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" + ) + 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" + context.spec_result = context.spec_registry.add(yaml_text) + + +@when("I add a YAML with top-level provider only and model in nested actors map") +def step_add_top_provider_nested_model(context: Context) -> None: + yaml_text = ( + "name: local/partial-actor\n" + "provider: top-level-provider\n" + "actors:\n" + " my_assistant:\n" + " type: llm\n" + " config:\n" + " model: nested-model\n" + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +@when("I add a YAML with top-level model only and provider in nested actors map") +def step_add_top_model_nested_provider(context: Context) -> None: + yaml_text = ( + "name: local/partial-actor\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) + + +@when("I attempt to add a YAML with no provider or model anywhere") +def step_add_no_provider_model(context: Context) -> None: + yaml_text = "name: local/empty-actor\ndescription: No provider\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 top-level provider and model and nested actors map with unsafe flag" +) +def step_add_top_level_with_nested_unsafe(context: Context) -> None: + yaml_text = ( + "name: local/top-level-actor\n" + "provider: openai\n" + "model: gpt-4\n" + "actors:\n" + " my_assistant:\n" + " type: llm\n" + " config:\n" + " unsafe: true\n" + ) + context.spec_result = context.spec_registry.add(yaml_text, unsafe=True) + + +@when( + "I add a YAML with top-level provider and model and nested actors map with graph descriptor" +) +def step_add_top_level_with_nested_graph(context: Context) -> None: + yaml_text = ( + "name: local/top-level-actor\n" + "provider: openai\n" + "model: gpt-4\n" + "actors:\n" + " my_assistant:\n" + " type: llm\n" + " config:\n" + " actor: anthropic/claude-3\n" + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +@when("I attempt to add an unsafe YAML without the unsafe flag") +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" + ) + context.spec_error = None + try: + context.spec_registry.add(yaml_text) + except ValidationError as exc: + context.spec_error = exc + + +@when("I add an unsafe YAML with the unsafe flag set") +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" + ) + context.spec_result = context.spec_registry.add(yaml_text, unsafe=True) + + +@when("I add an unsafe YAML with the allow_unsafe flag set") +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" + ) + context.spec_result = context.spec_registry.add(yaml_text, allow_unsafe=True) + + +@when( + 'I add the same actor again with update=True and provider "{provider}" and model "{model}"' +) +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" + ) + context.spec_result = context.spec_registry.add(yaml_text, update=True) + + +@when("I attempt to add the same actor again without update=True") +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" + ) + context.spec_error = None + try: + context.spec_registry.add(yaml_text) + except ValidationError as exc: + context.spec_error = exc + + +@when( + 'I add a spec-compliant YAML with schema_version "{version}" and compiled_metadata' +) +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" + ) + context.spec_result = context.spec_registry.add( + yaml_text, + schema_version=version, + compiled_metadata={"key": "val"}, + ) + + +# ── When: registry.add() missing name ──────────────────────────────── + + +@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" + context.spec_error = None + try: + context.spec_registry.add(yaml_text) + except ValidationError as exc: + context.spec_error = exc + + +# ── When: top-level unsafe ─────────────────────────────────────────── + + +@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" + 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 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" + context.spec_result = context.spec_registry.add(yaml_text, unsafe=True) + + +# ── When: multi-actor unsafe limitation ────────────────────────────── + + +@when("I attempt to add a multi-actor YAML with two actor entries") +def step_add_multi_actor_rejected(context: Context) -> None: + yaml_text = ( + "name: local/multi-actor\n" + "actors:\n" + " first_actor:\n" + " type: llm\n" + " config:\n" + " provider: openai\n" + " model: gpt-4\n" + " second_actor:\n" + " type: llm\n" + " config:\n" + " provider: anthropic\n" + " model: claude-3\n" + " unsafe: true\n" + ) + context.spec_error = None + try: + context.spec_registry.add(yaml_text) + except ValidationError as exc: + context.spec_error = exc + + +@when("I attempt to add a YAML with actors null and multi-entry agents map") +def step_add_multi_actor_agents_fallback_rejected(context: Context) -> None: + yaml_text = ( + "name: local/multi-agent-fallback\n" + "actors: null\n" + "agents:\n" + " first_agent:\n" + " type: llm\n" + " config:\n" + " provider: openai\n" + " model: gpt-4\n" + " second_agent:\n" + " type: llm\n" + " config:\n" + " provider: anthropic\n" + " model: claude-3\n" + ) + context.spec_error = None + try: + context.spec_registry.add(yaml_text) + except ValidationError as exc: + context.spec_error = exc + + +# ── When: _extract_v2_actor direct calls ───────────────────────────── + + +@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"}, + } + } + } + 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] + + +@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] = { + "actors": { + "my_assistant": { + "type": "llm", + "config": {"provider": "anthropic", "model": "claude-3"}, + } + } + } + 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] + + +@when("I call _extract_v2_actor with both actors and agents maps") +def step_extract_both_maps(context: Context) -> None: + data: dict[str, Any] = { + "actors": { + "a": { + "config": { + "provider": "actors-provider", + "model": "actors-model", + } + } + }, + "agents": { + "b": { + "config": { + "provider": "agents-provider", + "model": "agents-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] + + +@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] = { + "actors": { + "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] + + +@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] + + +@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] + + +@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] + + +@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] + + +@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] = { + "actors": { + "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] + + +@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"}, + } + } + } + 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] + + +@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] + + +@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] + + +@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] + + +@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] + + +@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] = { + "actors": { + "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] + + +@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] + + +@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] + + +# ── When: unsafe coercion edge cases (unsafe coercion) ─────────────── + + +@when('I call _extract_v2_actor with unsafe value "no"') +def step_extract_unsafe_string_no(context: Context) -> None: + data: dict[str, Any] = { + "actors": { + "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] + + +@when('I call _extract_v2_actor with unsafe value "yes"') +def step_extract_unsafe_string_yes(context: Context) -> None: + data: dict[str, Any] = { + "actors": { + "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] + + +@when("I call _extract_v2_actor with unsafe value 1") +def step_extract_unsafe_integer_1(context: Context) -> None: + data: dict[str, Any] = { + "actors": { + "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] + + +@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" + ) + 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] = { + "actors": { + "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] + + +@when("I call _extract_v2_actor with unsafe value 2") +def step_extract_unsafe_integer_2(context: Context) -> None: + data: dict[str, Any] = { + "actors": { + "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] + + +@when("I call _extract_v2_actor with unsafe value 0") +def step_extract_unsafe_integer_0(context: Context) -> None: + data: dict[str, Any] = { + "actors": { + "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] + + +# ── When: legacy graph key fallback (M3) ───────────────────────────── + + +@when("I add a YAML with top-level provider model and legacy graph key") +def step_add_legacy_graph_key(context: Context) -> None: + yaml_text = ( + "name: local/legacy-graph-actor\n" + "provider: openai\n" + "model: gpt-4\n" + "graph:\n" + " workflow: linear\n" + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +# ── When: empty actors map through registry.add() (M4) ─────────────── + + +@when( + "I attempt to add a YAML with empty actors map and valid agents map but no top-level provider" +) +def step_add_empty_actors_with_agents(context: Context) -> None: + yaml_text = ( + "name: local/empty-actors-actor\n" + "actors: {}\n" + "agents:\n" + " my_agent:\n" + " type: llm\n" + " config:\n" + " provider: openai\n" + " model: gpt-4\n" + ) + context.spec_error = None + try: + context.spec_registry.add(yaml_text) + except ValidationError as exc: + context.spec_error = exc + + +# ── When: provider_type / model_id aliases (m1) ────────────────────── + + +@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] = { + "actors": { + "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] + + +@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", + } + } + } + } + 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] + + +# ── When: combined actor with multiple slashes (L1) ────────────────── + + +@when( + "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] = { + "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] + + +# ── When: actors: null + valid agents through registry.add() (L2) ──── + + +@when("I add a YAML with actors null and valid agents map") +def step_add_actors_null_agents_valid(context: Context) -> None: + yaml_text = ( + "name: local/null-actors-actor\n" + "actors: null\n" + "agents:\n" + " my_agent:\n" + " type: llm\n" + " config:\n" + " provider: openai\n" + " model: gpt-4\n" + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +# ── When: nested options extraction through registry.add() (M1) ────── + + +@when("I add a spec-compliant YAML with actors map and nested options") +def step_add_actors_with_nested_options(context: Context) -> None: + yaml_text = ( + "name: local/options-actor\n" + "actors:\n" + " my_assistant:\n" + " type: llm\n" + " config:\n" + " actor: openai/gpt-4\n" + " options:\n" + " temperature: 0.9\n" + " max_tokens: 2000\n" + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +@when("I add a YAML with both top-level and nested options") +def step_add_both_top_level_and_nested_options(context: Context) -> None: + yaml_text = ( + "name: local/merged-options-actor\n" + "options:\n" + " temperature: 0.7\n" + " top_p: 0.95\n" + "actors:\n" + " my_assistant:\n" + " type: llm\n" + " config:\n" + " actor: openai/gpt-4\n" + " options:\n" + " temperature: 0.9\n" + " max_tokens: 2000\n" + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +# ── When: _extract_v2_options shallow copy mutation isolation (NIT-2) ─ + + +@when("I call _extract_v2_options and mutate the returned dict") +def step_extract_options_and_mutate(context: Context) -> None: + original_blob: dict[str, Any] = { + "actors": { + "a": { + "config": { + "provider": "p", + "model": "m", + "options": {"temperature": 0.7, "max_tokens": 1024}, + } + } + } + } + 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" + # Mutate the returned copy. + result["temperature"] = 999 + result["injected"] = True + + +# ── When: _extract_v2_options with empty dict (m4) ──────────────────── + + +@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({}) + + +# ── When: _extract_v2_options direct calls ─────────────────────────── + + +@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) + + +@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) + + +@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) + + +@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) + + +@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) + + +@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) + + +@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) + + +@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) + + +@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) + + +# ── When: top-level graph_descriptor key through registry.add() (T7) ─ + + +@when("I add a YAML with top-level provider model and graph_descriptor key") +def step_add_top_level_graph_descriptor(context: Context) -> None: + yaml_text = ( + "name: local/graph-descriptor-actor\n" + "provider: openai\n" + "model: gpt-4\n" + "graph_descriptor:\n" + " workflow: linear\n" + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +# ── When: top-level provider_type / model_id aliases (T8) ──────────── + + +@when("I add a YAML with top-level provider_type alias and model") +def step_add_top_level_provider_type_alias(context: Context) -> None: + yaml_text = ( + "name: local/alias-provider-actor\n" + "provider_type: alias-provider\n" + "model: gpt-4\n" + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +@when("I add a YAML with top-level provider and model_id alias") +def step_add_top_level_model_id_alias(context: Context) -> None: + yaml_text = ( + "name: local/alias-model-actor\nprovider: openai\nmodel_id: alias-model\n" + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +# ── When: top-level unsafe string coercion through registry.add() (T9) ── + + +@when('I add a YAML with top-level unsafe string "yes" and provider model') +def step_add_top_level_unsafe_string_yes(context: Context) -> None: + yaml_text = ( + 'name: local/unsafe-str-yes\nprovider: openai\nmodel: gpt-4\nunsafe: "yes"\n' + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +@when('I add a YAML with top-level unsafe string "no" and provider model') +def step_add_top_level_unsafe_string_no(context: Context) -> None: + yaml_text = ( + 'name: local/unsafe-str-no\nprovider: openai\nmodel: gpt-4\nunsafe: "no"\n' + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +# ── When: allow_unsafe=True on non-unsafe YAML (MAJ-1) ─────────────── + + +@when("I add a non-unsafe YAML with allow_unsafe flag set") +def step_add_non_unsafe_with_allow_unsafe(context: Context) -> None: + yaml_text = ( + "name: local/safe-actor\n" + "actors:\n" + " my_assistant:\n" + " type: llm\n" + " config:\n" + " provider: openai\n" + " model: gpt-4\n" + ) + context.spec_result = context.spec_registry.add(yaml_text, allow_unsafe=True) + + +# ── When: top-level unsafe: 1 (integer) through registry.add() (MIN-3) ── + + +@when("I attempt to add a YAML with top-level unsafe integer 1 and no flag") +def step_add_top_level_unsafe_int_1_no_flag(context: Context) -> None: + yaml_text = ( + "name: local/unsafe-int-top\nprovider: openai\nmodel: gpt-4\nunsafe: 1\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 top-level unsafe integer 1 and the unsafe flag set") +def step_add_top_level_unsafe_int_1_with_flag(context: Context) -> None: + yaml_text = ( + "name: local/unsafe-int-top\nprovider: openai\nmodel: gpt-4\nunsafe: 1\n" + ) + context.spec_result = context.spec_registry.add(yaml_text, unsafe=True) + + +# ── When: graph descriptor additional keys (MIN-4) ─────────────────── + + +@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] = { + "routes": {"default": "main"}, + "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] + + +# ── When: non-dict top-level options with nested options (MIN-5) ───── + + +@when("I add a YAML with non-dict top-level options and nested options") +def step_add_non_dict_top_options_with_nested(context: Context) -> None: + yaml_text = ( + "name: local/nondict-options-actor\n" + "options: not-a-dict\n" + "actors:\n" + " my_assistant:\n" + " type: llm\n" + " config:\n" + " actor: openai/gpt-4\n" + " options:\n" + " temperature: 0.9\n" + ) + context.spec_result = context.spec_registry.add(yaml_text) + + +# ── When: M4 — update=True on non-existent actor ───────────────────── + + +@when( + "I add a spec-compliant YAML with actors map and combined actor field with update=True" +) +def step_add_actors_combined_update_true(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" + ) + context.spec_result = context.spec_registry.add(yaml_text, update=True) + + +# ── When: M5 — v3 TOOL actor without provider/model ────────────────── + + +@when("I attempt to add a v3 TOOL YAML without provider or model") +def step_add_v3_tool_no_provider_model(context: Context) -> None: + yaml_text = ( + "name: local/tool-actor\n" + "type: tool\n" + "tool:\n" + " name: my_tool\n" + " description: A tool actor without provider or model\n" + ) + context.spec_error = None + try: + context.spec_result = context.spec_registry.add(yaml_text) + except Exception as exc: + context.spec_error = exc + + +# ── When: M6 — upsert_actor raises exception ───────────────────────── + + +@when("upsert_actor is configured to raise RuntimeError and I add a valid YAML") +def step_add_with_upsert_raising(context: Context) -> None: + original_upsert = context.spec_actor_service.upsert_actor + + def _failing_upsert(**kwargs: Any) -> None: # type: ignore[return] + 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" + context.spec_error = None + try: + context.spec_registry.add(yaml_text) + except RuntimeError as exc: + context.spec_error = exc + finally: + context.spec_actor_service.upsert_actor = original_upsert # type: ignore[method-assign] + + +# ── When: M7 — actors: false blocks agents fallback ────────────────── + + +@when("I attempt to add a YAML with actors false and valid agents map") +def step_add_actors_false_with_agents(context: Context) -> None: + yaml_text = ( + "name: local/my-actor\n" + "actors: false\n" + "agents:\n" + " my_agent:\n" + " type: llm\n" + " config:\n" + " provider: openai\n" + " model: gpt-4\n" + ) + context.spec_error = None + try: + context.spec_registry.add(yaml_text) + except ValidationError as exc: + context.spec_error = exc + + +# ── When: M8 — non-dict compiled_metadata ──────────────────────────── + + +@when("I attempt to add a valid YAML with compiled_metadata as a non-dict string") +def step_add_with_non_dict_compiled_metadata(context: Context) -> None: + yaml_text = "name: local/my-actor\nprovider: openai\nmodel: gpt-4\n" + context.spec_error = None + try: + context.spec_registry.add( + yaml_text, + compiled_metadata="not-a-dict", # type: ignore[arg-type] + ) + except Exception as exc: + 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 ───────────────────────────────────────────────────────────── + + +@then('the actor should be registered with provider "{provider}" and model "{model}"') +def step_assert_provider_model(context: Context, provider: str, model: str) -> None: + actor = context.spec_result + assert actor.provider == provider, ( + f"Expected provider={provider!r}, got {actor.provider!r}" + ) + assert actor.model == model, f"Expected model={model!r}, got {actor.model!r}" + + +@then('the registered actor name should be "{expected_name}"') +def step_assert_actor_name(context: Context, expected_name: str) -> None: + actor = context.spec_result + assert actor.name == expected_name, ( + f"Expected name={expected_name!r}, got {actor.name!r}" + ) + + +@then("the registered actor should exist in the actor service") +def step_assert_actor_persisted(context: Context) -> None: + actor = context.spec_result + stored = context.spec_actor_service.actors.get(actor.name) + assert stored is not None, f"Actor {actor.name!r} not found in actor service" + assert stored.provider == actor.provider + assert stored.model == actor.model + + +@then("the registered actor should be marked unsafe") +def step_assert_actor_unsafe(context: Context) -> None: + actor = context.spec_result + assert actor.unsafe is True, ( + f"Expected actor to be unsafe, got unsafe={actor.unsafe!r}" + ) + + +@then('the registered actor graph descriptor should contain key "{key}"') +def step_assert_registered_graph_key(context: Context, key: str) -> None: + actor = context.spec_result + assert actor.graph_descriptor is not None, ( + "Expected graph_descriptor to be set, got None" + ) + assert key in actor.graph_descriptor, ( + f"Expected key {key!r} in graph_descriptor, " + f"got keys: {list(actor.graph_descriptor.keys())}" + ) + + +@then('a spec-yaml ValidationError should be raised containing "{fragment}"') +def step_assert_validation_error(context: Context, fragment: str) -> None: + assert context.spec_error is not None, "Expected ValidationError not raised" + assert isinstance(context.spec_error, ValidationError), ( + f"Expected ValidationError, got {type(context.spec_error)}" + ) + assert fragment.lower() in str(context.spec_error).lower(), ( + f"Expected {fragment!r} in error, got {str(context.spec_error)!r}" + ) + + +@then('the spec-yaml extracted provider should be "{expected}"') +def step_assert_extracted_provider(context: Context, expected: str) -> None: + assert context.spec_extracted_provider == expected, ( + f"Expected {expected!r}, got {context.spec_extracted_provider!r}" + ) + + +@then("the spec-yaml extracted provider should be None") +def step_assert_extracted_provider_none(context: Context) -> None: + assert context.spec_extracted_provider is None, ( + f"Expected None, got {context.spec_extracted_provider!r}" + ) + + +@then('the spec-yaml extracted model should be "{expected}"') +def step_assert_extracted_model(context: Context, expected: str) -> None: + assert context.spec_extracted_model == expected, ( + f"Expected {expected!r}, got {context.spec_extracted_model!r}" + ) + + +@then("the spec-yaml extracted model should be None") +def step_assert_extracted_model_none(context: Context) -> None: + assert context.spec_extracted_model is None, ( + f"Expected None, got {context.spec_extracted_model!r}" + ) + + +@then('the spec-yaml extracted graph descriptor should contain key "{key}"') +def step_assert_extracted_graph_key(context: Context, key: str) -> None: + graph = context.spec_extracted_graph + assert graph is not None, "Expected graph descriptor to be set, got None" + assert key in graph, ( + f"Expected key {key!r} in graph descriptor, got keys: {list(graph.keys())}" + ) + + +@then('the spec-yaml extracted options should contain key "{key}" with value {value}') +def step_assert_extracted_options(context: Context, key: str, value: str) -> None: + assert context.spec_extracted_options is not None, "Options should not be None" + assert key in context.spec_extracted_options, ( + f"Key {key!r} not in options: {context.spec_extracted_options}" + ) + expected = ast.literal_eval(value) + assert context.spec_extracted_options[key] == expected, ( + f"Expected {expected!r}, got {context.spec_extracted_options[key]!r}" + ) + + +@then("the registered actor should not be marked unsafe") +def step_assert_actor_not_unsafe(context: Context) -> None: + actor = context.spec_result + assert actor.unsafe is False, ( + f"Expected actor.unsafe to be False, got {actor.unsafe!r}" + ) + + +@then("the registered actor graph descriptor should be None") +def step_assert_registered_graph_none(context: Context) -> None: + actor = context.spec_result + assert actor.graph_descriptor is None, ( + f"Expected graph_descriptor to be None, got {actor.graph_descriptor!r}" + ) + + +@then("the spec-yaml extracted graph descriptor should be None") +def step_assert_extracted_graph_none(context: Context) -> None: + assert context.spec_extracted_graph is None, ( + f"Expected None, got {context.spec_extracted_graph!r}" + ) + + +@then("the spec-yaml extracted unsafe flag should be False") +def step_assert_extracted_unsafe_false(context: Context) -> None: + assert context.spec_extracted_unsafe is False, ( + f"Expected False, got {context.spec_extracted_unsafe!r}" + ) + + +@then("the spec-yaml extracted unsafe flag should be True") +def step_assert_extracted_unsafe_true(context: Context) -> None: + assert context.spec_extracted_unsafe is True, ( + f"Expected True, got {context.spec_extracted_unsafe!r}" + ) + + +@then("the spec-yaml extracted options should be None") +def step_assert_extracted_options_none(context: Context) -> None: + assert context.spec_extracted_options is None, ( + f"Expected None, got {context.spec_extracted_options!r}" + ) + + +@then('the registered actor schema version should be "{expected}"') +def step_assert_schema_version(context: Context, expected: str) -> None: + actor = context.spec_result + assert actor.schema_version == expected, ( + f"Expected schema_version={expected!r}, got {actor.schema_version!r}" + ) + + +@then('the registered actor compiled metadata should contain key "{key}"') +def step_assert_compiled_metadata_key(context: Context, key: str) -> None: + actor = context.spec_result + assert actor.compiled_metadata is not None, ( + "Expected compiled_metadata to be set, got None" + ) + assert key in actor.compiled_metadata, ( + f"Expected key {key!r} in compiled_metadata, " + f"got keys: {list(actor.compiled_metadata.keys())}" + ) + + +@then('the registered actor compiled metadata key "{key}" should have value "{value}"') +def step_assert_compiled_metadata_key_value( + context: Context, key: str, value: str +) -> None: + actor = context.spec_result + assert actor.compiled_metadata is not None, ( + "Expected compiled_metadata to be set, got None" + ) + assert key in actor.compiled_metadata, ( + f"Expected key {key!r} in compiled_metadata, " + f"got keys: {list(actor.compiled_metadata.keys())}" + ) + assert actor.compiled_metadata[key] == value, ( + f"Expected compiled_metadata[{key!r}]={value!r}, " + f"got {actor.compiled_metadata[key]!r}" + ) + + +@then( + 'the registered actor config blob should contain options key "{key}" with value {value}' +) +def step_assert_config_blob_options(context: Context, key: str, value: str) -> None: + actor = context.spec_result + assert actor.config_blob is not None, "Expected config_blob to be set, got None" + options = actor.config_blob.get("options") + assert isinstance(options, dict), ( + f"Expected options dict in config_blob, got {type(options)}" + ) + assert key in options, ( + f"Expected key {key!r} in options, got keys: {list(options.keys())}" + ) + expected = ast.literal_eval(value) + assert options[key] == expected, ( + f"Expected options[{key!r}]={expected!r}, got {options[key]!r}" + ) + + +@then("the original blob options should be unmodified") +def step_assert_original_blob_unmodified(context: Context) -> None: + original_options = context.spec_original_blob["actors"]["a"]["config"]["options"] + assert original_options["temperature"] == 0.7, ( + f"Expected original temperature=0.7, got {original_options['temperature']!r}" + ) + assert original_options["max_tokens"] == 1024, ( + f"Expected original max_tokens=1024, got {original_options['max_tokens']!r}" + ) + assert "injected" not in original_options, ( + "Mutation leaked back into original blob: 'injected' key found" + ) + + +@then('the registered actor config blob should contain source "{expected}"') +def step_assert_config_blob_source(context: Context, expected: str) -> None: + actor = context.spec_result + assert actor.config_blob is not None, "Expected config_blob to be set, got None" + source = actor.config_blob.get("source") + assert source == expected, f"Expected source={expected!r}, got {source!r}" + + +@then('the spec-yaml extracted graph descriptor agent value should be "{expected}"') +def step_assert_extracted_graph_agent_value(context: Context, expected: str) -> None: + graph = context.spec_extracted_graph + assert graph is not None, "Expected graph descriptor to be set, got None" + assert "agent" in graph, ( + f"Expected key 'agent' in graph descriptor, got keys: {list(graph.keys())}" + ) + assert graph["agent"] == expected, ( + f"Expected agent={expected!r}, got {graph['agent']!r}" + ) + + +# ── Then: M5 — v3 TOOL actor without provider/model ────────────────── + + +@then("an error should be raised from upsert_actor") +def step_assert_error_from_upsert(context: Context) -> None: + assert context.spec_error is not None, ( + "Expected an error to be raised when upsert_actor receives empty provider/model," + " but no exception was captured" + ) + + +# ── Then: M6 — upsert_actor raises exception ───────────────────────── + + +@then("a RuntimeError should have been propagated from add") +def step_assert_runtime_error_propagated(context: Context) -> None: + assert context.spec_error is not None, ( + "Expected RuntimeError to propagate from upsert_actor through add(), " + "but no exception was captured" + ) + assert isinstance(context.spec_error, RuntimeError), ( + f"Expected RuntimeError, got {type(context.spec_error)}: {context.spec_error!r}" + ) + assert "upsert_actor service unavailable" in str(context.spec_error), ( + f"Unexpected RuntimeError message: {context.spec_error!r}" + ) + + +# ── Then: M8 — non-dict compiled_metadata ──────────────────────────── + + +@then("a Pydantic validation error should be raised for compiled_metadata") +def step_assert_pydantic_error_compiled_metadata(context: Context) -> None: + import pydantic + + assert context.spec_error is not None, ( + "Expected a Pydantic ValidationError when compiled_metadata is a non-dict," + " but no exception was captured" + ) + assert isinstance(context.spec_error, pydantic.ValidationError), ( + f"Expected pydantic.ValidationError, got {type(context.spec_error)}: " + f"{context.spec_error!r}" + ) diff --git a/features/steps/tui_prompt_textarea_steps.py b/features/steps/tui_prompt_textarea_steps.py index 81dabaefc..11da41767 100644 --- a/features/steps/tui_prompt_textarea_steps.py +++ b/features/steps/tui_prompt_textarea_steps.py @@ -7,6 +7,7 @@ from __future__ import annotations import importlib import sys +from typing import Any from types import ModuleType from behave import given, then, when @@ -49,7 +50,25 @@ def _build_mock_textual_with_textarea(): }, MockTextArea -def _install_mock_textual(context): +_PROMPT_MOD_NAME = "cleveragents.tui.widgets.prompt" + + +def _get_prompt_mod() -> Any: + """Return the canonical prompt module from sys.modules. + + Uses ``importlib.import_module`` (which always returns + ``sys.modules[name]``) instead of ``import cleveragents.tui.widgets.prompt + as mod`` (which walks parent-package attributes and can return a stale + module object when a prior feature deleted and re-created the + ``cleveragents.tui.*`` namespace). The stale object causes + ``importlib.reload()`` to fail with + ``ImportError: module ... not in sys.modules`` because Python 3.13's + reload checks ``sys.modules.get(name) is module``. + """ + return importlib.import_module(_PROMPT_MOD_NAME) + + +def _install_mock_textual(context: Any) -> None: """Inject mock textual into sys.modules and reload the prompt module.""" mocks, mock_textarea_cls = _build_mock_textual_with_textarea() context._prompt_saved_modules = {} @@ -58,14 +77,13 @@ def _install_mock_textual(context): for key, mod in mocks.items(): sys.modules[key] = mod - import cleveragents.tui.widgets.prompt as prompt_mod - + prompt_mod = _get_prompt_mod() importlib.reload(prompt_mod) context._prompt_mod = prompt_mod context._mock_textarea_cls = mock_textarea_cls -def _restore_modules(context): +def _restore_modules(context: Any) -> None: """Restore original sys.modules and reload the prompt module.""" for key, val in getattr(context, "_prompt_saved_modules", {}).items(): if val is None: @@ -73,9 +91,7 @@ def _restore_modules(context): else: sys.modules[key] = val - import cleveragents.tui.widgets.prompt as prompt_mod - - importlib.reload(prompt_mod) + importlib.reload(_get_prompt_mod()) # --------------------------------------------------------------------------- @@ -91,24 +107,23 @@ def step_load_prompt_with_mock_textarea(context): @given("the prompt module is loaded without textual") -def step_load_prompt_without_textual(context): +def step_load_prompt_without_textual(context: Any) -> None: """Remove textual from sys.modules so the fallback path is used.""" context._prompt_saved_modules_fallback = {} for key in _MOCK_TEXTUAL_KEYS: context._prompt_saved_modules_fallback[key] = sys.modules.pop(key, None) - import cleveragents.tui.widgets.prompt as prompt_mod - + prompt_mod = _get_prompt_mod() importlib.reload(prompt_mod) context._prompt_mod_fallback = prompt_mod - def restore(): + def restore() -> None: for key, val in context._prompt_saved_modules_fallback.items(): if val is None: sys.modules.pop(key, None) else: sys.modules[key] = val - importlib.reload(prompt_mod) + importlib.reload(_get_prompt_mod()) context.add_cleanup(restore) diff --git a/scripts/run_behave_parallel.py b/scripts/run_behave_parallel.py index 0731a103a..13e5fbecb 100644 --- a/scripts/run_behave_parallel.py +++ b/scripts/run_behave_parallel.py @@ -92,9 +92,28 @@ def _empty_summary() -> Summary: "scenarios": {"passed": 0, "failed": 0, "errors": 0, "skipped": 0}, "steps": {"passed": 0, "failed": 0, "errors": 0, "skipped": 0}, "duration": 0.0, + # Each entry is a string in behave's standard format: + # " features/path.feature:LINE Scenario name" + "failing_scenarios": [], + "errored_scenarios": [], } +def _scenario_ref(scenario: Any) -> str: + """Return the canonical behave reference string for a scenario. + + Produces `` features/foo.feature:42 Scenario name`` — the same + format that behave itself prints in its "Failing scenarios:" block, + so that the ``rui-find-failing-unit-tests`` skill can grep for it + directly in CI logs. + """ + filename = getattr(scenario, "filename", None) or getattr( + scenario.feature, "filename", "unknown" + ) + line = getattr(scenario, "line", 0) + return f" {filename}:{line} {scenario.name}" + + def _extract_summary(runner: Any) -> Summary: """Build a summary dict from a completed behave Runner.""" summary = _empty_summary() @@ -117,8 +136,10 @@ def _extract_summary(runner: Any) -> Summary: summary["scenarios"]["skipped"] += 1 elif sname == "failed": summary["scenarios"]["failed"] += 1 + summary["failing_scenarios"].append(_scenario_ref(scenario)) else: summary["scenarios"]["errors"] += 1 + summary["errored_scenarios"].append(_scenario_ref(scenario)) for step in scenario.steps: stname = step.status.name @@ -140,6 +161,8 @@ def _merge_summaries(summaries: list[Summary]) -> Summary: for field in ("passed", "failed", "errors", "skipped"): total[bucket][field] += s.get(bucket, {}).get(field, 0) total["duration"] += float(s.get("duration", 0.0)) + total["failing_scenarios"].extend(s.get("failing_scenarios", [])) + total["errored_scenarios"].extend(s.get("errored_scenarios", [])) return total @@ -167,6 +190,20 @@ def _print_overall_summary( if wall_seconds is not None: print(f"Wall time: {_format_duration(wall_seconds)}") + # Print consolidated failing/errored scenario lists in behave's standard + # format so CI logs are grep-able with the rui-find-failing-unit-tests + # skill. Deduplicate in case multiple workers reported the same scenario. + failing = sorted(set(total.get("failing_scenarios", []))) + errored = sorted(set(total.get("errored_scenarios", []))) + if failing: + print("\nFailing scenarios:") + for ref in failing: + print(ref) + if errored: + print("\nErrored scenarios:") + for ref in errored: + print(ref) + def _has_failures(total: Summary) -> bool: return ( diff --git a/src/cleveragents/actor/config.py b/src/cleveragents/actor/config.py index 959b268b4..79bccb32b 100644 --- a/src/cleveragents/actor/config.py +++ b/src/cleveragents/actor/config.py @@ -4,7 +4,7 @@ import json import os import re from pathlib import Path -from typing import Any, cast +from typing import Any, Literal, cast from pydantic import BaseModel, Field @@ -263,7 +263,10 @@ class ActorConfiguration(BaseModel): if option_overrides: merged_options.update(option_overrides) - resolved_unsafe = bool(data.get("unsafe", False)) or v2_unsafe or unsafe + top_unsafe_raw = data.get("unsafe", False) + resolved_unsafe = ( + (top_unsafe_raw is True or top_unsafe_raw == 1) or v2_unsafe or unsafe + ) if not resolved_provider: raise ValueError("provider is required") @@ -286,31 +289,106 @@ class ActorConfiguration(BaseModel): unsafe=resolved_unsafe, ) + @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_v2_actor( data: dict[str, Any], ) -> tuple[str | None, str | None, dict[str, Any] | None, bool]: - """Derive provider/model/graph from the v2 YAML actor config format.""" + """Derive provider/model/graph from the v2/spec YAML actor config format. - agents_obj = data.get("agents") - if isinstance(agents_obj, dict) and agents_obj: - agents_dict = cast(dict[str, Any], agents_obj) - for first_agent, first_entry in agents_dict.items(): + 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) - provider_value = config_dict.get("provider") or config_dict.get( - "provider_type" - ) - model_value = config_dict.get("model") or config_dict.get( - "model_id" - ) - unsafe_flag = bool(config_dict.get("unsafe", False)) + + # 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": first_agent, - "agents": agents_dict, + "agent": actor_key, + map_key: dict(map_dict), } for key in ( "routes", @@ -326,17 +404,21 @@ class ActorConfiguration(BaseModel): descriptor, unsafe_flag, ) - break + 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 actor config format.""" + """Extract option defaults from the v2/spec actor config format. - agents_obj = data.get("agents") - if isinstance(agents_obj, dict) and agents_obj: - agents_dict = cast(dict[str, Any], agents_obj) - for _, first_entry in agents_dict.items(): + 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") @@ -344,6 +426,9 @@ class ActorConfiguration(BaseModel): config_dict = cast(dict[str, Any], config_block) options = config_dict.get("options") if isinstance(options, dict): - return cast(dict[str, Any], options) - break + # 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/registry.py b/src/cleveragents/actor/registry.py index 2115e09b6..620f3b27a 100644 --- a/src/cleveragents/actor/registry.py +++ b/src/cleveragents/actor/registry.py @@ -184,6 +184,8 @@ class ActorRegistry: yaml_text: str, *, update: bool = False, + unsafe: bool = False, + allow_unsafe: bool = False, schema_version: str | None = None, compiled_metadata: dict[str, Any] | None = None, ) -> Actor: @@ -197,9 +199,30 @@ class ActorRegistry: using ``ActorConfigSchema`` to ensure proper schema compliance, including cycle detection for GRAPH actors. + .. note:: + + Only single-actor YAML is accepted. If the ``actors:`` or + ``agents:`` map contains more than one entry, a + ``ValidationError`` is raised. Multi-actor configurations + must be split and registered individually. + + .. note:: + + The nested ``actors:``/``agents:`` map is **always** consulted + for ``unsafe``, ``graph_descriptor``, and ``options`` extraction, + even when top-level ``provider`` and ``model`` fields are present. + This matches the behaviour of ``from_blob()``. + Args: yaml_text: The original actor YAML source. update: When ``True`` allow overwriting an existing actor. + unsafe: Asserts that the actor IS unsafe (maps to the spec's + "CLI --unsafe flag"). This flag both marks the actor as + unsafe *and* permits its registration. + allow_unsafe: Permits registration of an already-unsafe actor + without itself marking the actor as unsafe. Use this when + the YAML configuration declares ``unsafe: true`` and the + caller merely wants to allow it through the safety gate. schema_version: Explicit schema version; defaults to ``DEFAULT_SCHEMA_VERSION``. compiled_metadata: Optional compiler-produced metadata dict. @@ -208,8 +231,9 @@ class ActorRegistry: The persisted ``Actor`` domain object. Raises: - ValidationError: When the YAML is invalid or the actor already - exists and *update* is ``False``. + ValidationError: When the YAML is invalid, the actor already + exists and *update* is ``False``, or the actor is marked + unsafe but neither *unsafe* nor *allow_unsafe* is set. """ self.ensure_built_in_actors() @@ -232,6 +256,33 @@ class ActorRegistry: 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." @@ -244,6 +295,28 @@ class ActorRegistry: provider = str(provider_raw) if provider_raw else "" model = str(model_raw) if model_raw else "" + top_unsafe_raw = blob.get("unsafe", False) + # Accept only boolean ``True`` or integer ``1`` as unsafe. Note: + # ``top_unsafe_raw == 1`` also matches ``1.0`` (float) due to + # Python's numeric equality rules — this is fail-safe (treats 1.0 + # as unsafe). + # + # ``unsafe`` (the parameter) is an *assertion* — "this actor IS + # unsafe" (maps to the spec's "CLI --unsafe flag"). + # ``allow_unsafe`` is a *permission* — "I PERMIT an already-unsafe + # actor to be registered" but does NOT itself make the actor unsafe. + # Therefore only ``unsafe`` participates in the stored value; the + # spec rule is: "the result is true if *any* of: top-level unsafe, + # nested unsafe, or the CLI --unsafe flag is true." + effective_unsafe = ( + (top_unsafe_raw is True or top_unsafe_raw == 1) or nested_unsafe or unsafe + ) + if effective_unsafe and not (unsafe or allow_unsafe): + raise ValidationError( + "Actor configuration is marked unsafe; pass unsafe=True " + "or allow_unsafe=True to confirm." + ) + # Check for existing actor when not updating if not update: try: @@ -255,17 +328,38 @@ class ActorRegistry: 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) + version = schema_version or self.DEFAULT_SCHEMA_VERSION config_blob: dict[str, Any] = dict(blob) config_blob.setdefault("source", "yaml") + if v2_options is not None: + existing_options = config_blob.get("options") + if existing_options is None or not isinstance(existing_options, dict): + # No valid top-level options dict — use nested options directly. + config_blob["options"] = v2_options + else: + # Both top-level and nested options exist. Match ``from_blob()`` + # merge semantics: nested options as base, top-level overrides. + merged = dict(v2_options) + merged.update(existing_options) + config_blob["options"] = merged + + top_graph_raw = blob.get("graph_descriptor") + top_graph = top_graph_raw if top_graph_raw is not None else blob.get("graph") + resolved_graph = top_graph if top_graph is not None else nested_graph return self._actor_service.upsert_actor( name=name, provider=provider, model=model, config_blob=config_blob, - graph_descriptor=blob.get("graph_descriptor"), - unsafe=bool(blob.get("unsafe", False)), + graph_descriptor=resolved_graph, + unsafe=effective_unsafe, set_default=False, is_built_in=False, yaml_text=yaml_text, diff --git a/src/cleveragents/cli/commands/actor.py b/src/cleveragents/cli/commands/actor.py index 8cbc2ab5b..9bcaac7e6 100644 --- a/src/cleveragents/cli/commands/actor.py +++ b/src/cleveragents/cli/commands/actor.py @@ -658,8 +658,9 @@ def add( # Use the YAML-first path via registry.upsert_actor() with # yaml_text threaded through. This preserves the original YAML # text, schema_version, and compiled_metadata in the database - # while also honouring all CLI flags (--set-default, --option, - # --unsafe) that registry.add() does not accept. + # while also honouring all CLI flags (--set-default, --option) + # that registry.add() does not support. Note: registry.add() + # does accept ``unsafe`` and ``allow_unsafe`` parameters. actor = registry.upsert_actor( name=name, config_blob=canonical_blob,