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()`
|
leaks from dangling worktrees. Delegates to `GitWorktreeSandbox.cleanup_stale()`
|
||||||
in the infrastructure layer.
|
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
|
- **`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
|
a conflict during `plan apply`, the apply command now reads the actual conflict
|
||||||
detail from `CalledProcessError.stdout` (git writes conflict info to stdout, not
|
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
|
location as the primary anchor point. This fix enables `agents init` to work
|
||||||
correctly in all deployment modes: Docker containers, local pip installs
|
correctly in all deployment modes: Docker containers, local pip installs
|
||||||
(wheel or editable), and development environments.
|
(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
|
- **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in
|
||||||
- **bug-hunt-pool-supervisor Non-Blocking Tracking** (#8835): The automation-tracking-manager
|
- **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
|
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:
|
def get_actor(self, name: str) -> Actor:
|
||||||
if name not in self.actors:
|
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]
|
return self.actors[name]
|
||||||
|
|
||||||
def list_actors(self) -> list[Actor]:
|
def list_actors(self) -> list[Actor]:
|
||||||
@@ -82,7 +86,11 @@ class _StubActorService:
|
|||||||
|
|
||||||
def remove_actor(self, name: str) -> None:
|
def remove_actor(self, name: str) -> None:
|
||||||
if name not in self.actors:
|
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]
|
del self.actors[name]
|
||||||
if self.default_actor_name == name:
|
if self.default_actor_name == name:
|
||||||
self.default_actor_name = None
|
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")
|
@given("drcov3 multiple checkpoints exist for the plan")
|
||||||
def step_insert_multi_ckpts(context: Context) -> None:
|
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 = []
|
context.drcov3_ckpt_ids = []
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
cid = _next_ulid()
|
cid = _next_ulid()
|
||||||
@@ -1274,7 +1277,7 @@ def step_insert_multi_ckpts(context: Context) -> None:
|
|||||||
ckpt = _make_checkpoint(
|
ckpt = _make_checkpoint(
|
||||||
plan_id=context.drcov3_ckpt_plan_id,
|
plan_id=context.drcov3_ckpt_plan_id,
|
||||||
checkpoint_id=cid,
|
checkpoint_id=cid,
|
||||||
created_at=datetime.now(UTC) + timedelta(seconds=i),
|
created_at=base_time + timedelta(seconds=i),
|
||||||
)
|
)
|
||||||
context.drcov3_ckpt_repo.create(ckpt)
|
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")
|
@given("drcov3 five checkpoints exist for prune test")
|
||||||
def step_create_five_ckpts(context: Context) -> None:
|
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 = []
|
context.drcov3_prune_ckpt_ids = []
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
cid = _next_ulid()
|
cid = _next_ulid()
|
||||||
context.drcov3_prune_ckpt_ids.append(cid)
|
context.drcov3_prune_ckpt_ids.append(cid)
|
||||||
ckpt = _make_checkpoint(
|
ckpt = _make_checkpoint(
|
||||||
plan_id=context.drcov3_ckpt_plan_id,
|
plan_id=context.drcov3_prune_plan_id,
|
||||||
checkpoint_id=cid,
|
checkpoint_id=cid,
|
||||||
created_at=datetime.now(UTC) + timedelta(seconds=i),
|
created_at=base_time + timedelta(seconds=i),
|
||||||
)
|
)
|
||||||
context.drcov3_ckpt_repo.create(ckpt)
|
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:
|
def step_prune_ckpts(context: Context) -> None:
|
||||||
try:
|
try:
|
||||||
context.drcov3_result = context.drcov3_ckpt_repo.prune(
|
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:
|
except Exception as exc:
|
||||||
context.drcov3_error = exc
|
context.drcov3_error = exc
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ except Exception: # pragma: no cover - defensive optional import
|
|||||||
yaml = None
|
yaml = None
|
||||||
|
|
||||||
|
|
||||||
|
#: v3 actor types that signal the ``ActorConfigSchema`` format.
|
||||||
|
_V3_ACTOR_TYPES: frozenset[str] = frozenset({"llm", "graph", "tool"})
|
||||||
|
|
||||||
|
|
||||||
class ActorConfiguration(BaseModel):
|
class ActorConfiguration(BaseModel):
|
||||||
"""Canonical actor configuration parsed from user-provided blobs."""
|
"""Canonical actor configuration parsed from user-provided blobs."""
|
||||||
|
|
||||||
@@ -231,21 +235,37 @@ class ActorConfiguration(BaseModel):
|
|||||||
default_options: dict[str, Any] | None = None,
|
default_options: dict[str, Any] | None = None,
|
||||||
option_overrides: dict[str, Any] | None = None,
|
option_overrides: dict[str, Any] | None = None,
|
||||||
) -> ActorConfiguration:
|
) -> 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 {}
|
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_provider, v2_model, v2_graph, v2_unsafe = cls._extract_v2_actor(data)
|
||||||
v2_options = cls._extract_v2_options(data)
|
v2_options = cls._extract_v2_options(data)
|
||||||
|
|
||||||
resolved_provider = (
|
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 = (
|
resolved_graph = (
|
||||||
graph_descriptor
|
graph_descriptor
|
||||||
or data.get("graph_descriptor")
|
or data.get("graph_descriptor")
|
||||||
or data.get("graph")
|
or data.get("graph")
|
||||||
|
or v3_graph
|
||||||
or v2_graph
|
or v2_graph
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -263,7 +283,12 @@ class ActorConfiguration(BaseModel):
|
|||||||
if option_overrides:
|
if option_overrides:
|
||||||
merged_options.update(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:
|
if not resolved_provider:
|
||||||
raise ValueError("provider is required")
|
raise ValueError("provider is required")
|
||||||
@@ -286,31 +311,145 @@ class ActorConfiguration(BaseModel):
|
|||||||
unsafe=resolved_unsafe,
|
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
|
@staticmethod
|
||||||
def _extract_v2_actor(
|
def _extract_v2_actor(
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
) -> tuple[str | None, str | None, dict[str, Any] | None, bool]:
|
) -> 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")
|
Supports both the legacy ``agents:`` map key and the spec-compliant
|
||||||
if isinstance(agents_obj, dict) and agents_obj:
|
``actors:`` map key. Within each actor's ``config:`` block, the
|
||||||
agents_dict = cast(dict[str, Any], agents_obj)
|
combined ``actor`` field (e.g. ``"openai/gpt-4"``) is also accepted
|
||||||
for first_agent, first_entry in agents_dict.items():
|
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):
|
if isinstance(first_entry, dict):
|
||||||
entry_dict = cast(dict[str, Any], first_entry)
|
entry_dict = cast(dict[str, Any], first_entry)
|
||||||
config_block = entry_dict.get("config")
|
config_block = entry_dict.get("config")
|
||||||
if isinstance(config_block, dict):
|
if isinstance(config_block, dict):
|
||||||
config_dict = cast(dict[str, Any], config_block)
|
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_value = config_dict.get("provider") or config_dict.get(
|
||||||
"provider_type"
|
"provider_type"
|
||||||
)
|
)
|
||||||
model_value = config_dict.get("model") or config_dict.get(
|
model_value = config_dict.get("model") or config_dict.get(
|
||||||
"model_id"
|
"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] = {
|
descriptor: dict[str, Any] = {
|
||||||
"agent": first_agent,
|
"agent": actor_key,
|
||||||
"agents": agents_dict,
|
map_key: map_dict,
|
||||||
}
|
}
|
||||||
for key in (
|
for key in (
|
||||||
"routes",
|
"routes",
|
||||||
@@ -326,17 +465,22 @@ class ActorConfiguration(BaseModel):
|
|||||||
descriptor,
|
descriptor,
|
||||||
unsafe_flag,
|
unsafe_flag,
|
||||||
)
|
)
|
||||||
break
|
break # first entry only
|
||||||
return None, None, None, False
|
return None, None, None, False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_v2_options(data: dict[str, Any]) -> dict[str, Any] | None:
|
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")
|
Supports both ``actors:`` (spec-canonical) and ``agents:`` (legacy)
|
||||||
if isinstance(agents_obj, dict) and agents_obj:
|
map keys.
|
||||||
agents_dict = cast(dict[str, Any], agents_obj)
|
"""
|
||||||
for _, first_entry in agents_dict.items():
|
|
||||||
|
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):
|
if isinstance(first_entry, dict):
|
||||||
entry_dict = cast(dict[str, Any], first_entry)
|
entry_dict = cast(dict[str, Any], first_entry)
|
||||||
config_block = entry_dict.get("config")
|
config_block = entry_dict.get("config")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import pydantic
|
|||||||
|
|
||||||
from cleveragents.actor.config import ActorConfiguration
|
from cleveragents.actor.config import ActorConfiguration
|
||||||
from cleveragents.actor.schema import ActorConfigSchema, is_v3_yaml
|
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.application.services.actor_service import ActorService
|
||||||
from cleveragents.config.settings import Settings
|
from cleveragents.config.settings import Settings
|
||||||
from cleveragents.core.exceptions import NotFoundError, ValidationError
|
from cleveragents.core.exceptions import NotFoundError, ValidationError
|
||||||
@@ -19,6 +21,8 @@ from cleveragents.providers.registry import (
|
|||||||
ProviderRegistry,
|
ProviderRegistry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ActorRegistry:
|
class ActorRegistry:
|
||||||
"""Coordinate actor configuration parsing and built-in generation.
|
"""Coordinate actor configuration parsing and built-in generation.
|
||||||
@@ -184,6 +188,8 @@ class ActorRegistry:
|
|||||||
yaml_text: str,
|
yaml_text: str,
|
||||||
*,
|
*,
|
||||||
update: bool = False,
|
update: bool = False,
|
||||||
|
unsafe: bool = False,
|
||||||
|
allow_unsafe: bool = False,
|
||||||
schema_version: str | None = None,
|
schema_version: str | None = None,
|
||||||
compiled_metadata: dict[str, Any] | None = None,
|
compiled_metadata: dict[str, Any] | None = None,
|
||||||
) -> Actor:
|
) -> Actor:
|
||||||
@@ -192,28 +198,108 @@ class ActorRegistry:
|
|||||||
The YAML is parsed into an ``ActorConfiguration``, validated, and
|
The YAML is parsed into an ``ActorConfiguration``, validated, and
|
||||||
persisted alongside the original *yaml_text* and *schema_version*.
|
persisted alongside the original *yaml_text* and *schema_version*.
|
||||||
|
|
||||||
For v3 YAML files (detected by any non-null ``type`` field or a
|
When the YAML matches the v3 ``ActorConfigSchema`` (detected by a
|
||||||
``version`` starting with ``'3'``), the configuration is validated
|
top-level ``type`` key of ``llm``, ``graph``, or ``tool``) the full
|
||||||
using ``ActorConfigSchema`` to ensure proper schema compliance,
|
schema is validated, ``description`` is enforced, and ``skills`` /
|
||||||
including cycle detection for GRAPH actors.
|
``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:
|
Args:
|
||||||
yaml_text: The original actor YAML source.
|
yaml_text: The original actor YAML source.
|
||||||
update: When ``True`` allow overwriting an existing actor.
|
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
|
schema_version: Explicit schema version; defaults to
|
||||||
``DEFAULT_SCHEMA_VERSION``.
|
``DEFAULT_SCHEMA_VERSION``.
|
||||||
compiled_metadata: Optional compiler-produced metadata dict.
|
compiled_metadata: Optional compiler-produced metadata dict.
|
||||||
|
allow_unsafe: When ``True`` permit actors marked ``unsafe``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The persisted ``Actor`` domain object.
|
The persisted ``Actor`` domain object.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: When the YAML is invalid or the actor already
|
ValidationError: When the YAML is invalid, the actor already
|
||||||
exists and *update* is ``False``.
|
exists and *update* is ``False``, or the actor is unsafe
|
||||||
|
and neither *unsafe* nor *allow_unsafe* is set.
|
||||||
"""
|
"""
|
||||||
self.ensure_built_in_actors()
|
self.ensure_built_in_actors()
|
||||||
|
|
||||||
blob = ActorConfiguration.load_yaml_text(yaml_text)
|
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", "")
|
name_raw: str = blob.get("name", "")
|
||||||
if not name_raw:
|
if not name_raw:
|
||||||
raise ValidationError("Actor YAML must include a 'name' field.")
|
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", "")
|
provider_raw = blob.get("provider") or blob.get("provider_type", "")
|
||||||
model_raw = blob.get("model") or blob.get("model_id", "")
|
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):
|
if not blob_is_v3 and (not provider_raw or not model_raw):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Actor YAML must include 'provider' and 'model' fields."
|
"Actor YAML must include 'provider' and 'model' fields."
|
||||||
@@ -244,7 +342,20 @@ class ActorRegistry:
|
|||||||
provider = str(provider_raw) if provider_raw else ""
|
provider = str(provider_raw) if provider_raw else ""
|
||||||
model = str(model_raw) if model_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:
|
if not update:
|
||||||
try:
|
try:
|
||||||
self._actor_service.get_actor(name)
|
self._actor_service.get_actor(name)
|
||||||
@@ -255,21 +366,24 @@ class ActorRegistry:
|
|||||||
f"Actor '{name}' already exists. Pass update=True to overwrite."
|
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: dict[str, Any] = dict(blob)
|
||||||
config_blob.setdefault("source", "yaml")
|
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(
|
return self._actor_service.upsert_actor(
|
||||||
name=name,
|
name=name,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
model=model,
|
model=model,
|
||||||
config_blob=config_blob,
|
config_blob=config_blob,
|
||||||
graph_descriptor=blob.get("graph_descriptor"),
|
graph_descriptor=resolved_graph,
|
||||||
unsafe=bool(blob.get("unsafe", False)),
|
unsafe=effective_unsafe,
|
||||||
set_default=False,
|
set_default=False,
|
||||||
is_built_in=False,
|
is_built_in=False,
|
||||||
yaml_text=yaml_text,
|
yaml_text=yaml_text,
|
||||||
schema_version=version,
|
schema_version=schema_version,
|
||||||
compiled_metadata=compiled_metadata,
|
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
|
# Use the YAML-first path via registry.upsert_actor() with
|
||||||
# yaml_text threaded through. This preserves the original YAML
|
# yaml_text threaded through. This preserves the original YAML
|
||||||
# text, schema_version, and compiled_metadata in the database
|
# text, schema_version, and compiled_metadata in the database
|
||||||
# while also honouring all CLI flags (--set-default, --option,
|
# while also honouring all CLI flags (--set-default, --option)
|
||||||
# --unsafe) that registry.add() does not accept.
|
# that registry.add() does not support. Note: registry.add()
|
||||||
|
# does accept ``unsafe`` and ``allow_unsafe`` parameters.
|
||||||
actor = registry.upsert_actor(
|
actor = registry.upsert_actor(
|
||||||
name=name,
|
name=name,
|
||||||
config_blob=canonical_blob,
|
config_blob=canonical_blob,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from typing import Any
|
|||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from cleveragents.actor.config import _V3_ACTOR_TYPES
|
||||||
from cleveragents.core.exceptions import ConfigurationError
|
from cleveragents.core.exceptions import ConfigurationError
|
||||||
from cleveragents.reactive.route import BridgeConfig, RouteConfig, RouteType
|
from cleveragents.reactive.route import BridgeConfig, RouteConfig, RouteType
|
||||||
from cleveragents.reactive.stream_router import StreamType
|
from cleveragents.reactive.stream_router import StreamType
|
||||||
@@ -59,6 +60,18 @@ class ReactiveConfig(BaseModel):
|
|||||||
prompts: dict[str, Any] = Field(default_factory=dict)
|
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:
|
class ReactiveConfigParser:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.env_pattern = re.compile(r"\${([A-Za-z0-9_]+)(?::([^}]*))?}")
|
self.env_pattern = re.compile(r"\${([A-Za-z0-9_]+)(?::([^}]*))?}")
|
||||||
@@ -101,8 +114,19 @@ class ReactiveConfigParser:
|
|||||||
value = self.env_pattern.sub(repl, value)
|
value = self.env_pattern.sub(repl, value)
|
||||||
return 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:
|
def _build(self, data: dict[str, Any]) -> ReactiveConfig:
|
||||||
rc = 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 {}
|
agents_data = data.get("agents") or data.get("actors") or {}
|
||||||
for name, agent_data in (agents_data or {}).items():
|
for name, agent_data in (agents_data or {}).items():
|
||||||
rc.agents[name] = AgentConfig(
|
rc.agents[name] = AgentConfig(
|
||||||
@@ -175,3 +199,191 @@ class ReactiveConfigParser:
|
|||||||
rc.template_engine = data.get("template_engine", rc.template_engine)
|
rc.template_engine = data.get("template_engine", rc.template_engine)
|
||||||
rc.prompts = data.get("prompts", {}) or {}
|
rc.prompts = data.get("prompts", {}) or {}
|
||||||
return rc
|
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