fix(actor): resolve registry.add() rejection of spec-compliant actor YAML #10796
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user