Compare commits

...

6 Commits

Author SHA1 Message Date
hurui200320 6f3487d7f3 fix: add missing unsafe param to _add_legacy() (merge conflict artifact) 2026-04-21 06:30:13 +00:00
hurui200320 25a37f1820 Merge PR #10795: fix(langgraph): wire node stream on_next handlers to registered executors (closes #6511) 2026-04-21 06:26:05 +00:00
hurui200320 ce7f360d33 Merge PR #10796: fix(actor): resolve registry.add() rejection of spec-compliant actor YAML (closes #4466)
Conflicts in actor/config.py and actor/registry.py resolved by combining:
- PR #9921's v3_unsafe propagation
- PR #10796's precise unsafe coercion (bool True or int 1 only) and resolved_graph
2026-04-21 06:25:13 +00:00
hurui200320 df44055fff Merge PR #9921: fix(actor): support v3 Actor YAML schema in CLI registration and execution (closes #6283) 2026-04-21 06:24:06 +00:00
hurui200320 a59f2d68cd fix(actor): resolve registry.add() rejection of spec-compliant actor YAML
CI / helm (pull_request) Successful in 44s
CI / lint (pull_request) Successful in 3m48s
CI / typecheck (pull_request) Successful in 4m30s
CI / push-validation (pull_request) Successful in 24s
CI / build (pull_request) Successful in 3m35s
CI / quality (pull_request) Successful in 4m12s
CI / security (pull_request) Successful in 4m36s
CI / e2e_tests (pull_request) Successful in 6m57s
CI / integration_tests (pull_request) Successful in 7m41s
CI / unit_tests (pull_request) Successful in 8m53s
CI / docker (pull_request) Successful in 1m37s
CI / coverage (pull_request) Successful in 15m9s
CI / status-check (pull_request) Successful in 3s
The ActorRegistry.add() method rejected spec-compliant YAML that uses the
actors: map format with nested config: blocks because it only looked for
provider/model at the top level of the blob.  Four changes fix this:

1. _extract_v2_actor() now handles both the spec-canonical actors: key and
   the legacy agents: key, with actors: taking precedence.  It also supports
   the combined actor field format (e.g. "openai/gpt-4") from the spec.

2. _extract_v2_options() mirrors the same actors:/agents: support.

3. registry.add() now unconditionally calls _extract_v2_actor() so that
   nested unsafe flags and graph descriptors are always captured — even
   when top-level provider/model are present.  This eliminates the
   behavioural asymmetry with from_blob().

4. The unsafe confirmation gate now runs before the duplicate-actor check,
   and the graph_descriptor resolution uses explicit is-not-None checks
   to distinguish "not set" from "set to empty dict".

Review fixes (cycle 4):
- Added 9 new Behave scenarios: _extract_v2_options edge cases (empty map,
  None, list, missing options key), _extract_v2_actor with unsafe=True,
  add() with missing name field, top-level unsafe: true (rejection +
  acceptance), and multi-actor unsafe limitation documentation.
- Added graph descriptor assertions to all _extract_v2_actor direct
  scenarios that were missing them.
- Fixed unsafe field coercion to use explicit boolean check (is True or
  == 1) instead of bool() to prevent truthy non-boolean values like
  "no" from being treated as unsafe.
- Added legacy graph key fallback (blob.get("graph")) in add() for
  consistency with from_blob().
- Fixed _StubActorService.upsert_actor to handle set_default parameter
  and pass non-None config_blob to Actor.compute_hash().
- Updated stale CLI comment about registry.add() capabilities.
- Applied ruff format to step definitions.

Includes 45 Behave scenarios covering spec-compliant actors: map,
legacy agents: map, top-level fields, rejection of missing provider/model,
combined actor field edge cases, update=True path, schema_version and
compiled_metadata forwarding, actors-as-list edge case, empty actors dict
blocking agents fallback, malformed actor field parts, reverse precedence
for the combined actor field, _extract_v2_options edge cases, unsafe=True
detection, missing name rejection, top-level unsafe, and multi-actor
unsafe limitation.

ISSUES CLOSED: #4466
2026-04-21 05:24:53 +00:00
hurui200320 7a6d47c795 fix(actor): support v3 Actor YAML schema in CLI registration and execution
CI / push-validation (pull_request) Successful in 22s
CI / helm (pull_request) Successful in 27s
CI / lint (pull_request) Successful in 4m12s
CI / build (pull_request) Successful in 3m57s
CI / quality (pull_request) Successful in 4m41s
CI / typecheck (pull_request) Successful in 4m51s
CI / security (pull_request) Successful in 4m58s
CI / e2e_tests (pull_request) Successful in 7m7s
CI / integration_tests (pull_request) Successful in 10m58s
CI / unit_tests (pull_request) Successful in 13m17s
CI / docker (pull_request) Successful in 1m53s
CI / coverage (pull_request) Successful in 15m12s
CI / status-check (pull_request) Successful in 3s
The actor CLI was ignoring the v3 ActorConfigSchema format, preventing
spec-compliant actors with type/route/skills/lsp fields from being
registered or executed.  Three components were fixed:

ActorConfiguration.from_blob() now detects v3 format (top-level "type"
key with value llm/graph/tool) and extracts provider from the model
string, falling through to v2 extraction when v3 does not match.

ActorRegistry.add() now routes v3 YAML through full ActorConfigSchema
validation, persists description/skills/lsp in the config blob, and
compiles graph actors with compile_actor() storing metadata.  Legacy v2
YAML continues through the original path unchanged.

ReactiveConfigParser._build() now synthesises reactive agents and graph
routes from v3 actor data so that agents actor run can execute v3 actors
through the existing ReactiveCleverAgentsApp pipeline.

