fix(actor): resolve registry.add() rejection of spec-compliant actor YAML #10796

Merged
hurui200320 merged 1 commits from bugfix/m6-4466-registry-add-spec-compliant-yaml into master 2026-04-23 16:33:53 +00:00
8 changed files with 2477 additions and 42 deletions
+10
View File
@@ -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)
+542
View File
@@ -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
File diff suppressed because it is too large Load Diff
+27 -12
View File
@@ -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)
+37
View File
@@ -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 (
+109 -24
View File
@@ -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
+98 -4
View File
@@ -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.<name>.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,
+3 -2
View File
@@ -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,