Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f3487d7f3 | |||
| 25a37f1820 | |||
| ce7f360d33 | |||
| df44055fff | |||
| a59f2d68cd | |||
| 7a6d47c795 |
@@ -12,6 +12,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
leaks from dangling worktrees. Delegates to `GitWorktreeSandbox.cleanup_stale()`
|
||||
in the infrastructure layer.
|
||||
|
||||
- **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.
|
||||
|
||||
- **`plan apply` merge conflict cleanup** (#7250): When `git merge` fails due to
|
||||
a conflict during `plan apply`, the apply command now reads the actual conflict
|
||||
detail from `CalledProcessError.stdout` (git writes conflict info to stdout, not
|
||||
@@ -42,6 +48,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
location as the primary anchor point. This fix enables `agents init` to work
|
||||
correctly in all deployment modes: Docker containers, local pip installs
|
||||
(wheel or editable), and development environments.
|
||||
|
||||
- **Actor CLI v3 YAML Schema Support** (#6283): Fixed three components to add
|
||||
full v3 `ActorConfigSchema` support to the actor CLI registration and
|
||||
execution paths. `ActorConfiguration.from_blob()` now detects v3 format
|
||||
(top-level `type` key of `llm`/`graph`/`tool`) and correctly extracts
|
||||
provider, model, and graph descriptors — including `type: tool` actors
|
||||
without a `model` field. `ActorRegistry.add()` validates against the full
|
||||
Pydantic v2 schema, persists `skills`/`lsp`/`description` in the config
|
||||
blob, and compiles graph actors with proper metadata.
|
||||
`ReactiveConfigParser._build_from_v3()` now uses correct `source`/`target`
|
||||
edge keys (fixing `KeyError` in `to_graph_config()`), handles `config: null`
|
||||
nodes without crashing, propagates `context_view`/`memory`/`context`/
|
||||
`env_vars`/`response_format`/`lsp_capabilities`/`lsp_context_enrichment`
|
||||
into agent configs, and validates `entry_node` against the nodes map.
|
||||
Exception handling narrowed from broad `except Exception` to specific
|
||||
`NotFoundError` and `ActorCompilationError`. v3 registration logic
|
||||
extracted to `v3_registry.py` to keep `registry.py` under the 500-line
|
||||
limit. 16 BDD scenarios cover all v3 paths including tool actors,
|
||||
update mode, LSP dict bindings, and field propagation.
|
||||
- **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in
|
||||
- **bug-hunt-pool-supervisor Non-Blocking Tracking** (#8835): The automation-tracking-manager
|
||||
call in step 5 was blocking the main loop indefinitely, causing 3+ consecutive initialization
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
@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
|
||||
|
||||
# ── 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 unsafe limitation ───────────────────────────────────
|
||||
|
||||
Scenario: registry.add() only detects unsafe in first actor entry (known limitation)
|
||||
When I add a multi-actor YAML where unsafe is only in the second actor entry
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should not be marked unsafe
|
||||
|
||||
# ── _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 returns graph descriptor with actors map_key
|
||||
When I call _extract_v2_actor with an actors map containing combined actor field
|
||||
Then the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
|
||||
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
|
||||
|
||||
# ── Unsafe coercion edge cases (M2) ──────────────────────────────
|
||||
|
||||
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
|
||||
|
||||
# ── 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"
|
||||
@@ -0,0 +1,112 @@
|
||||
Feature: v3 Actor YAML schema support in CLI and registry
|
||||
As a developer using v3 actor YAML configs
|
||||
I want the CLI and registry to validate, persist, and execute v3 schema actors
|
||||
So that spec-compliant actors with skills, LSP bindings, and LangGraph routes work
|
||||
|
||||
Scenario: ActorConfiguration.from_blob extracts provider and model from v3 LLM YAML
|
||||
Given a v3 LLM actor blob with model "gpt-4"
|
||||
When I call ActorConfiguration.from_blob with the v3 blob
|
||||
Then the configuration should have provider "custom"
|
||||
And the configuration should have model "gpt-4"
|
||||
|
||||
Scenario: ActorConfiguration.from_blob infers provider from model with slash
|
||||
Given a v3 LLM actor blob with model "openai/gpt-4"
|
||||
When I call ActorConfiguration.from_blob with the v3 blob
|
||||
Then the configuration should have provider "openai"
|
||||
And the configuration should have model "openai/gpt-4"
|
||||
|
||||
Scenario: ActorConfiguration.from_blob builds graph descriptor for graph actors
|
||||
Given a v3 graph actor blob with a route block
|
||||
When I call ActorConfiguration.from_blob with the v3 blob
|
||||
Then the configuration should have a graph_descriptor with type "graph"
|
||||
|
||||
Scenario: ActorConfiguration.from_blob falls through to v2 for legacy YAML
|
||||
Given a v2 actor blob with agents and routes
|
||||
When I call ActorConfiguration.from_blob with the v2 blob
|
||||
Then the configuration should have provider "openai"
|
||||
And the configuration should have model "gpt-4"
|
||||
|
||||
Scenario: ActorRegistry.add validates v3 LLM actor YAML
|
||||
Given a mock actor service for v3 testing
|
||||
And a v3 LLM actor YAML text
|
||||
When I call ActorRegistry.add with the v3 YAML
|
||||
Then the actor should be persisted with v3 fields in config_blob
|
||||
And the persisted config_blob should contain skills
|
||||
And the persisted config_blob should contain lsp bindings
|
||||
|
||||
Scenario: ActorRegistry.add rejects v3 YAML without required description
|
||||
Given a mock actor service for v3 testing
|
||||
And a v3 actor YAML text missing description
|
||||
When I call ActorRegistry.add with the invalid v3 YAML
|
||||
Then a ValidationError should be raised mentioning description
|
||||
|
||||
Scenario: ActorRegistry.add validates and compiles v3 graph actor
|
||||
Given a mock actor service for v3 testing
|
||||
And a v3 graph actor YAML text with route
|
||||
When I call ActorRegistry.add with the v3 graph YAML
|
||||
Then the actor should be persisted with compiled metadata
|
||||
And the graph_descriptor should contain route information
|
||||
|
||||
Scenario: ReactiveConfigParser handles v3 LLM actor format
|
||||
Given a v3 LLM actor config dict
|
||||
When I parse the v3 config through ReactiveConfigParser
|
||||
Then the reactive config should have one agent
|
||||
And the agent config should contain the model
|
||||
And the agent config should contain skills
|
||||
|
||||
Scenario: ReactiveConfigParser handles v3 graph actor format
|
||||
Given a v3 graph actor config dict with route
|
||||
When I parse the v3 config through ReactiveConfigParser
|
||||
Then the reactive config should have agents for each node
|
||||
And the reactive config should have a graph route
|
||||
And the graph route edges should use source and target keys
|
||||
|
||||
Scenario: v3 format detection returns false for v2 data
|
||||
Given a v2 actor config dict with agents key
|
||||
When I check if the config is v3 format
|
||||
Then it should not be detected as v3
|
||||
|
||||
# M14: test update=True path
|
||||
Scenario: ActorRegistry.add with update=True overwrites existing actor
|
||||
Given a mock actor service for v3 testing
|
||||
And an existing actor in the mock service
|
||||
And a v3 LLM actor YAML text
|
||||
When I call ActorRegistry.add with update=True for the v3 YAML
|
||||
Then the actor should be persisted successfully with update
|
||||
|
||||
# M15: test type:tool extraction and parsing paths
|
||||
# from_blob requires model for ActorConfiguration — tool actors without
|
||||
# model raise ValueError. The proper v3 path is ActorRegistry.add().
|
||||
Scenario: ActorConfiguration.from_blob rejects type tool without model
|
||||
Given a v3 tool actor blob without model
|
||||
When I call ActorConfiguration.from_blob with the v3 tool blob
|
||||
Then the tool actor from_blob should raise ValueError for missing model
|
||||
|
||||
Scenario: ActorRegistry.add validates v3 tool actor YAML
|
||||
Given a mock actor service for v3 testing
|
||||
And a v3 tool actor YAML text
|
||||
When I call ActorRegistry.add with the v3 tool YAML
|
||||
Then the tool actor should be persisted with type tool
|
||||
|
||||
# m13: test _build_from_v3 with lsp as dict
|
||||
Scenario: ReactiveConfigParser handles v3 LLM actor with lsp as dict
|
||||
Given a v3 LLM actor config dict with lsp as dict
|
||||
When I parse the v3 config through ReactiveConfigParser
|
||||
Then the reactive config should have one agent
|
||||
And the agent config should contain lsp as dict
|
||||
|
||||
# m13: test context_view, memory, env_vars, response_format propagation
|
||||
Scenario: ReactiveConfigParser propagates context_view memory env_vars and response_format
|
||||
Given a v3 LLM actor config dict with context_view and memory
|
||||
When I parse the v3 config through ReactiveConfigParser
|
||||
Then the reactive config should have one agent
|
||||
And the agent config should contain context_view
|
||||
And the agent config should contain memory settings
|
||||
And the agent config should contain env_vars
|
||||
And the agent config should contain response_format
|
||||
|
||||
# m13: positive _is_v3_format test
|
||||
Scenario: v3 format detection returns true for v3 LLM data
|
||||
Given a v3 LLM actor config dict
|
||||
When I check if the v3 LLM config is v3 format
|
||||
Then it should be detected as v3
|
||||
@@ -74,7 +74,11 @@ class _StubActorService:
|
||||
|
||||
def get_actor(self, name: str) -> Actor:
|
||||
if name not in self.actors:
|
||||
raise NotFoundError(f"Actor '{name}' not found")
|
||||
raise NotFoundError(
|
||||
f"Actor '{name}' not found",
|
||||
resource_type="actor",
|
||||
resource_id=name,
|
||||
)
|
||||
return self.actors[name]
|
||||
|
||||
def list_actors(self) -> list[Actor]:
|
||||
@@ -82,7 +86,11 @@ class _StubActorService:
|
||||
|
||||
def remove_actor(self, name: str) -> None:
|
||||
if name not in self.actors:
|
||||
raise NotFoundError(f"Actor '{name}' not found")
|
||||
raise NotFoundError(
|
||||
f"Actor '{name}' not found",
|
||||
resource_type="actor",
|
||||
resource_id=name,
|
||||
)
|
||||
del self.actors[name]
|
||||
if self.default_actor_name == name:
|
||||
self.default_actor_name = None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
# pyright: reportRedeclaration=false
|
||||
"""Extended step definitions for v3 Actor YAML schema support.
|
||||
|
||||
Covers tool-actor extraction, update-mode overwriting, LSP-as-dict
|
||||
bindings, and field-propagation scenarios (context_view, memory,
|
||||
env_vars, response_format). Core scenarios live in
|
||||
``actor_v3_schema_steps.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.actor.config import ActorConfiguration
|
||||
from cleveragents.reactive.config_parser import ReactiveConfigParser
|
||||
|
||||
# ── Given steps ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@given("a v3 tool actor blob without model")
|
||||
def step_v3_tool_blob_no_model(context: Any) -> None:
|
||||
# Tool actors may omit model in v3 schema, but from_blob requires
|
||||
# a model for ActorConfiguration. The v3 extraction returns
|
||||
# provider="custom" and model="" which from_blob treats as missing.
|
||||
# The proper v3 path goes through ActorRegistry.add() / add_v3().
|
||||
context.v3_blob = {
|
||||
"name": "local/test-tool",
|
||||
"type": "tool",
|
||||
"description": "A test tool actor",
|
||||
"tools": ["local/file-ops"],
|
||||
}
|
||||
|
||||
|
||||
@given("a v3 tool actor YAML text")
|
||||
def step_v3_tool_yaml_text(context: Any) -> None:
|
||||
context.v3_tool_yaml_text = yaml.safe_dump(
|
||||
{
|
||||
"name": "local/test-tool",
|
||||
"type": "tool",
|
||||
"description": "A test tool actor",
|
||||
"tools": ["local/file-ops"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@given("a v3 LLM actor config dict with lsp as dict")
|
||||
def step_v3_llm_config_dict_lsp_dict(context: Any) -> None:
|
||||
context.v3_config = {
|
||||
"name": "local/test-llm",
|
||||
"type": "llm",
|
||||
"description": "A test LLM actor",
|
||||
"model": "gpt-4",
|
||||
"lsp": {"auto": True, "languages": ["python"]},
|
||||
}
|
||||
|
||||
|
||||
@given("a v3 LLM actor config dict with context_view and memory")
|
||||
def step_v3_llm_config_with_context_view(context: Any) -> None:
|
||||
context.v3_config = {
|
||||
"name": "local/test-llm",
|
||||
"type": "llm",
|
||||
"description": "A test LLM actor",
|
||||
"model": "gpt-4",
|
||||
"context_view": "executor",
|
||||
"memory": {"enabled": True, "max_messages": 50},
|
||||
"context": {"include_files": ["README.md"]},
|
||||
"env_vars": {"API_KEY": "test"},
|
||||
"response_format": {"type": "object"},
|
||||
}
|
||||
|
||||
|
||||
# ── When steps ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@when("I call ActorRegistry.add with update=True for the v3 YAML")
|
||||
def step_call_registry_add_v3_update(context: Any) -> None:
|
||||
context.result_actor = context.registry.add(context.v3_yaml_text, update=True)
|
||||
context.upsert_kwargs = context.mock_actor_service.upsert_actor.call_args
|
||||
|
||||
|
||||
@when("I call ActorConfiguration.from_blob with the v3 tool blob")
|
||||
def step_call_from_blob_v3_tool(context: Any) -> None:
|
||||
# from_blob requires model for ActorConfiguration, so tool actors
|
||||
# without model will raise ValueError. This is expected — the
|
||||
# proper v3 path is through ActorRegistry.add() / add_v3().
|
||||
try:
|
||||
context.actor_config = ActorConfiguration.from_blob(
|
||||
blob=context.v3_blob,
|
||||
name="local/test",
|
||||
)
|
||||
context.from_blob_error = None
|
||||
except ValueError as exc:
|
||||
context.from_blob_error = exc
|
||||
context.actor_config = None
|
||||
|
||||
|
||||
@when("I call ActorRegistry.add with the v3 tool YAML")
|
||||
def step_call_registry_add_v3_tool(context: Any) -> None:
|
||||
context.result_actor = context.registry.add(context.v3_tool_yaml_text)
|
||||
context.upsert_kwargs = context.mock_actor_service.upsert_actor.call_args
|
||||
|
||||
|
||||
@when("I check if the v3 LLM config is v3 format")
|
||||
def step_check_v3_llm_format(context: Any) -> None:
|
||||
context.is_v3 = ReactiveConfigParser._is_v3_format(context.v3_config)
|
||||
|
||||
|
||||
# ── Then steps ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@then("the actor should be persisted successfully with update")
|
||||
def step_actor_persisted_update(context: Any) -> None:
|
||||
assert context.result_actor is not None, "Expected actor to be persisted"
|
||||
call_kwargs = context.upsert_kwargs
|
||||
assert call_kwargs is not None, "upsert_actor was not called"
|
||||
|
||||
|
||||
@then("the tool actor from_blob should raise ValueError for missing model")
|
||||
def step_tool_from_blob_raises(context: Any) -> None:
|
||||
assert context.from_blob_error is not None, (
|
||||
"Expected ValueError for tool actor without model in from_blob"
|
||||
)
|
||||
assert "model" in str(context.from_blob_error).lower(), (
|
||||
f"Error should mention 'model': {context.from_blob_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("the tool actor should be persisted with type tool")
|
||||
def step_tool_actor_persisted(context: Any) -> None:
|
||||
call_kwargs = context.upsert_kwargs
|
||||
assert call_kwargs is not None, "upsert_actor was not called"
|
||||
_, kwargs = call_kwargs
|
||||
blob = kwargs.get("config_blob", {})
|
||||
assert blob.get("type") == "tool", (
|
||||
f"Expected type 'tool' in config_blob, got '{blob.get('type')}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the agent config should contain lsp as dict")
|
||||
def step_agent_config_has_lsp_dict(context: Any) -> None:
|
||||
agents = context.reactive_config.agents
|
||||
agent = next(iter(agents.values()))
|
||||
lsp = agent.config.get("lsp")
|
||||
assert isinstance(lsp, dict), f"Expected lsp as dict, got {type(lsp)}"
|
||||
assert lsp.get("auto") is True, f"Expected lsp.auto=True, got {lsp}"
|
||||
|
||||
|
||||
@then("the agent config should contain context_view")
|
||||
def step_agent_config_has_context_view(context: Any) -> None:
|
||||
agents = context.reactive_config.agents
|
||||
agent = next(iter(agents.values()))
|
||||
assert agent.config.get("context_view") == "executor", (
|
||||
f"Expected context_view 'executor', got '{agent.config.get('context_view')}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the agent config should contain memory settings")
|
||||
def step_agent_config_has_memory(context: Any) -> None:
|
||||
agents = context.reactive_config.agents
|
||||
agent = next(iter(agents.values()))
|
||||
memory = agent.config.get("memory")
|
||||
assert isinstance(memory, dict), f"Expected memory as dict, got {type(memory)}"
|
||||
assert memory.get("enabled") is True
|
||||
|
||||
|
||||
@then("the agent config should contain env_vars")
|
||||
def step_agent_config_has_env_vars(context: Any) -> None:
|
||||
agents = context.reactive_config.agents
|
||||
agent = next(iter(agents.values()))
|
||||
env_vars = agent.config.get("env_vars")
|
||||
assert isinstance(env_vars, dict), (
|
||||
f"Expected env_vars as dict, got {type(env_vars)}"
|
||||
)
|
||||
assert env_vars.get("API_KEY") == "test"
|
||||
|
||||
|
||||
@then("the agent config should contain response_format")
|
||||
def step_agent_config_has_response_format(context: Any) -> None:
|
||||
agents = context.reactive_config.agents
|
||||
agent = next(iter(agents.values()))
|
||||
rf = agent.config.get("response_format")
|
||||
assert isinstance(rf, dict), f"Expected response_format as dict, got {type(rf)}"
|
||||
assert rf.get("type") == "object"
|
||||
|
||||
|
||||
@then("it should be detected as v3")
|
||||
def step_is_v3(context: Any) -> None:
|
||||
assert context.is_v3 is True, "Expected v3 data to be detected as v3"
|
||||
@@ -0,0 +1,478 @@
|
||||
# pyright: reportRedeclaration=false
|
||||
"""Step definitions for v3 Actor YAML schema support (core scenarios).
|
||||
|
||||
Extended scenarios (tool actors, update mode, LSP dict, context
|
||||
propagation) are in ``actor_v3_schema_extended_steps.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import yaml
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.actor.compiler import CompilationMetadata, CompiledActor
|
||||
from cleveragents.actor.config import ActorConfiguration
|
||||
from cleveragents.actor.registry import ActorRegistry
|
||||
from cleveragents.core.exceptions import NotFoundError, ValidationError
|
||||
from cleveragents.domain.models.core.actor import Actor
|
||||
from cleveragents.reactive.config_parser import ReactiveConfigParser
|
||||
|
||||
|
||||
def _make_actor(
|
||||
*,
|
||||
name: str = "local/test-actor",
|
||||
provider: str = "custom",
|
||||
model: str = "gpt-4",
|
||||
config_blob: dict[str, Any] | None = None,
|
||||
yaml_text: str | None = None,
|
||||
compiled_metadata: dict[str, Any] | None = None,
|
||||
graph_descriptor: dict[str, Any] | None = None,
|
||||
) -> Actor:
|
||||
blob = config_blob or {}
|
||||
return Actor(
|
||||
id=1,
|
||||
name=name,
|
||||
provider=provider,
|
||||
model=model,
|
||||
config_blob=blob,
|
||||
config_hash=Actor.compute_hash(blob),
|
||||
graph_descriptor=graph_descriptor,
|
||||
unsafe=False,
|
||||
is_built_in=False,
|
||||
is_default=False,
|
||||
yaml_text=yaml_text,
|
||||
schema_version="1.0",
|
||||
compiled_metadata=compiled_metadata,
|
||||
)
|
||||
|
||||
|
||||
# ── Given steps ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@given('a v3 LLM actor blob with model "{model}"')
|
||||
def step_v3_llm_blob(context: Any, model: str) -> None:
|
||||
context.v3_blob = {
|
||||
"name": "local/test-llm",
|
||||
"type": "llm",
|
||||
"description": "A test LLM actor",
|
||||
"model": model,
|
||||
}
|
||||
|
||||
|
||||
@given("a v3 graph actor blob with a route block")
|
||||
def step_v3_graph_blob(context: Any) -> None:
|
||||
context.v3_blob = {
|
||||
"name": "local/test-graph",
|
||||
"type": "graph",
|
||||
"description": "A test graph actor",
|
||||
"model": "gpt-4",
|
||||
"route": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "planner",
|
||||
"type": "agent",
|
||||
"name": "Planner",
|
||||
"description": "Plans tasks",
|
||||
"config": {"model": "gpt-4"},
|
||||
},
|
||||
{
|
||||
"id": "executor",
|
||||
"type": "tool",
|
||||
"name": "Executor",
|
||||
"description": "Executes tasks",
|
||||
"config": {"tool_name": "exec/run"},
|
||||
},
|
||||
],
|
||||
"edges": [{"from_node": "planner", "to_node": "executor"}],
|
||||
"entry_node": "planner",
|
||||
"exit_nodes": ["executor"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@given("a v2 actor blob with agents and routes")
|
||||
def step_v2_blob(context: Any) -> None:
|
||||
context.v2_blob = {
|
||||
"agents": {
|
||||
"main": {
|
||||
"type": "llm",
|
||||
"config": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
},
|
||||
}
|
||||
},
|
||||
"routes": {},
|
||||
}
|
||||
|
||||
|
||||
@given("a mock actor service for v3 testing")
|
||||
def step_mock_actor_service(context: Any) -> None:
|
||||
mock_actor_service = MagicMock()
|
||||
|
||||
def upsert_side_effect(**kwargs: Any) -> Actor:
|
||||
return _make_actor(
|
||||
name=kwargs.get("name", "local/test"),
|
||||
provider=kwargs.get("provider", "custom"),
|
||||
model=kwargs.get("model", "gpt-4"),
|
||||
config_blob=kwargs.get("config_blob"),
|
||||
yaml_text=kwargs.get("yaml_text"),
|
||||
compiled_metadata=kwargs.get("compiled_metadata"),
|
||||
graph_descriptor=kwargs.get("graph_descriptor"),
|
||||
)
|
||||
|
||||
mock_actor_service.upsert_actor.side_effect = upsert_side_effect
|
||||
# C4: use NotFoundError instead of generic Exception.
|
||||
mock_actor_service.get_actor.side_effect = NotFoundError(
|
||||
"Actor not found", resource_type="actor", resource_id="unknown"
|
||||
)
|
||||
|
||||
mock_provider_registry = MagicMock()
|
||||
mock_provider_registry.get_configured_providers.return_value = []
|
||||
|
||||
mock_settings = MagicMock()
|
||||
|
||||
context.mock_actor_service = mock_actor_service
|
||||
context.registry = ActorRegistry(
|
||||
actor_service=mock_actor_service,
|
||||
provider_registry=mock_provider_registry,
|
||||
settings=mock_settings,
|
||||
)
|
||||
|
||||
|
||||
@given("a v3 LLM actor YAML text")
|
||||
def step_v3_llm_yaml_text(context: Any) -> None:
|
||||
context.v3_yaml_text = yaml.safe_dump(
|
||||
{
|
||||
"name": "local/test-llm",
|
||||
"type": "llm",
|
||||
"description": "A test LLM actor for v3 schema",
|
||||
"model": "gpt-4",
|
||||
"skills": ["local/file-ops", "local/code-search"],
|
||||
"lsp": ["local/pyright"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@given("a v3 actor YAML text missing description")
|
||||
def step_v3_yaml_missing_desc(context: Any) -> None:
|
||||
context.v3_yaml_invalid = yaml.safe_dump(
|
||||
{
|
||||
"name": "local/test-no-desc",
|
||||
"type": "llm",
|
||||
"model": "gpt-4",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@given("a v3 graph actor YAML text with route")
|
||||
def step_v3_graph_yaml_text(context: Any) -> None:
|
||||
context.v3_graph_yaml_text = yaml.safe_dump(
|
||||
{
|
||||
"name": "local/test-graph",
|
||||
"type": "graph",
|
||||
"description": "A test graph actor",
|
||||
"model": "gpt-4",
|
||||
"skills": ["local/file-ops"],
|
||||
"route": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "planner",
|
||||
"type": "agent",
|
||||
"name": "Planner",
|
||||
"description": "Plans tasks",
|
||||
"config": {"model": "gpt-4"},
|
||||
},
|
||||
{
|
||||
"id": "executor",
|
||||
"type": "tool",
|
||||
"name": "Executor",
|
||||
"description": "Executes tasks",
|
||||
"config": {"tool_name": "exec/run"},
|
||||
},
|
||||
],
|
||||
"edges": [{"from_node": "planner", "to_node": "executor"}],
|
||||
"entry_node": "planner",
|
||||
"exit_nodes": ["executor"],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@given("a v3 LLM actor config dict")
|
||||
def step_v3_llm_config_dict(context: Any) -> None:
|
||||
context.v3_config = {
|
||||
"name": "local/test-llm",
|
||||
"type": "llm",
|
||||
"description": "A test LLM actor",
|
||||
"model": "gpt-4",
|
||||
"system_prompt": "You are helpful",
|
||||
"skills": ["local/file-ops"],
|
||||
"lsp": ["local/pyright"],
|
||||
"tools": ["local/search"],
|
||||
}
|
||||
|
||||
|
||||
@given("a v3 graph actor config dict with route")
|
||||
def step_v3_graph_config_dict(context: Any) -> None:
|
||||
context.v3_config = {
|
||||
"name": "local/test-graph",
|
||||
"type": "graph",
|
||||
"description": "A test graph actor",
|
||||
"model": "gpt-4",
|
||||
"route": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "planner",
|
||||
"type": "agent",
|
||||
"name": "Planner",
|
||||
"description": "Plans",
|
||||
"config": {},
|
||||
},
|
||||
{
|
||||
"id": "executor",
|
||||
"type": "tool",
|
||||
"name": "Executor",
|
||||
"description": "Executes",
|
||||
"config": {},
|
||||
},
|
||||
],
|
||||
"edges": [{"from_node": "planner", "to_node": "executor"}],
|
||||
"entry_node": "planner",
|
||||
"exit_nodes": ["executor"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@given("a v2 actor config dict with agents key")
|
||||
def step_v2_config_dict(context: Any) -> None:
|
||||
context.v2_config = {
|
||||
"agents": {"main": {"type": "llm", "config": {"provider": "openai"}}},
|
||||
}
|
||||
|
||||
|
||||
@given("an existing actor in the mock service")
|
||||
def step_existing_actor(context: Any) -> None:
|
||||
"""Set up mock to return an existing actor for duplicate checks."""
|
||||
context.mock_actor_service.get_actor.side_effect = None
|
||||
context.mock_actor_service.get_actor.return_value = _make_actor(
|
||||
name="local/test-llm",
|
||||
)
|
||||
|
||||
|
||||
# ── When steps ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@when("I call ActorConfiguration.from_blob with the v3 blob")
|
||||
def step_call_from_blob_v3(context: Any) -> None:
|
||||
context.actor_config = ActorConfiguration.from_blob(
|
||||
blob=context.v3_blob,
|
||||
name="local/test",
|
||||
)
|
||||
|
||||
|
||||
@when("I call ActorConfiguration.from_blob with the v2 blob")
|
||||
def step_call_from_blob_v2(context: Any) -> None:
|
||||
context.actor_config = ActorConfiguration.from_blob(
|
||||
blob=context.v2_blob,
|
||||
name="local/test",
|
||||
)
|
||||
|
||||
|
||||
@when("I call ActorRegistry.add with the v3 YAML")
|
||||
def step_call_registry_add_v3(context: Any) -> None:
|
||||
context.result_actor = context.registry.add(context.v3_yaml_text)
|
||||
context.upsert_kwargs = context.mock_actor_service.upsert_actor.call_args
|
||||
|
||||
|
||||
@when("I call ActorRegistry.add with the invalid v3 YAML")
|
||||
def step_call_registry_add_invalid_v3(context: Any) -> None:
|
||||
try:
|
||||
context.registry.add(context.v3_yaml_invalid)
|
||||
context.add_error = None
|
||||
except ValidationError as exc:
|
||||
context.add_error = exc
|
||||
|
||||
|
||||
@when("I call ActorRegistry.add with the v3 graph YAML")
|
||||
def step_call_registry_add_v3_graph(context: Any) -> None:
|
||||
# M16: mock compile_actor to return controlled metadata.
|
||||
mock_metadata = CompilationMetadata(
|
||||
node_ids=["planner", "executor"],
|
||||
tool_nodes=["executor"],
|
||||
lsp_bindings=[],
|
||||
subgraph_refs={},
|
||||
entry_node="planner",
|
||||
exit_nodes=["executor"],
|
||||
)
|
||||
mock_compiled = CompiledActor(
|
||||
name="local/test-graph",
|
||||
nodes={},
|
||||
edges=[],
|
||||
entry_point="planner",
|
||||
metadata=mock_metadata,
|
||||
)
|
||||
with patch(
|
||||
"cleveragents.actor.compiler.compile_actor",
|
||||
return_value=mock_compiled,
|
||||
):
|
||||
context.result_actor = context.registry.add(context.v3_graph_yaml_text)
|
||||
context.upsert_kwargs = context.mock_actor_service.upsert_actor.call_args
|
||||
|
||||
|
||||
@when("I parse the v3 config through ReactiveConfigParser")
|
||||
def step_parse_v3_config(context: Any) -> None:
|
||||
parser = ReactiveConfigParser()
|
||||
context.reactive_config = parser._build(context.v3_config)
|
||||
|
||||
|
||||
@when("I check if the config is v3 format")
|
||||
def step_check_v3_format(context: Any) -> None:
|
||||
context.is_v3 = ReactiveConfigParser._is_v3_format(context.v2_config)
|
||||
|
||||
|
||||
# ── Then steps ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@then('the configuration should have provider "{provider}"')
|
||||
def step_config_has_provider(context: Any, provider: str) -> None:
|
||||
assert context.actor_config.provider == provider, (
|
||||
f"Expected provider '{provider}', got '{context.actor_config.provider}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the configuration should have model "{model}"')
|
||||
def step_config_has_model(context: Any, model: str) -> None:
|
||||
assert context.actor_config.model == model, (
|
||||
f"Expected model '{model}', got '{context.actor_config.model}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the configuration should have a graph_descriptor with type "{gtype}"')
|
||||
def step_config_has_graph_descriptor(context: Any, gtype: str) -> None:
|
||||
gd = context.actor_config.graph_descriptor
|
||||
assert gd is not None, "Expected graph_descriptor, got None"
|
||||
assert gd.get("type") == gtype, (
|
||||
f"Expected graph_descriptor type '{gtype}', got '{gd.get('type')}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the actor should be persisted with v3 fields in config_blob")
|
||||
def step_actor_persisted_v3(context: Any) -> None:
|
||||
call_kwargs = context.upsert_kwargs
|
||||
assert call_kwargs is not None, "upsert_actor was not called"
|
||||
_, kwargs = call_kwargs
|
||||
blob = kwargs.get("config_blob", {})
|
||||
assert blob.get("type") == "llm", (
|
||||
f"Expected type 'llm' in config_blob, got '{blob.get('type')}'"
|
||||
)
|
||||
assert blob.get("description") == "A test LLM actor for v3 schema", (
|
||||
f"Missing or wrong description: {blob.get('description')}"
|
||||
)
|
||||
|
||||
|
||||
@then("the persisted config_blob should contain skills")
|
||||
def step_blob_has_skills(context: Any) -> None:
|
||||
_, kwargs = context.upsert_kwargs
|
||||
blob = kwargs.get("config_blob", {})
|
||||
skills = blob.get("skills", [])
|
||||
assert len(skills) == 2, f"Expected 2 skills, got {len(skills)}"
|
||||
assert "local/file-ops" in skills
|
||||
|
||||
|
||||
@then("the persisted config_blob should contain lsp bindings")
|
||||
def step_blob_has_lsp(context: Any) -> None:
|
||||
_, kwargs = context.upsert_kwargs
|
||||
blob = kwargs.get("config_blob", {})
|
||||
lsp = blob.get("lsp")
|
||||
assert lsp is not None, "Expected lsp in config_blob"
|
||||
assert "local/pyright" in lsp
|
||||
|
||||
|
||||
@then("a ValidationError should be raised mentioning description")
|
||||
def step_validation_error_description(context: Any) -> None:
|
||||
assert context.add_error is not None, "Expected a ValidationError to be raised"
|
||||
error_msg = str(context.add_error).lower()
|
||||
assert "description" in error_msg, (
|
||||
f"Error message should mention 'description': {context.add_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("the actor should be persisted with compiled metadata")
|
||||
def step_actor_has_compiled_metadata(context: Any) -> None:
|
||||
_, kwargs = context.upsert_kwargs
|
||||
compiled = kwargs.get("compiled_metadata")
|
||||
assert compiled is not None, "Expected compiled_metadata, got None"
|
||||
assert "node_ids" in compiled, (
|
||||
f"compiled_metadata should contain node_ids: {compiled}"
|
||||
)
|
||||
|
||||
|
||||
@then("the graph_descriptor should contain route information")
|
||||
def step_graph_descriptor_route(context: Any) -> None:
|
||||
_, kwargs = context.upsert_kwargs
|
||||
gd = kwargs.get("graph_descriptor")
|
||||
assert gd is not None, "Expected graph_descriptor, got None"
|
||||
assert "route" in gd, f"graph_descriptor should contain 'route': {gd}"
|
||||
|
||||
|
||||
@then("the reactive config should have one agent")
|
||||
def step_reactive_has_one_agent(context: Any) -> None:
|
||||
agents = context.reactive_config.agents
|
||||
assert len(agents) == 1, f"Expected 1 agent, got {len(agents)}"
|
||||
|
||||
|
||||
@then("the agent config should contain the model")
|
||||
def step_agent_config_has_model(context: Any) -> None:
|
||||
agents = context.reactive_config.agents
|
||||
agent = next(iter(agents.values()))
|
||||
assert agent.config.get("model") == "gpt-4", (
|
||||
f"Agent model mismatch: {agent.config.get('model')}"
|
||||
)
|
||||
|
||||
|
||||
@then("the agent config should contain skills")
|
||||
def step_agent_config_has_skills(context: Any) -> None:
|
||||
agents = context.reactive_config.agents
|
||||
agent = next(iter(agents.values()))
|
||||
skills = agent.config.get("skills", [])
|
||||
assert "local/file-ops" in skills, f"Expected 'local/file-ops' in skills: {skills}"
|
||||
|
||||
|
||||
@then("the reactive config should have agents for each node")
|
||||
def step_reactive_has_node_agents(context: Any) -> None:
|
||||
agents = context.reactive_config.agents
|
||||
assert "planner" in agents, f"Missing 'planner' agent: {list(agents.keys())}"
|
||||
assert "executor" in agents, f"Missing 'executor' agent: {list(agents.keys())}"
|
||||
|
||||
|
||||
@then("the reactive config should have a graph route")
|
||||
def step_reactive_has_graph_route(context: Any) -> None:
|
||||
routes = context.reactive_config.routes
|
||||
assert len(routes) >= 1, f"Expected at least 1 route, got {len(routes)}"
|
||||
route = next(iter(routes.values()))
|
||||
assert route.type.value == "graph", f"Expected graph route, got {route.type.value}"
|
||||
# Nit: also check entry_point and edges.
|
||||
assert route.entry_point == "planner", (
|
||||
f"Expected entry_point 'planner', got '{route.entry_point}'"
|
||||
)
|
||||
assert len(route.edges) >= 1, f"Expected at least 1 edge, got {len(route.edges)}"
|
||||
|
||||
|
||||
@then("it should not be detected as v3")
|
||||
def step_not_v3(context: Any) -> None:
|
||||
assert context.is_v3 is False, "Expected v2 data to NOT be detected as v3"
|
||||
|
||||
|
||||
@then("the graph route edges should use source and target keys")
|
||||
def step_graph_edges_use_source_target(context: Any) -> None:
|
||||
routes = context.reactive_config.routes
|
||||
route = next(iter(routes.values()))
|
||||
for edge in route.edges:
|
||||
assert "source" in edge, f"Edge missing 'source' key: {edge}"
|
||||
assert "target" in edge, f"Edge missing 'target' key: {edge}"
|
||||
assert "from" not in edge, f"Edge should not have 'from' key: {edge}"
|
||||
assert "to" not in edge, f"Edge should not have 'to' key: {edge}"
|
||||
@@ -1267,6 +1267,9 @@ def step_assert_ckpt_match(context: Context) -> None:
|
||||
|
||||
@given("drcov3 multiple checkpoints exist for the plan")
|
||||
def step_insert_multi_ckpts(context: Context) -> None:
|
||||
# Use a fixed base time so ISO-8601 string ordering is deterministic
|
||||
# regardless of clock resolution or CI environment timing.
|
||||
base_time = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||
context.drcov3_ckpt_ids = []
|
||||
for i in range(3):
|
||||
cid = _next_ulid()
|
||||
@@ -1274,7 +1277,7 @@ def step_insert_multi_ckpts(context: Context) -> None:
|
||||
ckpt = _make_checkpoint(
|
||||
plan_id=context.drcov3_ckpt_plan_id,
|
||||
checkpoint_id=cid,
|
||||
created_at=datetime.now(UTC) + timedelta(seconds=i),
|
||||
created_at=base_time + timedelta(seconds=i),
|
||||
)
|
||||
context.drcov3_ckpt_repo.create(ckpt)
|
||||
|
||||
@@ -1327,14 +1330,25 @@ def step_assert_ckpt_deleted(context: Context) -> None:
|
||||
|
||||
@given("drcov3 five checkpoints exist for prune test")
|
||||
def step_create_five_ckpts(context: Context) -> None:
|
||||
# Use a dedicated plan for the prune test to avoid interference
|
||||
# from checkpoints created in earlier scenarios of this feature.
|
||||
context.drcov3_prune_plan_id = _next_ulid()
|
||||
session = context.drcov3_factory()
|
||||
_ensure_plan_row(session, context.drcov3_prune_plan_id)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
# Use a fixed base time so ISO-8601 string ordering is deterministic
|
||||
# regardless of clock resolution or CI environment timing.
|
||||
base_time = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||||
context.drcov3_prune_ckpt_ids = []
|
||||
for i in range(5):
|
||||
cid = _next_ulid()
|
||||
context.drcov3_prune_ckpt_ids.append(cid)
|
||||
ckpt = _make_checkpoint(
|
||||
plan_id=context.drcov3_ckpt_plan_id,
|
||||
plan_id=context.drcov3_prune_plan_id,
|
||||
checkpoint_id=cid,
|
||||
created_at=datetime.now(UTC) + timedelta(seconds=i),
|
||||
created_at=base_time + timedelta(seconds=i),
|
||||
)
|
||||
context.drcov3_ckpt_repo.create(ckpt)
|
||||
|
||||
@@ -1343,7 +1357,7 @@ def step_create_five_ckpts(context: Context) -> None:
|
||||
def step_prune_ckpts(context: Context) -> None:
|
||||
try:
|
||||
context.drcov3_result = context.drcov3_ckpt_repo.prune(
|
||||
context.drcov3_ckpt_plan_id, max_checkpoints=3
|
||||
context.drcov3_prune_plan_id, max_checkpoints=3
|
||||
)
|
||||
except Exception as exc:
|
||||
context.drcov3_error = exc
|
||||
|
||||
@@ -16,6 +16,10 @@ except Exception: # pragma: no cover - defensive optional import
|
||||
yaml = None
|
||||
|
||||
|
||||
#: v3 actor types that signal the ``ActorConfigSchema`` format.
|
||||
_V3_ACTOR_TYPES: frozenset[str] = frozenset({"llm", "graph", "tool"})
|
||||
|
||||
|
||||
class ActorConfiguration(BaseModel):
|
||||
"""Canonical actor configuration parsed from user-provided blobs."""
|
||||
|
||||
@@ -231,21 +235,37 @@ class ActorConfiguration(BaseModel):
|
||||
default_options: dict[str, Any] | None = None,
|
||||
option_overrides: dict[str, Any] | None = None,
|
||||
) -> ActorConfiguration:
|
||||
"""Coerce an incoming config blob plus CLI overrides into a validated model."""
|
||||
"""Coerce an incoming config blob plus CLI overrides into a validated model.
|
||||
|
||||
Supports both the legacy v2 ``agents/routes`` YAML layout and the v3
|
||||
``ActorConfigSchema`` format (identified by a top-level ``type`` key
|
||||
whose value is one of ``llm``, ``graph``, or ``tool``).
|
||||
"""
|
||||
|
||||
data: dict[str, Any] = dict(blob) if isinstance(blob, dict) else {}
|
||||
|
||||
# Try v3 extraction first (presence of 'type' with a v3 value).
|
||||
v3_provider, v3_model, v3_graph, v3_unsafe = cls._extract_v3_actor(data)
|
||||
|
||||
# Fall back to v2 extraction when v3 didn't match.
|
||||
v2_provider, v2_model, v2_graph, v2_unsafe = cls._extract_v2_actor(data)
|
||||
v2_options = cls._extract_v2_options(data)
|
||||
|
||||
resolved_provider = (
|
||||
provider or data.get("provider") or data.get("provider_type") or v2_provider
|
||||
provider
|
||||
or data.get("provider")
|
||||
or data.get("provider_type")
|
||||
or v3_provider
|
||||
or v2_provider
|
||||
)
|
||||
resolved_model = (
|
||||
model or data.get("model") or data.get("model_id") or v3_model or v2_model
|
||||
)
|
||||
resolved_model = model or data.get("model") or data.get("model_id") or v2_model
|
||||
resolved_graph = (
|
||||
graph_descriptor
|
||||
or data.get("graph_descriptor")
|
||||
or data.get("graph")
|
||||
or v3_graph
|
||||
or v2_graph
|
||||
)
|
||||
|
||||
@@ -263,7 +283,12 @@ 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)
|
||||
# Accept only boolean True or integer 1 as unsafe (not truthy strings).
|
||||
# Also incorporate v3_unsafe (from v3 schema path) and v2_unsafe.
|
||||
resolved_unsafe = (
|
||||
(top_unsafe_raw is True or top_unsafe_raw == 1) or v3_unsafe or v2_unsafe or unsafe
|
||||
)
|
||||
|
||||
if not resolved_provider:
|
||||
raise ValueError("provider is required")
|
||||
@@ -286,31 +311,145 @@ class ActorConfiguration(BaseModel):
|
||||
unsafe=resolved_unsafe,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_v3_actor(
|
||||
data: dict[str, Any],
|
||||
) -> tuple[str | None, str | None, dict[str, Any] | None, bool]:
|
||||
"""Derive provider/model/graph from the v3 Actor YAML schema format.
|
||||
|
||||
The v3 schema is identified by a top-level ``type`` key whose value
|
||||
is one of ``llm``, ``graph``, or ``tool``. The model is taken from
|
||||
the top-level ``model`` key. Because v3 YAML does not carry a
|
||||
``provider`` field the provider is inferred from the model string:
|
||||
|
||||
* If the model contains ``/`` the prefix is used as provider
|
||||
(e.g. ``openai/gpt-4`` → provider ``openai``).
|
||||
* Otherwise ``"custom"`` is used as a fallback.
|
||||
|
||||
For ``type: graph`` actors the ``route`` block is wrapped into a
|
||||
graph descriptor.
|
||||
|
||||
Returns:
|
||||
``(provider, model, graph_descriptor, unsafe)`` — any component
|
||||
may be ``None`` if the data does not look like v3.
|
||||
"""
|
||||
actor_type = data.get("type")
|
||||
if not isinstance(actor_type, str):
|
||||
return None, None, None, False
|
||||
if actor_type.lower() not in _V3_ACTOR_TYPES:
|
||||
return None, None, None, False
|
||||
|
||||
model_value = data.get("model")
|
||||
# C2: model is optional for TOOL actors — only reject when model
|
||||
# is absent for types that require it (LLM, GRAPH).
|
||||
if not model_value or not isinstance(model_value, str):
|
||||
if actor_type.lower() != "tool":
|
||||
return None, None, None, False
|
||||
# TOOL actors may omit model; use empty string as sentinel.
|
||||
model_value = ""
|
||||
|
||||
# Infer provider from model string or default to "custom".
|
||||
if model_value and "/" in model_value:
|
||||
provider_value = model_value.split("/", 1)[0]
|
||||
else:
|
||||
provider_value = "custom"
|
||||
|
||||
unsafe_flag = bool(data.get("unsafe", False))
|
||||
|
||||
# Build a graph descriptor for graph actors from the route block.
|
||||
graph_descriptor: dict[str, Any] | None = None
|
||||
if actor_type.lower() == "graph":
|
||||
route_raw = data.get("route")
|
||||
if isinstance(route_raw, dict):
|
||||
graph_descriptor = {
|
||||
"type": "graph",
|
||||
"route": route_raw,
|
||||
"model": model_value,
|
||||
}
|
||||
|
||||
return provider_value, model_value, graph_descriptor, unsafe_flag
|
||||
|
||||
@staticmethod
|
||||
def _extract_v2_actor(
|
||||
data: dict[str, Any],
|
||||
) -> 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.
|
||||
"""
|
||||
|
||||
# The spec allows either ``actors:`` or ``agents:`` as the top-level
|
||||
# map key (oneOf). Try both, preferring ``actors:`` (spec-canonical).
|
||||
actors_val = data.get("actors")
|
||||
if actors_val is not None:
|
||||
map_obj = actors_val
|
||||
map_key = "actors"
|
||||
else:
|
||||
map_obj = data.get("agents")
|
||||
map_key = "agents"
|
||||
|
||||
# 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:``.
|
||||
#
|
||||
# LIMITATION: Only the **first** entry in the actors/agents map is
|
||||
# inspected for provider, model, unsafe, and graph descriptor. If a
|
||||
# multi-actor YAML has ``unsafe: true`` in a *later* actor's config
|
||||
# block but not in the first, the unsafe flag will not be detected.
|
||||
# The ``add()`` method is designed for single-actor YAML files; the
|
||||
# multi-actor case is handled by the v3 schema validation path.
|
||||
if isinstance(map_obj, dict) and map_obj:
|
||||
map_dict = cast(dict[str, Any], map_obj)
|
||||
# Only the first actor entry is used for provider/model extraction.
|
||||
for actor_key, first_entry in map_dict.items():
|
||||
if isinstance(first_entry, dict):
|
||||
entry_dict = cast(dict[str, Any], first_entry)
|
||||
config_block = entry_dict.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
config_dict = cast(dict[str, Any], config_block)
|
||||
|
||||
# Support the combined ``actor`` field format
|
||||
# (e.g. ``"openai/gpt-4"`` → provider + model).
|
||||
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))
|
||||
|
||||
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: map_dict,
|
||||
}
|
||||
for key in (
|
||||
"routes",
|
||||
@@ -326,17 +465,22 @@ 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.
|
||||
"""
|
||||
|
||||
actors_val = data.get("actors")
|
||||
map_obj = actors_val if actors_val is not None else data.get("agents")
|
||||
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")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
@@ -9,6 +10,7 @@ import pydantic
|
||||
|
||||
from cleveragents.actor.config import ActorConfiguration
|
||||
from cleveragents.actor.schema import ActorConfigSchema, is_v3_yaml
|
||||
from cleveragents.actor.v3_registry import add_v3, is_v3_blob
|
||||
from cleveragents.application.services.actor_service import ActorService
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.core.exceptions import NotFoundError, ValidationError
|
||||
@@ -19,6 +21,8 @@ from cleveragents.providers.registry import (
|
||||
ProviderRegistry,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ActorRegistry:
|
||||
"""Coordinate actor configuration parsing and built-in generation.
|
||||
@@ -184,6 +188,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:
|
||||
@@ -192,28 +198,108 @@ class ActorRegistry:
|
||||
The YAML is parsed into an ``ActorConfiguration``, validated, and
|
||||
persisted alongside the original *yaml_text* and *schema_version*.
|
||||
|
||||
For v3 YAML files (detected by any non-null ``type`` field or a
|
||||
``version`` starting with ``'3'``), the configuration is validated
|
||||
using ``ActorConfigSchema`` to ensure proper schema compliance,
|
||||
including cycle detection for GRAPH actors.
|
||||
When the YAML matches the v3 ``ActorConfigSchema`` (detected by a
|
||||
top-level ``type`` key of ``llm``, ``graph``, or ``tool``) the full
|
||||
schema is validated, ``description`` is enforced, and ``skills`` /
|
||||
``lsp`` bindings are stored in the config blob. For ``type: graph``
|
||||
actors the route is additionally compiled and the compilation
|
||||
metadata is persisted.
|
||||
|
||||
.. note::
|
||||
|
||||
The nested ``actors:``/``agents:`` map is **always** consulted
|
||||
for ``unsafe`` and ``graph_descriptor`` 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: Caller confirms the actor is allowed to be unsafe.
|
||||
allow_unsafe: Alternative flag to permit unsafe actors.
|
||||
schema_version: Explicit schema version; defaults to
|
||||
``DEFAULT_SCHEMA_VERSION``.
|
||||
compiled_metadata: Optional compiler-produced metadata dict.
|
||||
allow_unsafe: When ``True`` permit actors marked ``unsafe``.
|
||||
|
||||
Returns:
|
||||
The persisted ``Actor`` domain object.
|
||||
|
||||
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 unsafe
|
||||
and neither *unsafe* nor *allow_unsafe* is set.
|
||||
"""
|
||||
self.ensure_built_in_actors()
|
||||
|
||||
blob = ActorConfiguration.load_yaml_text(yaml_text)
|
||||
if not isinstance(blob, dict):
|
||||
raise ValidationError("Actor YAML must be a mapping.")
|
||||
|
||||
version = schema_version or self.DEFAULT_SCHEMA_VERSION
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# v3 path: validate against ActorConfigSchema when detected
|
||||
# ----------------------------------------------------------
|
||||
if is_v3_blob(blob):
|
||||
return add_v3(
|
||||
blob,
|
||||
yaml_text=yaml_text,
|
||||
update=update,
|
||||
schema_version=version,
|
||||
compiled_metadata=compiled_metadata,
|
||||
actor_service=self._actor_service,
|
||||
allow_unsafe=allow_unsafe,
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Legacy (v2) path: flat provider/model extraction
|
||||
# ----------------------------------------------------------
|
||||
return self._add_legacy(
|
||||
blob,
|
||||
yaml_text=yaml_text,
|
||||
update=update,
|
||||
schema_version=version,
|
||||
compiled_metadata=compiled_metadata,
|
||||
unsafe=unsafe,
|
||||
allow_unsafe=allow_unsafe,
|
||||
)
|
||||
|
||||
def _add_legacy(
|
||||
self,
|
||||
blob: dict[str, Any],
|
||||
*,
|
||||
yaml_text: str,
|
||||
update: bool,
|
||||
schema_version: str,
|
||||
compiled_metadata: dict[str, Any] | None,
|
||||
unsafe: bool = False,
|
||||
allow_unsafe: bool = False,
|
||||
) -> Actor:
|
||||
"""Register an actor from a legacy (v2) blob.
|
||||
|
||||
Extracts ``name``, ``provider``, and ``model`` from the flat
|
||||
top-level keys of the blob. Validates that all required fields
|
||||
are present and that the actor does not already exist (unless
|
||||
*update* is ``True``). Enforces the ``unsafe`` flag when
|
||||
*allow_unsafe* is ``False``.
|
||||
|
||||
Args:
|
||||
blob: Parsed YAML blob in legacy v2 format.
|
||||
yaml_text: Original YAML source text.
|
||||
update: When ``True`` allow overwriting an existing actor.
|
||||
schema_version: Schema version string.
|
||||
compiled_metadata: Optional pre-computed compilation metadata.
|
||||
allow_unsafe: When ``True`` permit actors marked ``unsafe``.
|
||||
|
||||
Returns:
|
||||
The persisted ``Actor`` domain object.
|
||||
|
||||
Raises:
|
||||
ValidationError: When required fields are missing, the actor
|
||||
already exists and *update* is ``False``, or the actor is
|
||||
unsafe and *allow_unsafe* is ``False``.
|
||||
"""
|
||||
name_raw: str = blob.get("name", "")
|
||||
if not name_raw:
|
||||
raise ValidationError("Actor YAML must include a 'name' field.")
|
||||
@@ -232,6 +318,18 @@ class ActorRegistry:
|
||||
provider_raw = blob.get("provider") or blob.get("provider_type", "")
|
||||
model_raw = blob.get("model") or blob.get("model_id", "")
|
||||
|
||||
# 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,7 +342,20 @@ class ActorRegistry:
|
||||
provider = str(provider_raw) if provider_raw else ""
|
||||
model = str(model_raw) if model_raw else ""
|
||||
|
||||
# Check for existing actor when not updating
|
||||
top_unsafe_raw = blob.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).
|
||||
effective_unsafe = (
|
||||
top_unsafe_raw is True or top_unsafe_raw == 1
|
||||
) or nested_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."
|
||||
)
|
||||
|
||||
# M3: catch only NotFoundError, not generic Exception.
|
||||
if not update:
|
||||
try:
|
||||
self._actor_service.get_actor(name)
|
||||
@@ -255,21 +366,24 @@ class ActorRegistry:
|
||||
f"Actor '{name}' already exists. Pass update=True to overwrite."
|
||||
)
|
||||
|
||||
version = schema_version or self.DEFAULT_SCHEMA_VERSION
|
||||
config_blob: dict[str, Any] = dict(blob)
|
||||
config_blob.setdefault("source", "yaml")
|
||||
|
||||
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,
|
||||
schema_version=version,
|
||||
schema_version=schema_version,
|
||||
compiled_metadata=compiled_metadata,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""v3 Actor registration logic extracted from ``ActorRegistry``.
|
||||
|
||||
This module handles the v3 ``ActorConfigSchema`` registration path,
|
||||
including schema validation, provider inference, graph compilation,
|
||||
and unsafe flag enforcement. It is used by :class:`ActorRegistry`
|
||||
to keep the main registry module under the 500-line limit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from cleveragents.actor.compiler import ActorCompilationError, compile_actor
|
||||
from cleveragents.actor.config import _V3_ACTOR_TYPES
|
||||
from cleveragents.actor.schema import ActorConfigSchema
|
||||
from cleveragents.application.services.actor_service import ActorService
|
||||
from cleveragents.core.exceptions import NotFoundError, ValidationError
|
||||
from cleveragents.domain.models.core import Actor
|
||||
from cleveragents.reactive.config_parser import _infer_provider_from_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_v3_blob(blob: dict[str, Any]) -> bool:
|
||||
"""Return ``True`` when *blob* looks like a v3 ``ActorConfigSchema``."""
|
||||
actor_type = blob.get("type")
|
||||
return isinstance(actor_type, str) and actor_type.lower() in _V3_ACTOR_TYPES
|
||||
|
||||
|
||||
def add_v3(
|
||||
blob: dict[str, Any],
|
||||
*,
|
||||
yaml_text: str,
|
||||
update: bool,
|
||||
schema_version: str,
|
||||
compiled_metadata: dict[str, Any] | None,
|
||||
actor_service: ActorService,
|
||||
allow_unsafe: bool = False,
|
||||
) -> Actor:
|
||||
"""Register an actor from a v3 ``ActorConfigSchema`` blob.
|
||||
|
||||
Validates the full v3 schema, infers ``provider`` from the model
|
||||
string, persists ``skills`` / ``lsp`` / ``description`` in the
|
||||
config blob, and compiles ``type: graph`` actors.
|
||||
|
||||
Args:
|
||||
blob: Parsed YAML blob matching the v3 schema.
|
||||
yaml_text: Original YAML source text.
|
||||
update: When ``True`` allow overwriting an existing actor.
|
||||
schema_version: Schema version string.
|
||||
compiled_metadata: Optional pre-computed compilation metadata.
|
||||
When provided for graph actors, compilation is skipped.
|
||||
actor_service: The actor persistence service.
|
||||
allow_unsafe: When ``True`` permit actors marked ``unsafe``.
|
||||
|
||||
Returns:
|
||||
The persisted ``Actor`` domain object.
|
||||
|
||||
Raises:
|
||||
ValidationError: When the YAML is invalid, the actor already
|
||||
exists and *update* is ``False``, or the actor is unsafe
|
||||
and *allow_unsafe* is ``False``.
|
||||
"""
|
||||
# Ensure name is namespaced before schema validation.
|
||||
name_raw = blob.get("name", "")
|
||||
if isinstance(name_raw, str) and name_raw and "/" not in name_raw:
|
||||
blob["name"] = f"local/{name_raw}"
|
||||
|
||||
# Infer provider before schema validation — the schema now requires
|
||||
# provider for LLM and GRAPH actors (added by #5869).
|
||||
if not blob.get("provider"):
|
||||
model_raw = blob.get("model") or ""
|
||||
blob["provider"] = (
|
||||
_infer_provider_from_model(model_raw) if model_raw else "custom"
|
||||
)
|
||||
|
||||
try:
|
||||
schema = ActorConfigSchema.model_validate(blob)
|
||||
except PydanticValidationError as exc:
|
||||
field_errors: list[str] = []
|
||||
for err in exc.errors():
|
||||
path = ".".join(str(loc) for loc in err["loc"])
|
||||
field_errors.append(f" {path}: {err['msg']}")
|
||||
raise ValidationError(
|
||||
"v3 Actor YAML schema validation failed:\n" + "\n".join(field_errors)
|
||||
) from exc
|
||||
|
||||
name = schema.name
|
||||
# C2: model is optional for TOOL actors — let validate_type_requirements
|
||||
# handle type-specific model requirements instead of rejecting here.
|
||||
# The Actor domain model requires min_length=1 for the model field,
|
||||
# so use "tool" as a sentinel for tool actors without a model.
|
||||
model = schema.model or ("tool" if schema.type.value == "tool" else "")
|
||||
|
||||
# M11: enforce unsafe flag.
|
||||
unsafe_flag = bool(blob.get("unsafe", False))
|
||||
if unsafe_flag and not allow_unsafe:
|
||||
raise ValidationError(
|
||||
"Actor configuration is marked unsafe; re-run with --unsafe to confirm."
|
||||
)
|
||||
|
||||
# Infer provider: use explicit blob.provider if present,
|
||||
# otherwise derive from model string or fall back to "custom".
|
||||
provider_raw = blob.get("provider") or blob.get("provider_type")
|
||||
if provider_raw:
|
||||
provider = str(provider_raw)
|
||||
elif model:
|
||||
provider = _infer_provider_from_model(model)
|
||||
else:
|
||||
provider = "custom"
|
||||
|
||||
# Check for existing actor when not updating.
|
||||
# M3: catch only NotFoundError, not generic Exception.
|
||||
if not update:
|
||||
try:
|
||||
actor_service.get_actor(name)
|
||||
raise ValidationError(
|
||||
f"Actor '{name}' already exists. Pass update=True to overwrite."
|
||||
)
|
||||
except NotFoundError:
|
||||
pass # Expected for new actors
|
||||
|
||||
# m9: deep copy blob to avoid shared nested mutable references.
|
||||
config_blob: dict[str, Any] = copy.deepcopy(blob)
|
||||
config_blob.setdefault("source", "yaml")
|
||||
config_blob["provider"] = provider
|
||||
config_blob["model"] = model
|
||||
|
||||
# Compile graph actors and capture metadata.
|
||||
# M10: skip compilation when compiled_metadata is already provided.
|
||||
graph_descriptor = blob.get("graph_descriptor")
|
||||
if (
|
||||
schema.type.value == "graph"
|
||||
and schema.route is not None
|
||||
and compiled_metadata is None
|
||||
):
|
||||
# M4: narrow exception to ActorCompilationError.
|
||||
try:
|
||||
compiled = compile_actor(schema)
|
||||
compiled_metadata = compiled.metadata.model_dump(mode="json")
|
||||
graph_descriptor = {
|
||||
"type": "graph",
|
||||
"route": schema.route.model_dump(mode="json"),
|
||||
"model": model,
|
||||
"entry_point": compiled.entry_point,
|
||||
}
|
||||
except ActorCompilationError as compile_exc:
|
||||
logger.warning(
|
||||
"v3 graph compilation failed: %s",
|
||||
compile_exc,
|
||||
)
|
||||
# Still persist the actor; compilation metadata is optional.
|
||||
|
||||
return actor_service.upsert_actor(
|
||||
name=name,
|
||||
provider=provider,
|
||||
model=model,
|
||||
config_blob=config_blob,
|
||||
graph_descriptor=graph_descriptor,
|
||||
unsafe=unsafe_flag,
|
||||
set_default=False,
|
||||
is_built_in=False,
|
||||
yaml_text=yaml_text,
|
||||
schema_version=schema_version,
|
||||
compiled_metadata=compiled_metadata,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import Any
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from cleveragents.actor.config import _V3_ACTOR_TYPES
|
||||
from cleveragents.core.exceptions import ConfigurationError
|
||||
from cleveragents.reactive.route import BridgeConfig, RouteConfig, RouteType
|
||||
from cleveragents.reactive.stream_router import StreamType
|
||||
@@ -59,6 +60,18 @@ class ReactiveConfig(BaseModel):
|
||||
prompts: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def _infer_provider_from_model(model: str) -> str:
|
||||
"""Infer a provider identifier from a model string.
|
||||
|
||||
If the model contains ``/`` the prefix is used as provider
|
||||
(e.g. ``openai/gpt-4`` → ``openai``). Otherwise ``"custom"``
|
||||
is returned as a fallback.
|
||||
"""
|
||||
if "/" in model:
|
||||
return model.split("/", 1)[0]
|
||||
return "custom"
|
||||
|
||||
|
||||
class ReactiveConfigParser:
|
||||
def __init__(self) -> None:
|
||||
self.env_pattern = re.compile(r"\${([A-Za-z0-9_]+)(?::([^}]*))?}")
|
||||
@@ -101,8 +114,19 @@ class ReactiveConfigParser:
|
||||
value = self.env_pattern.sub(repl, value)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _is_v3_format(data: dict[str, Any]) -> bool:
|
||||
"""Return ``True`` when *data* matches the v3 ``ActorConfigSchema``."""
|
||||
actor_type = data.get("type")
|
||||
return isinstance(actor_type, str) and actor_type.lower() in _V3_ACTOR_TYPES
|
||||
|
||||
def _build(self, data: dict[str, Any]) -> ReactiveConfig:
|
||||
rc = ReactiveConfig()
|
||||
|
||||
# v3 Actor YAML: synthesise a reactive agent from the top-level config.
|
||||
if self._is_v3_format(data):
|
||||
return self._build_from_v3(data, rc)
|
||||
|
||||
agents_data = data.get("agents") or data.get("actors") or {}
|
||||
for name, agent_data in (agents_data or {}).items():
|
||||
rc.agents[name] = AgentConfig(
|
||||
@@ -175,3 +199,191 @@ class ReactiveConfigParser:
|
||||
rc.template_engine = data.get("template_engine", rc.template_engine)
|
||||
rc.prompts = data.get("prompts", {}) or {}
|
||||
return rc
|
||||
|
||||
@staticmethod
|
||||
def _build_from_v3(
|
||||
data: dict[str, Any],
|
||||
rc: ReactiveConfig,
|
||||
) -> ReactiveConfig:
|
||||
"""Synthesise a ``ReactiveConfig`` from a v3 ``ActorConfigSchema`` blob.
|
||||
|
||||
For ``type: llm`` actors a single reactive agent is created with the
|
||||
model, system prompt, and tool references from the v3 config. For
|
||||
``type: graph`` actors the route nodes are mapped to reactive agents
|
||||
and the edges are mapped to graph routes. Skills and LSP bindings
|
||||
are propagated via the agent config so the runtime can attach them.
|
||||
|
||||
All v3 fields — ``context_view``, ``memory``, ``context``,
|
||||
``env_vars``, ``response_format``, ``lsp_capabilities``, and
|
||||
``lsp_context_enrichment`` — are propagated into the agent config
|
||||
so the runtime can consume them.
|
||||
"""
|
||||
# m4: guard against None-to-string coercion for all three fields.
|
||||
actor_type = str(data.get("type") or "llm").lower()
|
||||
actor_name = str(data.get("name") or "v3_actor")
|
||||
model = str(data.get("model") or "")
|
||||
|
||||
# m8: validate that model is non-empty for types that require it.
|
||||
if not model and actor_type in ("llm", "graph"):
|
||||
raise ConfigurationError(
|
||||
f"v3 actor '{actor_name}' of type '{actor_type}' requires a "
|
||||
f"non-empty 'model' field."
|
||||
)
|
||||
|
||||
# Infer provider from model string (m11: shared utility).
|
||||
provider = _infer_provider_from_model(model)
|
||||
|
||||
# Collect v3 metadata for runtime attachment.
|
||||
# m12: validate element types for skills list.
|
||||
raw_skills = data.get("skills", []) or []
|
||||
skills: list[str] = (
|
||||
[str(s) for s in raw_skills] if isinstance(raw_skills, list) else []
|
||||
)
|
||||
lsp_raw = data.get("lsp")
|
||||
lsp_bindings: list[str] | dict[str, Any] | None = None
|
||||
if isinstance(lsp_raw, list):
|
||||
lsp_bindings = [str(s) for s in lsp_raw]
|
||||
elif isinstance(lsp_raw, dict):
|
||||
lsp_bindings = dict(lsp_raw)
|
||||
|
||||
agent_config: dict[str, Any] = {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"system_prompt": data.get("system_prompt", ""),
|
||||
}
|
||||
if skills:
|
||||
agent_config["skills"] = skills
|
||||
if lsp_bindings is not None:
|
||||
agent_config["lsp"] = lsp_bindings
|
||||
|
||||
# Attach tool references for tool-bearing actors.
|
||||
tools_raw = data.get("tools", []) or []
|
||||
if tools_raw:
|
||||
agent_config["tools"] = tools_raw
|
||||
|
||||
# M6: propagate context_view.
|
||||
context_view = data.get("context_view")
|
||||
if context_view is not None:
|
||||
agent_config["context_view"] = context_view
|
||||
|
||||
# M7: propagate memory and context configuration objects.
|
||||
memory_raw = data.get("memory")
|
||||
if isinstance(memory_raw, dict):
|
||||
agent_config["memory"] = memory_raw
|
||||
|
||||
context_raw = data.get("context")
|
||||
if isinstance(context_raw, dict):
|
||||
agent_config["context"] = context_raw
|
||||
|
||||
# M8: propagate lsp_capabilities and lsp_context_enrichment.
|
||||
lsp_caps = data.get("lsp_capabilities")
|
||||
if lsp_caps is not None:
|
||||
agent_config["lsp_capabilities"] = lsp_caps
|
||||
|
||||
lsp_enrichment = data.get("lsp_context_enrichment")
|
||||
if isinstance(lsp_enrichment, dict):
|
||||
agent_config["lsp_context_enrichment"] = lsp_enrichment
|
||||
|
||||
# m2: propagate env_vars.
|
||||
env_vars = data.get("env_vars")
|
||||
if isinstance(env_vars, dict):
|
||||
agent_config["env_vars"] = env_vars
|
||||
|
||||
# m3: propagate response_format.
|
||||
response_format = data.get("response_format")
|
||||
if isinstance(response_format, dict):
|
||||
agent_config["response_format"] = response_format
|
||||
|
||||
if actor_type in ("llm", "tool"):
|
||||
# Single-agent synthesis.
|
||||
rc.agents[actor_name] = AgentConfig(
|
||||
name=actor_name,
|
||||
type=actor_type,
|
||||
config=agent_config,
|
||||
)
|
||||
elif actor_type == "graph":
|
||||
# Map route nodes → agents and edges → graph routes.
|
||||
route_raw = data.get("route")
|
||||
if not isinstance(route_raw, dict):
|
||||
# m7: graph actor without route is a configuration error.
|
||||
raise ConfigurationError(
|
||||
f"v3 graph actor '{actor_name}' requires a 'route' "
|
||||
f"mapping but none was provided."
|
||||
)
|
||||
|
||||
nodes_list = route_raw.get("nodes", [])
|
||||
edges_list = route_raw.get("edges", [])
|
||||
entry_node = route_raw.get("entry_node", "start")
|
||||
|
||||
nodes_map: dict[str, dict[str, Any]] = {}
|
||||
for node in nodes_list:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_id = str(node.get("id", ""))
|
||||
if not node_id:
|
||||
continue
|
||||
# C3: guard against config: null producing TypeError.
|
||||
raw_config = node.get("config")
|
||||
node_config = dict(raw_config) if isinstance(raw_config, dict) else {}
|
||||
node_config.setdefault("model", model)
|
||||
node_config.setdefault("provider", provider)
|
||||
# M9: copy skills list to avoid shared mutable reference.
|
||||
if skills:
|
||||
node_config.setdefault("skills", list(skills))
|
||||
# M5: propagate LSP bindings to graph nodes.
|
||||
if lsp_bindings is not None:
|
||||
node_config.setdefault("lsp", lsp_bindings)
|
||||
nodes_map[node_id] = node_config
|
||||
|
||||
# Create a reactive agent for each graph node.
|
||||
rc.agents[node_id] = AgentConfig(
|
||||
name=node_id,
|
||||
type=str(node.get("type", "agent")),
|
||||
config=node_config,
|
||||
)
|
||||
|
||||
# m5: validate entry_node against nodes_map.
|
||||
if entry_node not in nodes_map:
|
||||
raise ConfigurationError(
|
||||
f"v3 graph actor '{actor_name}': entry_node "
|
||||
f"'{entry_node}' not found in route nodes "
|
||||
f"{sorted(nodes_map.keys())}."
|
||||
)
|
||||
|
||||
# C1: use "source"/"target" keys matching RouteConfig.to_graph_config().
|
||||
edge_entries: list[dict[str, Any]] = []
|
||||
for edge in edges_list:
|
||||
if not isinstance(edge, dict):
|
||||
continue
|
||||
from_node = edge.get("from_node", "")
|
||||
to_node = edge.get("to_node", "")
|
||||
# m6: skip edges with empty source or target.
|
||||
if not from_node or not to_node:
|
||||
continue
|
||||
edge_entries.append(
|
||||
{
|
||||
"source": from_node,
|
||||
"target": to_node,
|
||||
"condition": edge.get("condition"),
|
||||
}
|
||||
)
|
||||
|
||||
# m1: propagate exit_nodes into route metadata.
|
||||
exit_nodes = route_raw.get("exit_nodes", [])
|
||||
route_metadata: dict[str, Any] = {
|
||||
"v3_actor": actor_name,
|
||||
"skills": skills,
|
||||
}
|
||||
if exit_nodes:
|
||||
route_metadata["exit_nodes"] = exit_nodes
|
||||
|
||||
rc.routes[f"{actor_name}_graph"] = RouteConfig(
|
||||
name=f"{actor_name}_graph",
|
||||
type=RouteType.GRAPH,
|
||||
nodes=nodes_map,
|
||||
edges=edge_entries,
|
||||
entry_point=entry_node,
|
||||
metadata=route_metadata,
|
||||
)
|
||||
|
||||
return rc
|
||||
|
||||
Reference in New Issue
Block a user