ISSUES CLOSED: #6283
2026-04-21 05:14:06 +00:00
13 changed files with 2947 additions and 37 deletions
+25
View File
@@ -12,6 +12,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
leaks from dangling worktrees. Delegates to `GitWorktreeSandbox.cleanup_stale()`
in the infrastructure layer.
- **ActorRegistry.add() spec-compliant YAML support** (#4466): The registry now
accepts actor YAML using the spec's `actors:` map format with nested `config:`
blocks, in addition to the legacy top-level `provider`/`model` format. The
`unsafe` flag and graph descriptor from nested config are now correctly
preserved during registration.
- **`plan apply` merge conflict cleanup** (#7250): When `git merge` fails due to
a conflict during `plan apply`, the apply command now reads the actual conflict
detail from `CalledProcessError.stdout` (git writes conflict info to stdout, not
@@ -42,6 +48,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
location as the primary anchor point. This fix enables `agents init` to work
correctly in all deployment modes: Docker containers, local pip installs
(wheel or editable), and development environments.
- **Actor CLI v3 YAML Schema Support** (#6283): Fixed three components to add
full v3 `ActorConfigSchema` support to the actor CLI registration and
execution paths. `ActorConfiguration.from_blob()` now detects v3 format
(top-level `type` key of `llm`/`graph`/`tool`) and correctly extracts
provider, model, and graph descriptors — including `type: tool` actors
without a `model` field. `ActorRegistry.add()` validates against the full
Pydantic v2 schema, persists `skills`/`lsp`/`description` in the config
blob, and compiles graph actors with proper metadata.
`ReactiveConfigParser._build_from_v3()` now uses correct `source`/`target`
edge keys (fixing `KeyError` in `to_graph_config()`), handles `config: null`
nodes without crashing, propagates `context_view`/`memory`/`context`/
`env_vars`/`response_format`/`lsp_capabilities`/`lsp_context_enrichment`
into agent configs, and validates `entry_node` against the nodes map.
Exception handling narrowed from broad `except Exception` to specific
`NotFoundError` and `ActorCompilationError`. v3 registration logic
extracted to `v3_registry.py` to keep `registry.py` under the 500-line
limit. 16 BDD scenarios cover all v3 paths including tool actors,
update mode, LSP dict bindings, and field propagation.
- **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in
- **bug-hunt-pool-supervisor Non-Blocking Tracking** (#8835): The automation-tracking-manager
call in step 5 was blocking the main loop indefinitely, causing 3+ consecutive initialization
+369
View File
@@ -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"
+112
View File
@@ -0,0 +1,112 @@
Feature: v3 Actor YAML schema support in CLI and registry
As a developer using v3 actor YAML configs
I want the CLI and registry to validate, persist, and execute v3 schema actors
So that spec-compliant actors with skills, LSP bindings, and LangGraph routes work
Scenario: ActorConfiguration.from_blob extracts provider and model from v3 LLM YAML
Given a v3 LLM actor blob with model "gpt-4"
When I call ActorConfiguration.from_blob with the v3 blob
Then the configuration should have provider "custom"
And the configuration should have model "gpt-4"
Scenario: ActorConfiguration.from_blob infers provider from model with slash
Given a v3 LLM actor blob with model "openai/gpt-4"
When I call ActorConfiguration.from_blob with the v3 blob
Then the configuration should have provider "openai"
And the configuration should have model "openai/gpt-4"
Scenario: ActorConfiguration.from_blob builds graph descriptor for graph actors
Given a v3 graph actor blob with a route block
When I call ActorConfiguration.from_blob with the v3 blob
Then the configuration should have a graph_descriptor with type "graph"
Scenario: ActorConfiguration.from_blob falls through to v2 for legacy YAML
Given a v2 actor blob with agents and routes
When I call ActorConfiguration.from_blob with the v2 blob
Then the configuration should have provider "openai"
And the configuration should have model "gpt-4"
Scenario: ActorRegistry.add validates v3 LLM actor YAML
Given a mock actor service for v3 testing
And a v3 LLM actor YAML text
When I call ActorRegistry.add with the v3 YAML
Then the actor should be persisted with v3 fields in config_blob
And the persisted config_blob should contain skills
And the persisted config_blob should contain lsp bindings
Scenario: ActorRegistry.add rejects v3 YAML without required description
Given a mock actor service for v3 testing
And a v3 actor YAML text missing description
When I call ActorRegistry.add with the invalid v3 YAML
Then a ValidationError should be raised mentioning description
Scenario: ActorRegistry.add validates and compiles v3 graph actor
Given a mock actor service for v3 testing
And a v3 graph actor YAML text with route
When I call ActorRegistry.add with the v3 graph YAML
Then the actor should be persisted with compiled metadata
And the graph_descriptor should contain route information
Scenario: ReactiveConfigParser handles v3 LLM actor format
Given a v3 LLM actor config dict
When I parse the v3 config through ReactiveConfigParser
Then the reactive config should have one agent
And the agent config should contain the model
And the agent config should contain skills
Scenario: ReactiveConfigParser handles v3 graph actor format
Given a v3 graph actor config dict with route
When I parse the v3 config through ReactiveConfigParser
Then the reactive config should have agents for each node
And the reactive config should have a graph route
And the graph route edges should use source and target keys
Scenario: v3 format detection returns false for v2 data
Given a v2 actor config dict with agents key
When I check if the config is v3 format
Then it should not be detected as v3
# M14: test update=True path
Scenario: ActorRegistry.add with update=True overwrites existing actor
Given a mock actor service for v3 testing
And an existing actor in the mock service
And a v3 LLM actor YAML text
When I call ActorRegistry.add with update=True for the v3 YAML
Then the actor should be persisted successfully with update
# M15: test type:tool extraction and parsing paths
# from_blob requires model for ActorConfiguration — tool actors without
# model raise ValueError. The proper v3 path is ActorRegistry.add().
Scenario: ActorConfiguration.from_blob rejects type tool without model
Given a v3 tool actor blob without model
When I call ActorConfiguration.from_blob with the v3 tool blob
Then the tool actor from_blob should raise ValueError for missing model
Scenario: ActorRegistry.add validates v3 tool actor YAML
Given a mock actor service for v3 testing
And a v3 tool actor YAML text
When I call ActorRegistry.add with the v3 tool YAML
Then the tool actor should be persisted with type tool
# m13: test _build_from_v3 with lsp as dict
Scenario: ReactiveConfigParser handles v3 LLM actor with lsp as dict
Given a v3 LLM actor config dict with lsp as dict
When I parse the v3 config through ReactiveConfigParser
Then the reactive config should have one agent
And the agent config should contain lsp as dict
# m13: test context_view, memory, env_vars, response_format propagation
Scenario: ReactiveConfigParser propagates context_view memory env_vars and response_format
Given a v3 LLM actor config dict with context_view and memory
When I parse the v3 config through ReactiveConfigParser
Then the reactive config should have one agent
And the agent config should contain context_view
And the agent config should contain memory settings
And the agent config should contain env_vars
And the agent config should contain response_format
# m13: positive _is_v3_format test
Scenario: v3 format detection returns true for v3 LLM data
Given a v3 LLM actor config dict
When I check if the v3 LLM config is v3 format
Then it should be detected as v3
@@ -74,7 +74,11 @@ class _StubActorService:
def get_actor(self, name: str) -> Actor:
if name not in self.actors:
raise NotFoundError(f"Actor '{name}' not found")
raise NotFoundError(
f"Actor '{name}' not found",
resource_type="actor",
resource_id=name,
)
return self.actors[name]
def list_actors(self) -> list[Actor]:
@@ -82,7 +86,11 @@ class _StubActorService:
def remove_actor(self, name: str) -> None:
if name not in self.actors:
raise NotFoundError(f"Actor '{name}' not found")
raise NotFoundError(
f"Actor '{name}' not found",
resource_type="actor",
resource_id=name,
)
del self.actors[name]
if self.default_actor_name == name:
self.default_actor_name = None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
# pyright: reportRedeclaration=false
"""Extended step definitions for v3 Actor YAML schema support.
Covers tool-actor extraction, update-mode overwriting, LSP-as-dict
bindings, and field-propagation scenarios (context_view, memory,
env_vars, response_format). Core scenarios live in
``actor_v3_schema_steps.py``.
"""
from __future__ import annotations
from typing import Any
import yaml
from behave import given, then, when
from cleveragents.actor.config import ActorConfiguration
from cleveragents.reactive.config_parser import ReactiveConfigParser
# ── Given steps ──────────────────────────────────────────────────────────
@given("a v3 tool actor blob without model")
def step_v3_tool_blob_no_model(context: Any) -> None:
# Tool actors may omit model in v3 schema, but from_blob requires
# a model for ActorConfiguration. The v3 extraction returns
# provider="custom" and model="" which from_blob treats as missing.
# The proper v3 path goes through ActorRegistry.add() / add_v3().
context.v3_blob = {
"name": "local/test-tool",
"type": "tool",
"description": "A test tool actor",
"tools": ["local/file-ops"],
}
@given("a v3 tool actor YAML text")
def step_v3_tool_yaml_text(context: Any) -> None:
context.v3_tool_yaml_text = yaml.safe_dump(
{
"name": "local/test-tool",
"type": "tool",
"description": "A test tool actor",
"tools": ["local/file-ops"],
}
)
@given("a v3 LLM actor config dict with lsp as dict")
def step_v3_llm_config_dict_lsp_dict(context: Any) -> None:
context.v3_config = {
"name": "local/test-llm",
"type": "llm",
"description": "A test LLM actor",
"model": "gpt-4",
"lsp": {"auto": True, "languages": ["python"]},
}
@given("a v3 LLM actor config dict with context_view and memory")
def step_v3_llm_config_with_context_view(context: Any) -> None:
context.v3_config = {
"name": "local/test-llm",
"type": "llm",
"description": "A test LLM actor",
"model": "gpt-4",
"context_view": "executor",
"memory": {"enabled": True, "max_messages": 50},
"context": {"include_files": ["README.md"]},
"env_vars": {"API_KEY": "test"},
"response_format": {"type": "object"},
}
# ── When steps ───────────────────────────────────────────────────────────
@when("I call ActorRegistry.add with update=True for the v3 YAML")
def step_call_registry_add_v3_update(context: Any) -> None:
context.result_actor = context.registry.add(context.v3_yaml_text, update=True)
context.upsert_kwargs = context.mock_actor_service.upsert_actor.call_args
@when("I call ActorConfiguration.from_blob with the v3 tool blob")
def step_call_from_blob_v3_tool(context: Any) -> None:
# from_blob requires model for ActorConfiguration, so tool actors
# without model will raise ValueError. This is expected — the
# proper v3 path is through ActorRegistry.add() / add_v3().
try:
context.actor_config = ActorConfiguration.from_blob(
blob=context.v3_blob,
name="local/test",
)
context.from_blob_error = None
except ValueError as exc:
context.from_blob_error = exc
context.actor_config = None
@when("I call ActorRegistry.add with the v3 tool YAML")
def step_call_registry_add_v3_tool(context: Any) -> None:
context.result_actor = context.registry.add(context.v3_tool_yaml_text)
context.upsert_kwargs = context.mock_actor_service.upsert_actor.call_args
@when("I check if the v3 LLM config is v3 format")
def step_check_v3_llm_format(context: Any) -> None:
context.is_v3 = ReactiveConfigParser._is_v3_format(context.v3_config)
# ── Then steps ───────────────────────────────────────────────────────────
@then("the actor should be persisted successfully with update")
def step_actor_persisted_update(context: Any) -> None:
assert context.result_actor is not None, "Expected actor to be persisted"
call_kwargs = context.upsert_kwargs
assert call_kwargs is not None, "upsert_actor was not called"
@then("the tool actor from_blob should raise ValueError for missing model")
def step_tool_from_blob_raises(context: Any) -> None:
assert context.from_blob_error is not None, (
"Expected ValueError for tool actor without model in from_blob"
)
assert "model" in str(context.from_blob_error).lower(), (
f"Error should mention 'model': {context.from_blob_error}"
)
@then("the tool actor should be persisted with type tool")
def step_tool_actor_persisted(context: Any) -> None:
call_kwargs = context.upsert_kwargs
assert call_kwargs is not None, "upsert_actor was not called"
_, kwargs = call_kwargs
blob = kwargs.get("config_blob", {})
assert blob.get("type") == "tool", (
f"Expected type 'tool' in config_blob, got '{blob.get('type')}'"
)
@then("the agent config should contain lsp as dict")
def step_agent_config_has_lsp_dict(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
lsp = agent.config.get("lsp")
assert isinstance(lsp, dict), f"Expected lsp as dict, got {type(lsp)}"
assert lsp.get("auto") is True, f"Expected lsp.auto=True, got {lsp}"
@then("the agent config should contain context_view")
def step_agent_config_has_context_view(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
assert agent.config.get("context_view") == "executor", (
f"Expected context_view 'executor', got '{agent.config.get('context_view')}'"
)
@then("the agent config should contain memory settings")
def step_agent_config_has_memory(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
memory = agent.config.get("memory")
assert isinstance(memory, dict), f"Expected memory as dict, got {type(memory)}"
assert memory.get("enabled") is True
@then("the agent config should contain env_vars")
def step_agent_config_has_env_vars(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
env_vars = agent.config.get("env_vars")
assert isinstance(env_vars, dict), (
f"Expected env_vars as dict, got {type(env_vars)}"
)
assert env_vars.get("API_KEY") == "test"
@then("the agent config should contain response_format")
def step_agent_config_has_response_format(context: Any) -> None:
agents = context.reactive_config.agents
agent = next(iter(agents.values()))
rf = agent.config.get("response_format")
assert isinstance(rf, dict), f"Expected response_format as dict, got {type(rf)}"
assert rf.get("type") == "object"
@then("it should be detected as v3")
def step_is_v3(context: Any) -> None:
assert context.is_v3 is True, "Expected v3 data to be detected as v3"
+478
View File
@@ -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}"
+18 -4
View File
@@ -1267,6 +1267,9 @@ def step_assert_ckpt_match(context: Context) -> None:
@given("drcov3 multiple checkpoints exist for the plan")
def step_insert_multi_ckpts(context: Context) -> None:
# Use a fixed base time so ISO-8601 string ordering is deterministic
# regardless of clock resolution or CI environment timing.
base_time = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC)
context.drcov3_ckpt_ids = []
for i in range(3):
cid = _next_ulid()
@@ -1274,7 +1277,7 @@ def step_insert_multi_ckpts(context: Context) -> None:
ckpt = _make_checkpoint(
plan_id=context.drcov3_ckpt_plan_id,
checkpoint_id=cid,
created_at=datetime.now(UTC) + timedelta(seconds=i),
created_at=base_time + timedelta(seconds=i),
)
context.drcov3_ckpt_repo.create(ckpt)
@@ -1327,14 +1330,25 @@ def step_assert_ckpt_deleted(context: Context) -> None:
@given("drcov3 five checkpoints exist for prune test")
def step_create_five_ckpts(context: Context) -> None:
# Use a dedicated plan for the prune test to avoid interference
# from checkpoints created in earlier scenarios of this feature.
context.drcov3_prune_plan_id = _next_ulid()
session = context.drcov3_factory()
_ensure_plan_row(session, context.drcov3_prune_plan_id)
session.commit()
session.close()
# Use a fixed base time so ISO-8601 string ordering is deterministic
# regardless of clock resolution or CI environment timing.
base_time = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
context.drcov3_prune_ckpt_ids = []
for i in range(5):
cid = _next_ulid()
context.drcov3_prune_ckpt_ids.append(cid)
ckpt = _make_checkpoint(
plan_id=context.drcov3_ckpt_plan_id,
plan_id=context.drcov3_prune_plan_id,
checkpoint_id=cid,
created_at=datetime.now(UTC) + timedelta(seconds=i),
created_at=base_time + timedelta(seconds=i),
)
context.drcov3_ckpt_repo.create(ckpt)
@@ -1343,7 +1357,7 @@ def step_create_five_ckpts(context: Context) -> None:
def step_prune_ckpts(context: Context) -> None:
try:
context.drcov3_result = context.drcov3_ckpt_repo.prune(
context.drcov3_ckpt_plan_id, max_checkpoints=3
context.drcov3_prune_plan_id, max_checkpoints=3
)
except Exception as exc:
context.drcov3_error = exc
+162 -18
View File
@@ -16,6 +16,10 @@ except Exception: # pragma: no cover - defensive optional import
yaml = None
#: v3 actor types that signal the ``ActorConfigSchema`` format.
_V3_ACTOR_TYPES: frozenset[str] = frozenset({"llm", "graph", "tool"})
class ActorConfiguration(BaseModel):
"""Canonical actor configuration parsed from user-provided blobs."""
@@ -231,21 +235,37 @@ class ActorConfiguration(BaseModel):
default_options: dict[str, Any] | None = None,
option_overrides: dict[str, Any] | None = None,
) -> ActorConfiguration:
"""Coerce an incoming config blob plus CLI overrides into a validated model."""
"""Coerce an incoming config blob plus CLI overrides into a validated model.
Supports both the legacy v2 ``agents/routes`` YAML layout and the v3
``ActorConfigSchema`` format (identified by a top-level ``type`` key
whose value is one of ``llm``, ``graph``, or ``tool``).
"""
data: dict[str, Any] = dict(blob) if isinstance(blob, dict) else {}
# Try v3 extraction first (presence of 'type' with a v3 value).
v3_provider, v3_model, v3_graph, v3_unsafe = cls._extract_v3_actor(data)
# Fall back to v2 extraction when v3 didn't match.
v2_provider, v2_model, v2_graph, v2_unsafe = cls._extract_v2_actor(data)
v2_options = cls._extract_v2_options(data)
resolved_provider = (
provider or data.get("provider") or data.get("provider_type") or v2_provider
provider
or data.get("provider")
or data.get("provider_type")
or v3_provider
or v2_provider
)
resolved_model = (
model or data.get("model") or data.get("model_id") or v3_model or v2_model
)
resolved_model = model or data.get("model") or data.get("model_id") or v2_model
resolved_graph = (
graph_descriptor
or data.get("graph_descriptor")
or data.get("graph")
or v3_graph
or v2_graph
)
@@ -263,7 +283,12 @@ class ActorConfiguration(BaseModel):
if option_overrides:
merged_options.update(option_overrides)
resolved_unsafe = bool(data.get("unsafe", False)) or v2_unsafe or unsafe
top_unsafe_raw = data.get("unsafe", False)
# Accept only boolean True or integer 1 as unsafe (not truthy strings).
# Also incorporate v3_unsafe (from v3 schema path) and v2_unsafe.
resolved_unsafe = (
(top_unsafe_raw is True or top_unsafe_raw == 1) or v3_unsafe or v2_unsafe or unsafe
)
if not resolved_provider:
raise ValueError("provider is required")
@@ -286,31 +311,145 @@ class ActorConfiguration(BaseModel):
unsafe=resolved_unsafe,
)
@staticmethod
def _extract_v3_actor(
data: dict[str, Any],
) -> tuple[str | None, str | None, dict[str, Any] | None, bool]:
"""Derive provider/model/graph from the v3 Actor YAML schema format.
The v3 schema is identified by a top-level ``type`` key whose value
is one of ``llm``, ``graph``, or ``tool``. The model is taken from
the top-level ``model`` key. Because v3 YAML does not carry a
``provider`` field the provider is inferred from the model string:
* If the model contains ``/`` the prefix is used as provider
(e.g. ``openai/gpt-4`` provider ``openai``).
* Otherwise ``"custom"`` is used as a fallback.
For ``type: graph`` actors the ``route`` block is wrapped into a
graph descriptor.
Returns:
``(provider, model, graph_descriptor, unsafe)`` any component
may be ``None`` if the data does not look like v3.
"""
actor_type = data.get("type")
if not isinstance(actor_type, str):
return None, None, None, False
if actor_type.lower() not in _V3_ACTOR_TYPES:
return None, None, None, False
model_value = data.get("model")
# C2: model is optional for TOOL actors — only reject when model
# is absent for types that require it (LLM, GRAPH).
if not model_value or not isinstance(model_value, str):
if actor_type.lower() != "tool":
return None, None, None, False
# TOOL actors may omit model; use empty string as sentinel.
model_value = ""
# Infer provider from model string or default to "custom".
if model_value and "/" in model_value:
provider_value = model_value.split("/", 1)[0]
else:
provider_value = "custom"
unsafe_flag = bool(data.get("unsafe", False))
# Build a graph descriptor for graph actors from the route block.
graph_descriptor: dict[str, Any] | None = None
if actor_type.lower() == "graph":
route_raw = data.get("route")
if isinstance(route_raw, dict):
graph_descriptor = {
"type": "graph",
"route": route_raw,
"model": model_value,
}
return provider_value, model_value, graph_descriptor, unsafe_flag
@staticmethod
def _extract_v2_actor(
data: dict[str, Any],
) -> tuple[str | None, str | None, dict[str, Any] | None, bool]:
"""Derive provider/model/graph from the v2 YAML actor config format."""
"""Derive provider/model/graph from the v2/spec YAML actor config format.
agents_obj = data.get("agents")
if isinstance(agents_obj, dict) and agents_obj:
agents_dict = cast(dict[str, Any], agents_obj)
for first_agent, first_entry in agents_dict.items():
Supports both the legacy ``agents:`` map key and the spec-compliant
``actors:`` map key. Within each actor's ``config:`` block, the
combined ``actor`` field (e.g. ``"openai/gpt-4"``) is also accepted
as an alternative to separate ``provider`` + ``model`` fields.
"""
# The spec allows either ``actors:`` or ``agents:`` as the top-level
# map key (oneOf). Try both, preferring ``actors:`` (spec-canonical).
actors_val = data.get("actors")
if actors_val is not None:
map_obj = actors_val
map_key = "actors"
else:
map_obj = data.get("agents")
map_key = "agents"
# NOTE: Any non-None value for ``actors:`` — including falsy values
# such as ``actors: {}``, ``actors: false``, ``actors: 0``, or
# ``actors: ""`` — blocks the ``agents:`` fallback. This is
# intentional: a present ``actors:`` key explicitly declares the
# actors map (even if empty or invalid), whereas ``actors: null``
# (or absent) defers to ``agents:``.
#
# LIMITATION: Only the **first** entry in the actors/agents map is
# inspected for provider, model, unsafe, and graph descriptor. If a
# multi-actor YAML has ``unsafe: true`` in a *later* actor's config
# block but not in the first, the unsafe flag will not be detected.
# The ``add()`` method is designed for single-actor YAML files; the
# multi-actor case is handled by the v3 schema validation path.
if isinstance(map_obj, dict) and map_obj:
map_dict = cast(dict[str, Any], map_obj)
# Only the first actor entry is used for provider/model extraction.
for actor_key, first_entry in map_dict.items():
if isinstance(first_entry, dict):
entry_dict = cast(dict[str, Any], first_entry)
config_block = entry_dict.get("config")
if isinstance(config_block, dict):
config_dict = cast(dict[str, Any], config_block)
# Support the combined ``actor`` field format
# (e.g. ``"openai/gpt-4"`` → provider + model).
provider_value = config_dict.get("provider") or config_dict.get(
"provider_type"
)
model_value = config_dict.get("model") or config_dict.get(
"model_id"
)
unsafe_flag = bool(config_dict.get("unsafe", False))
combined_actor = config_dict.get("actor")
if (
combined_actor
and isinstance(combined_actor, str)
and "/" in combined_actor
):
parts = combined_actor.split("/", 1)
if not provider_value:
provider_value = parts[0]
if not model_value:
model_value = parts[1]
unsafe_raw = config_dict.get("unsafe", False)
# Accept only boolean ``True`` or integer ``1`` as
# unsafe. Note: ``unsafe_raw == 1`` also matches
# ``1.0`` (float) due to Python's numeric equality
# rules — this is fail-safe (treats 1.0 as unsafe).
unsafe_flag = unsafe_raw is True or unsafe_raw == 1
# The graph descriptor is always built and returned
# when a valid ``config:`` block is found, regardless
# of whether ``provider``/``model`` were extracted.
# This allows the caller to preserve the graph
# structure even when provider/model come from
# elsewhere (e.g. top-level fields).
descriptor: dict[str, Any] = {
"agent": first_agent,
"agents": agents_dict,
"agent": actor_key,
map_key: map_dict,
}
for key in (
"routes",
@@ -326,17 +465,22 @@ class ActorConfiguration(BaseModel):
descriptor,
unsafe_flag,
)
break
break # first entry only
return None, None, None, False
@staticmethod
def _extract_v2_options(data: dict[str, Any]) -> dict[str, Any] | None:
"""Extract option defaults from the v2 actor config format."""
"""Extract option defaults from the v2/spec actor config format.
agents_obj = data.get("agents")
if isinstance(agents_obj, dict) and agents_obj:
agents_dict = cast(dict[str, Any], agents_obj)
for _, first_entry in agents_dict.items():
Supports both ``actors:`` (spec-canonical) and ``agents:`` (legacy)
map keys.
"""
actors_val = data.get("actors")
map_obj = actors_val if actors_val is not None else data.get("agents")
if isinstance(map_obj, dict) and map_obj:
map_dict = cast(dict[str, Any], map_obj)
for _, first_entry in map_dict.items():
if isinstance(first_entry, dict):
entry_dict = cast(dict[str, Any], first_entry)
config_block = entry_dict.get("config")
+125 -11
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from dataclasses import asdict
from typing import Any
@@ -9,6 +10,7 @@ import pydantic
from cleveragents.actor.config import ActorConfiguration
from cleveragents.actor.schema import ActorConfigSchema, is_v3_yaml
from cleveragents.actor.v3_registry import add_v3, is_v3_blob
from cleveragents.application.services.actor_service import ActorService
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import NotFoundError, ValidationError
@@ -19,6 +21,8 @@ from cleveragents.providers.registry import (
ProviderRegistry,
)
logger = logging.getLogger(__name__)
class ActorRegistry:
"""Coordinate actor configuration parsing and built-in generation.
@@ -184,6 +188,8 @@ class ActorRegistry:
yaml_text: str,
*,
update: bool = False,
unsafe: bool = False,
allow_unsafe: bool = False,
schema_version: str | None = None,
compiled_metadata: dict[str, Any] | None = None,
) -> Actor:
@@ -192,28 +198,108 @@ class ActorRegistry:
The YAML is parsed into an ``ActorConfiguration``, validated, and
persisted alongside the original *yaml_text* and *schema_version*.
For v3 YAML files (detected by any non-null ``type`` field or a
``version`` starting with ``'3'``), the configuration is validated
using ``ActorConfigSchema`` to ensure proper schema compliance,
including cycle detection for GRAPH actors.
When the YAML matches the v3 ``ActorConfigSchema`` (detected by a
top-level ``type`` key of ``llm``, ``graph``, or ``tool``) the full
schema is validated, ``description`` is enforced, and ``skills`` /
``lsp`` bindings are stored in the config blob. For ``type: graph``
actors the route is additionally compiled and the compilation
metadata is persisted.
.. note::
The nested ``actors:``/``agents:`` map is **always** consulted
for ``unsafe`` and ``graph_descriptor`` extraction, even when
top-level ``provider`` and ``model`` fields are present. This
matches the behaviour of ``from_blob()``.
Args:
yaml_text: The original actor YAML source.
update: When ``True`` allow overwriting an existing actor.
unsafe: Caller confirms the actor is allowed to be unsafe.
allow_unsafe: Alternative flag to permit unsafe actors.
schema_version: Explicit schema version; defaults to
``DEFAULT_SCHEMA_VERSION``.
compiled_metadata: Optional compiler-produced metadata dict.
allow_unsafe: When ``True`` permit actors marked ``unsafe``.
Returns:
The persisted ``Actor`` domain object.
Raises:
ValidationError: When the YAML is invalid or the actor already
exists and *update* is ``False``.
ValidationError: When the YAML is invalid, the actor already
exists and *update* is ``False``, or the actor is unsafe
and neither *unsafe* nor *allow_unsafe* is set.
"""
self.ensure_built_in_actors()
blob = ActorConfiguration.load_yaml_text(yaml_text)
if not isinstance(blob, dict):
raise ValidationError("Actor YAML must be a mapping.")
version = schema_version or self.DEFAULT_SCHEMA_VERSION
# ----------------------------------------------------------
# v3 path: validate against ActorConfigSchema when detected
# ----------------------------------------------------------
if is_v3_blob(blob):
return add_v3(
blob,
yaml_text=yaml_text,
update=update,
schema_version=version,
compiled_metadata=compiled_metadata,
actor_service=self._actor_service,
allow_unsafe=allow_unsafe,
)
# ----------------------------------------------------------
# Legacy (v2) path: flat provider/model extraction
# ----------------------------------------------------------
return self._add_legacy(
blob,
yaml_text=yaml_text,
update=update,
schema_version=version,
compiled_metadata=compiled_metadata,
unsafe=unsafe,
allow_unsafe=allow_unsafe,
)
def _add_legacy(
self,
blob: dict[str, Any],
*,
yaml_text: str,
update: bool,
schema_version: str,
compiled_metadata: dict[str, Any] | None,
unsafe: bool = False,
allow_unsafe: bool = False,
) -> Actor:
"""Register an actor from a legacy (v2) blob.
Extracts ``name``, ``provider``, and ``model`` from the flat
top-level keys of the blob. Validates that all required fields
are present and that the actor does not already exist (unless
*update* is ``True``). Enforces the ``unsafe`` flag when
*allow_unsafe* is ``False``.
Args:
blob: Parsed YAML blob in legacy v2 format.
yaml_text: Original YAML source text.
update: When ``True`` allow overwriting an existing actor.
schema_version: Schema version string.
compiled_metadata: Optional pre-computed compilation metadata.
allow_unsafe: When ``True`` permit actors marked ``unsafe``.
Returns:
The persisted ``Actor`` domain object.
Raises:
ValidationError: When required fields are missing, the actor
already exists and *update* is ``False``, or the actor is
unsafe and *allow_unsafe* is ``False``.
"""
name_raw: str = blob.get("name", "")
if not name_raw:
raise ValidationError("Actor YAML must include a 'name' field.")
@@ -232,6 +318,18 @@ class ActorRegistry:
provider_raw = blob.get("provider") or blob.get("provider_type", "")
model_raw = blob.get("model") or blob.get("model_id", "")
# Always extract from the nested ``actors:``/``agents:`` map so that
# ``unsafe`` and ``graph_descriptor`` are captured even when top-level
# ``provider``/``model`` are present. This matches the behaviour of
# ``from_blob()`` which unconditionally calls ``_extract_v2_actor()``.
nested_provider, nested_model, nested_graph, nested_unsafe = (
ActorConfiguration._extract_v2_actor(blob)
)
if not provider_raw and nested_provider:
provider_raw = nested_provider
if not model_raw and nested_model:
model_raw = nested_model
if not blob_is_v3 and (not provider_raw or not model_raw):
raise ValidationError(
"Actor YAML must include 'provider' and 'model' fields."
@@ -244,7 +342,20 @@ class ActorRegistry:
provider = str(provider_raw) if provider_raw else ""
model = str(model_raw) if model_raw else ""
# Check for existing actor when not updating
top_unsafe_raw = blob.get("unsafe", False)
# Accept only boolean ``True`` or integer ``1`` as unsafe. Note:
# ``unsafe_raw == 1`` also matches ``1.0`` (float) due to Python's
# numeric equality rules — this is fail-safe (treats 1.0 as unsafe).
effective_unsafe = (
top_unsafe_raw is True or top_unsafe_raw == 1
) or nested_unsafe
if effective_unsafe and not (unsafe or allow_unsafe):
raise ValidationError(
"Actor configuration is marked unsafe; pass unsafe=True "
"or allow_unsafe=True to confirm."
)
# M3: catch only NotFoundError, not generic Exception.
if not update:
try:
self._actor_service.get_actor(name)
@@ -255,21 +366,24 @@ class ActorRegistry:
f"Actor '{name}' already exists. Pass update=True to overwrite."
)
version = schema_version or self.DEFAULT_SCHEMA_VERSION
config_blob: dict[str, Any] = dict(blob)
config_blob.setdefault("source", "yaml")
top_graph_raw = blob.get("graph_descriptor")
top_graph = top_graph_raw if top_graph_raw is not None else blob.get("graph")
resolved_graph = top_graph if top_graph is not None else nested_graph
return self._actor_service.upsert_actor(
name=name,
provider=provider,
model=model,
config_blob=config_blob,
graph_descriptor=blob.get("graph_descriptor"),
unsafe=bool(blob.get("unsafe", False)),
graph_descriptor=resolved_graph,
unsafe=effective_unsafe,
set_default=False,
is_built_in=False,
yaml_text=yaml_text,
schema_version=version,
schema_version=schema_version,
compiled_metadata=compiled_metadata,
)
+170
View File
@@ -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,
)
+3 -2
View File
@@ -658,8 +658,9 @@ def add(
# Use the YAML-first path via registry.upsert_actor() with
# yaml_text threaded through. This preserves the original YAML
# text, schema_version, and compiled_metadata in the database
# while also honouring all CLI flags (--set-default, --option,
# --unsafe) that registry.add() does not accept.
# while also honouring all CLI flags (--set-default, --option)
# that registry.add() does not support. Note: registry.add()
# does accept ``unsafe`` and ``allow_unsafe`` parameters.
actor = registry.upsert_actor(
name=name,
config_blob=canonical_blob,
+212
View File
@@ -15,6 +15,7 @@ from typing import Any
import yaml
from pydantic import BaseModel, Field
from cleveragents.actor.config import _V3_ACTOR_TYPES
from cleveragents.core.exceptions import ConfigurationError
from cleveragents.reactive.route import BridgeConfig, RouteConfig, RouteType
from cleveragents.reactive.stream_router import StreamType
@@ -59,6 +60,18 @@ class ReactiveConfig(BaseModel):
prompts: dict[str, Any] = Field(default_factory=dict)
def _infer_provider_from_model(model: str) -> str:
"""Infer a provider identifier from a model string.
If the model contains ``/`` the prefix is used as provider
(e.g. ``openai/gpt-4`` ``openai``). Otherwise ``"custom"``
is returned as a fallback.
"""
if "/" in model:
return model.split("/", 1)[0]
return "custom"
class ReactiveConfigParser:
def __init__(self) -> None:
self.env_pattern = re.compile(r"\${([A-Za-z0-9_]+)(?::([^}]*))?}")
@@ -101,8 +114,19 @@ class ReactiveConfigParser:
value = self.env_pattern.sub(repl, value)
return value
@staticmethod
def _is_v3_format(data: dict[str, Any]) -> bool:
"""Return ``True`` when *data* matches the v3 ``ActorConfigSchema``."""
actor_type = data.get("type")
return isinstance(actor_type, str) and actor_type.lower() in _V3_ACTOR_TYPES
def _build(self, data: dict[str, Any]) -> ReactiveConfig:
rc = ReactiveConfig()
# v3 Actor YAML: synthesise a reactive agent from the top-level config.
if self._is_v3_format(data):
return self._build_from_v3(data, rc)
agents_data = data.get("agents") or data.get("actors") or {}
for name, agent_data in (agents_data or {}).items():
rc.agents[name] = AgentConfig(
@@ -175,3 +199,191 @@ class ReactiveConfigParser:
rc.template_engine = data.get("template_engine", rc.template_engine)
rc.prompts = data.get("prompts", {}) or {}
return rc
@staticmethod
def _build_from_v3(
data: dict[str, Any],
rc: ReactiveConfig,
) -> ReactiveConfig:
"""Synthesise a ``ReactiveConfig`` from a v3 ``ActorConfigSchema`` blob.
For ``type: llm`` actors a single reactive agent is created with the
model, system prompt, and tool references from the v3 config. For
``type: graph`` actors the route nodes are mapped to reactive agents
and the edges are mapped to graph routes. Skills and LSP bindings
are propagated via the agent config so the runtime can attach them.
All v3 fields ``context_view``, ``memory``, ``context``,
``env_vars``, ``response_format``, ``lsp_capabilities``, and
``lsp_context_enrichment`` are propagated into the agent config
so the runtime can consume them.
"""
# m4: guard against None-to-string coercion for all three fields.
actor_type = str(data.get("type") or "llm").lower()
actor_name = str(data.get("name") or "v3_actor")
model = str(data.get("model") or "")
# m8: validate that model is non-empty for types that require it.
if not model and actor_type in ("llm", "graph"):
raise ConfigurationError(
f"v3 actor '{actor_name}' of type '{actor_type}' requires a "
f"non-empty 'model' field."
)
# Infer provider from model string (m11: shared utility).
provider = _infer_provider_from_model(model)
# Collect v3 metadata for runtime attachment.
# m12: validate element types for skills list.
raw_skills = data.get("skills", []) or []
skills: list[str] = (
[str(s) for s in raw_skills] if isinstance(raw_skills, list) else []
)
lsp_raw = data.get("lsp")
lsp_bindings: list[str] | dict[str, Any] | None = None
if isinstance(lsp_raw, list):
lsp_bindings = [str(s) for s in lsp_raw]
elif isinstance(lsp_raw, dict):
lsp_bindings = dict(lsp_raw)
agent_config: dict[str, Any] = {
"provider": provider,
"model": model,
"system_prompt": data.get("system_prompt", ""),
}
if skills:
agent_config["skills"] = skills
if lsp_bindings is not None:
agent_config["lsp"] = lsp_bindings
# Attach tool references for tool-bearing actors.
tools_raw = data.get("tools", []) or []
if tools_raw:
agent_config["tools"] = tools_raw
# M6: propagate context_view.
context_view = data.get("context_view")
if context_view is not None:
agent_config["context_view"] = context_view
# M7: propagate memory and context configuration objects.
memory_raw = data.get("memory")
if isinstance(memory_raw, dict):
agent_config["memory"] = memory_raw
context_raw = data.get("context")
if isinstance(context_raw, dict):
agent_config["context"] = context_raw
# M8: propagate lsp_capabilities and lsp_context_enrichment.
lsp_caps = data.get("lsp_capabilities")
if lsp_caps is not None:
agent_config["lsp_capabilities"] = lsp_caps
lsp_enrichment = data.get("lsp_context_enrichment")
if isinstance(lsp_enrichment, dict):
agent_config["lsp_context_enrichment"] = lsp_enrichment
# m2: propagate env_vars.
env_vars = data.get("env_vars")
if isinstance(env_vars, dict):
agent_config["env_vars"] = env_vars
# m3: propagate response_format.
response_format = data.get("response_format")
if isinstance(response_format, dict):
agent_config["response_format"] = response_format
if actor_type in ("llm", "tool"):
# Single-agent synthesis.
rc.agents[actor_name] = AgentConfig(
name=actor_name,
type=actor_type,
config=agent_config,
)
elif actor_type == "graph":
# Map route nodes → agents and edges → graph routes.
route_raw = data.get("route")
if not isinstance(route_raw, dict):
# m7: graph actor without route is a configuration error.
raise ConfigurationError(
f"v3 graph actor '{actor_name}' requires a 'route' "
f"mapping but none was provided."
)
nodes_list = route_raw.get("nodes", [])
edges_list = route_raw.get("edges", [])
entry_node = route_raw.get("entry_node", "start")
nodes_map: dict[str, dict[str, Any]] = {}
for node in nodes_list:
if not isinstance(node, dict):
continue
node_id = str(node.get("id", ""))
if not node_id:
continue
# C3: guard against config: null producing TypeError.
raw_config = node.get("config")
node_config = dict(raw_config) if isinstance(raw_config, dict) else {}
node_config.setdefault("model", model)
node_config.setdefault("provider", provider)
# M9: copy skills list to avoid shared mutable reference.
if skills:
node_config.setdefault("skills", list(skills))
# M5: propagate LSP bindings to graph nodes.
if lsp_bindings is not None:
node_config.setdefault("lsp", lsp_bindings)
nodes_map[node_id] = node_config
# Create a reactive agent for each graph node.
rc.agents[node_id] = AgentConfig(
name=node_id,
type=str(node.get("type", "agent")),
config=node_config,
)
# m5: validate entry_node against nodes_map.
if entry_node not in nodes_map:
raise ConfigurationError(
f"v3 graph actor '{actor_name}': entry_node "
f"'{entry_node}' not found in route nodes "
f"{sorted(nodes_map.keys())}."
)
# C1: use "source"/"target" keys matching RouteConfig.to_graph_config().
edge_entries: list[dict[str, Any]] = []
for edge in edges_list:
if not isinstance(edge, dict):
continue
from_node = edge.get("from_node", "")
to_node = edge.get("to_node", "")
# m6: skip edges with empty source or target.
if not from_node or not to_node:
continue
edge_entries.append(
{
"source": from_node,
"target": to_node,
"condition": edge.get("condition"),
}
)
# m1: propagate exit_nodes into route metadata.
exit_nodes = route_raw.get("exit_nodes", [])
route_metadata: dict[str, Any] = {
"v3_actor": actor_name,
"skills": skills,
}
if exit_nodes:
route_metadata["exit_nodes"] = exit_nodes
rc.routes[f"{actor_name}_graph"] = RouteConfig(
name=f"{actor_name}_graph",
type=RouteType.GRAPH,
nodes=nodes_map,
edges=edge_entries,
entry_point=entry_node,
metadata=route_metadata,
)
return rc