Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb87e81acd | |||
| 7bef6ba706 | |||
|
78be08870c
|
|||
| 5ee08ea946 | |||
| 6e1646d565 | |||
| 815f546bd2 | |||
| f78c1c2c98 | |||
| 3f0ce3d20a | |||
| 2cba7d41bc |
@@ -11,7 +11,7 @@
|
||||
"type": "local",
|
||||
"command": ["npx", "@modelcontextprotocol/server-sequential-thinking"],
|
||||
"enabled": false
|
||||
},
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"CleverThis": {
|
||||
@@ -21,7 +21,7 @@
|
||||
"apiKey": "{env:HF_TOKEN}",
|
||||
"headers": {
|
||||
"X-HF-Bill-To": "CleverThis",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
@@ -38,7 +38,7 @@
|
||||
"apiKey": "{env:HF_TOKEN}",
|
||||
"headers": {
|
||||
"X-HF-Bill-To": "CleverThis",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
@@ -59,7 +59,7 @@
|
||||
"apiKey": "{env:HF_TOKEN}",
|
||||
"headers": {
|
||||
"X-HF-Bill-To": "CleverThis",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
@@ -76,7 +76,7 @@
|
||||
"apiKey": "{env:HF_TOKEN}",
|
||||
"headers": {
|
||||
"X-HF-Bill-To": "CleverThis",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
@@ -93,7 +93,7 @@
|
||||
"apiKey": "{env:HF_TOKEN}",
|
||||
"headers": {
|
||||
"X-HF-Bill-To": "CleverThis",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
@@ -110,7 +110,7 @@
|
||||
"apiKey": "{env:HF_TOKEN}",
|
||||
"headers": {
|
||||
"X-HF-Bill-To": "CleverThis",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
@@ -127,7 +127,7 @@
|
||||
"apiKey": "{env:HF_TOKEN}",
|
||||
"headers": {
|
||||
"X-HF-Bill-To": "CleverThis",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
@@ -144,7 +144,7 @@
|
||||
"apiKey": "{env:HF_TOKEN}",
|
||||
"headers": {
|
||||
"X-HF-Bill-To": "CleverThis",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}",
|
||||
"Authorization": "Bearer {env:HF_TOKEN}"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
|
||||
@@ -3,8 +3,6 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
|
||||
vars:
|
||||
docker_prefix: "http://harbor.cleverthis.com/docker/"
|
||||
|
||||
@@ -14,6 +14,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
from the TDD test so both scenarios run as normal regression guards. (#988)
|
||||
|
||||
### Fixed
|
||||
- **Actor configuration validation incorrectly requires top-level provider field** (#4300):
|
||||
Actor configuration in V3 is now obtained from the nested configuration
|
||||
parameter, according to the specification.
|
||||
Removed the legacy V2 fallback support and the tests affected by that
|
||||
removal. Mocked existing steps to allow remaining V2 features to be
|
||||
covered/tested.
|
||||
|
||||
- **TUI Prompt Symbol Mode Awareness** (#6431): The prompt widget now displays a
|
||||
mode-dependent symbol (`❯` normal, `/` command, `$` shell, `☰` multi-line),
|
||||
implemented via `_PromptSymbolMixin` and `InputMode.MULTILINE`. The widget uses
|
||||
@@ -96,6 +103,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed stale `AUTO-BUG-POOL` tracking prefix references in automation-tracking.md documentation and agent-system-specification.md spec document, replaced with correct `AUTO-BUG-SUP` prefix used by the bug-hunt-pool-supervisor agent (#7875).
|
||||
|
||||
- **`agents session list` now displays full 26-character session ULIDs** (#10970): The Rich table
|
||||
and Summary panel ("Most Recent" / "Oldest") previously showed only the first 8 characters of
|
||||
each session ULID. This made the output unusable for copy-paste into `session tell`,
|
||||
@@ -220,6 +229,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
failure paths. Comprehensive BDD test coverage validates the fix under concurrent
|
||||
execution and confirms proper cleanup behavior.
|
||||
|
||||
- **Database resource types (PostgreSQL, SQLite) with transaction-based sandbox strategy** (#8608):
|
||||
Implemented comprehensive database resource support enabling users to interact with
|
||||
PostgreSQL and SQLite backends through a unified resource interface. Introduces
|
||||
`DatabaseResourceHandler` providing full CRUD operations (`read`, `write`, `delete`,
|
||||
`list_children`), connection validation with automatic credential masking via
|
||||
:mod:`cleveragents.shared.redaction`, and transaction-based sandbox strategy using
|
||||
BEGIN/COMMIT/ROLLBACK wrappers for safe, isolated database operations. SQLite-specific
|
||||
checkpoint and rollback support with SAVEPOINT semantics. Support for multiple backends (PostgreSQL, SQLite, MySQL, DuckDB) via unified "DatabaseResourceHandler" and type-specific routing. BDD test
|
||||
coverage in ``features/database_resources.feature`` (connection validation, CRUD workflows,
|
||||
transaction/rollback behavior, error handling, credential masking verification) and
|
||||
Robot Framework integration tests in ``robot/database_resources.robot``.
|
||||
|
||||
- **TransactionSandbox infrastructure for database resource isolation** (#8608):
|
||||
Implemented ``TransactionSandbox`` class with BEGIN/COMMIT/ROLLBACK lifecycle
|
||||
management for transaction-based sandbox strategy. Wired into ``SandboxFactory``
|
||||
as the strategy resolver for database resource types. Added ``database`` resource type
|
||||
registration in bootstrap builtin types and updated ``_resource_registry_data.py``
|
||||
to recognize database resource categories.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **fix(repositories): derive PlanResult.success from result_success column instead of error_message** (#7501):
|
||||
@@ -420,6 +448,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
MCP logger thread-safety in `session.py` using a threading lock. Created
|
||||
migration guide for users transitioning from legacy to V3 workflow.
|
||||
|
||||
- **Plan Tree JSON/YAML Command Envelope** (#9163): `agents plan tree --format json/yaml`
|
||||
now wraps output in the spec-required command envelope with `command`, `status`,
|
||||
`exit_code`, `data`, `timing`, and `messages` fields. The `data` field contains
|
||||
`plan_id`, `tree`, `summary` (nodes, depth, child_plans, invariants, superseded),
|
||||
`child_plans` list, and `decision_ids` mapping. Timing now reflects actual elapsed
|
||||
milliseconds from command start to envelope construction.
|
||||
|
||||
- **Automation Profile Silent Fallback** (#8232): `_resolve_profile_for_plan` in
|
||||
`PlanLifecycleService` now raises a clear `ValidationError` when a plan's
|
||||
automation profile name is not a known built-in profile, instead of silently
|
||||
|
||||
+5
-4
@@ -17,12 +17,12 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
|
||||
* HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution.
|
||||
* HAL 9000 has contributed the plan concurrency race-condition fix (#7989): wired `LockService` into the plan lifecycle, guarding `execute_plan()` and `apply_plan()` with plan-level advisory locks and unique per-invocation owner identities to prevent silent concurrent state corruption.
|
||||
* HAL 9000 has contributed the bug-hunt-pool-supervisor non-blocking tracking fix: updated step 5 to be best-effort and added rule 9 to prevent the automation-tracking-manager call from blocking the main supervisor loop.
|
||||
<<<<<<< HEAD
|
||||
* HAL 9000 has contributed the bug-hunt-pool-supervisor non-blocking tracking fix (#7875 / PR #7957): updated step 5 to be best-effort and added rule 9 to prevent the automation-tracking-manager call from blocking the main supervisor loop.
|
||||
* Jeffrey Phillips Freeman has contributed the complete AUTO-BUG-POOL to AUTO-BUG-SUP tracking prefix fix across agent-system-specification.md, automation-tracking.md documentation and agent-system-specification.md spec document, replaced with correct `AUTO-BUG-SUP` prefix used by the bug-hunt-pool-supervisor agent (#7875).
|
||||
* HAL 9000 has contributed the plugin entry point security hardening fix (#7476): enforced entry point allowlist validation before importing plugin modules to prevent malicious plugin loading.
|
||||
* HAL 9000 has contributed the benchmark workflow separation (#9040): moved the benchmark-regression job out of the default PR workflow into a dedicated scheduled workflow, reducing median PR CI turnaround time from 99-132 minutes to under 30 minutes.
|
||||
* HAL 9000 has contributed the agent-evolution-pool-supervisor PR metadata assignment (#7888): the supervisor now automatically looks up the Type/Automation label and earliest open milestone before dispatching improvement PR creation workers, ensuring all generated improvement PRs have correct Type labels and milestone assignments.
|
||||
* HAL 9000 has contributed the decision recording hook for the Strategize phase (issue #8522): captures every decision point with question, chosen option, alternatives, confidence, rationale, and full context snapshot for replay and correction.
|
||||
* HAL 9000 has contributed automated specification maintenance, documentation updates, and bot-driven PR authorship.
|
||||
* HAL 9000 has contributed the plan tree JSON/YAML command envelope fix (#9163): wrapped `agents plan tree --format json/yaml` output in the spec-required command envelope structure, added summary statistics, decision_ids mapping, child_plans list, and accurate timing measurement.
|
||||
* This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc.
|
||||
* HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system.
|
||||
* HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559).
|
||||
@@ -38,3 +38,4 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the error-suppression removal fix (PR #9247 / issue #9060): removed both `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors from `actor_registry.list_actors()` and the route bridge refresh, enabling exceptions to propagate per CONTRIBUTING.md fail-fast policy. Added three Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation.
|
||||
* HAL 9000 has contributed the Strategize phase full context snapshot fix (issue #9056): added `_build_strategize_context_snapshot()` helper to `PlanLifecycleService`, updated `_try_record_decision()` to accept and forward a `ContextSnapshot` parameter, and added BDD test coverage verifying all four `ContextSnapshot` fields (`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, `relevant_resources`) are populated during the Strategize phase.
|
||||
* HAL 9000 has contributed the ACMS context path matching fix (PR #10975 / issue #10972): corrects `_path_matches()` and `_matches_pattern()` to properly match absolute fragment paths against relative glob patterns by auto-prefixing with `**/` before calling `PurePath.full_match()`, preventing silent inefficacy of include/exclude filters for absolute paths in fragment metadata.
|
||||
* HAL 9000 has contributed database resource types (PostgreSQL, SQLite) with transaction-based sandbox strategy: implemented ``DatabaseResourceHandler`` providing full CRUD operations (`read`, `write`, `delete`, `list_children`) and connection validation with automatic credential masking for PostgreSQL and SQLite backends. Includes ``TransactionSandbox`` infrastructure wired into ``SandboxFactory``, BDD test coverage in ``features/database_resources.feature``, and Robot Framework integration tests in ``robot/database_resources.robot`` (PR #10591 / issue #8608, Epic #8568).
|
||||
|
||||
@@ -665,7 +665,7 @@ The Automation Tracking Manager provides eleven operations:
|
||||
| `AUTO-IMP-POOL` | Implementation Pool Supervisor |
|
||||
| `AUTO-REV-POOL` | PR Review Pool Supervisor |
|
||||
| `AUTO-UAT-POOL` | UAT Test Pool Supervisor |
|
||||
| `AUTO-BUG-POOL` | Bug Hunt Pool Supervisor |
|
||||
| `AUTO-BUG-SUP` | Bug Hunt Pool Supervisor |
|
||||
| `AUTO-INF-POOL` | Test Infrastructure Pool Supervisor |
|
||||
| `AUTO-ARCH` | Architecture Supervisor |
|
||||
| `AUTO-EPIC` | Epic Planning Supervisor |
|
||||
@@ -693,7 +693,7 @@ The following table maps between the two schemes for agents where they differ:
|
||||
| Implementation Pool | `AUTO-IMP-SUP` | `AUTO-IMP-POOL` |
|
||||
| PR Review Pool | `AUTO-REV-SUP` | `AUTO-REV-POOL` |
|
||||
| UAT Test Pool | `AUTO-UAT-SUP` | `AUTO-UAT-POOL` |
|
||||
| Bug Hunt Pool | `AUTO-BUG-SUP` | `AUTO-BUG-POOL` |
|
||||
| Bug Hunt Pool | `AUTO-BUG-SUP` | `AUTO-BUG-SUP` |
|
||||
| Test Infrastructure Pool | `AUTO-INF-SUP` | `AUTO-INF-POOL` |
|
||||
| Human Liaison | `AUTO-HUMAN` | `AUTO-LIAISON` |
|
||||
| Grooming | `AUTO-GROOM` | `AUTO-GROOMER` |
|
||||
@@ -1869,7 +1869,7 @@ Only critical bugs get assigned to the active milestone. Non-critical issues go
|
||||
| **Mode** | `subagent` |
|
||||
| **Model** | `google/gemini-2.5-pro` |
|
||||
| **Temperature** | 0.1 |
|
||||
| **Tracking Prefix** | `AUTO-BUG-POOL` |
|
||||
| **Tracking Prefix** | `AUTO-BUG-SUP` |
|
||||
| **Worker Count** | N/4 (quarter allocation) |
|
||||
|
||||
#### 8.6.1 Purpose
|
||||
@@ -3164,7 +3164,7 @@ Defines how ALL agents discover, interact with, and coordinate through the autom
|
||||
|
||||
### 13.9 Bug Hunter Tracking Update (`shared/bug_hunter_tracking_update.md`)
|
||||
|
||||
Defines the migration guide for converting the Bug Hunt supervisor from the old session state system to the new individual tracking issue system. Documents the tracking issue format `[AUTO-BUG-POOL] Bug Detection Pool Status (Cycle N)`, the cleanup protocol (one issue per cycle, preserve announcements), and worker coordination via announcement issues.
|
||||
Defines the migration guide for converting the Bug Hunt supervisor from the old session state system to the new individual tracking issue system. Documents the tracking issue format `[AUTO-BUG-SUP] Bug Hunt Status (Cycle 25)`, the cleanup protocol (one issue per cycle, preserve announcements), and worker coordination via announcement issues.
|
||||
|
||||
---
|
||||
|
||||
@@ -6767,7 +6767,7 @@ The system uses five distinct redundancy patterns:
|
||||
| 4 | pr-merge-pool-supervisor | subagent | claude-sonnet-4-6 | AUTO-MERGE | Singleton Supervisor |
|
||||
| 5 | pr-fix-pool-supervisor | *(removed — absorbed into implementation-pool-supervisor)* | -- | -- | -- |
|
||||
| 6 | uat-test-pool-supervisor | subagent | claude-sonnet-4-6 | AUTO-UAT-POOL | Pool Supervisor |
|
||||
| 7 | bug-hunt-pool-supervisor | subagent | gemini-2.5-pro | AUTO-BUG-POOL | Pool Supervisor |
|
||||
| 7 | bug-hunt-pool-supervisor | subagent | gemini-2.5-pro | AUTO-BUG-SUP | Pool Supervisor |
|
||||
| 8 | test-infra-pool-supervisor | subagent | gemini-2.5-pro | AUTO-INF-POOL | Pool Supervisor |
|
||||
| 9 | architecture-pool-supervisor | subagent | claude-sonnet-4-6 | AUTO-ARCH | Singleton Supervisor |
|
||||
| 10 | epic-planning-pool-supervisor | subagent | claude-sonnet-4-6 | AUTO-EPIC | Singleton Supervisor |
|
||||
|
||||
@@ -61,7 +61,7 @@ All prefixes are registered in the `automation-tracking-manager` subagent, which
|
||||
| uat-test-pool-supervisor | `AUTO-UAT-POOL` | `[AUTO-UAT-POOL] UAT Status (Cycle 6)` |
|
||||
| project-owner-pool-supervisor | `AUTO-PROJ-OWN` | `[AUTO-PROJ-OWN] Project Status (Cycle 11)` |
|
||||
| agent-evolution-pool-supervisor | `AUTO-EVLV` | `[AUTO-EVLV] Agent Evolution Report (Cycle 10)` |
|
||||
| bug-hunt-pool-supervisor | `AUTO-BUG-POOL` | `[AUTO-BUG-POOL] Bug Detection Pool Status (Cycle 60)` |
|
||||
| bug-hunt-pool-supervisor | `AUTO-BUG-SUP` | `[AUTO-BUG-SUP] Bug Hunt Status (Cycle 25)` |
|
||||
| spec-update-pool-supervisor | `AUTO-SPEC` | `[AUTO-SPEC] Specification Update Report (Cycle 1)` |
|
||||
| test-infra-pool-supervisor | `AUTO-INF-POOL` | `[AUTO-INF-POOL] Infrastructure Analysis Report (Cycle 2)` |
|
||||
| epic-planner | `AUTO-EPIC` | `[AUTO-EPIC] Epic Planning Update (Cycle 9)` |
|
||||
@@ -394,7 +394,7 @@ label:"Automation Tracking" [AUTO-UAT-POOL] in:title
|
||||
label:"Automation Tracking" [AUTO-PROJ-OWN] in:title
|
||||
label:"Automation Tracking" [AUTO-EVLV] in:title
|
||||
label:"Automation Tracking" [AUTO-EPIC] in:title
|
||||
label:"Automation Tracking" [AUTO-BUG-POOL] in:title
|
||||
label:"Automation Tracking" [AUTO-BUG-SUP] in:title
|
||||
label:"Automation Tracking" [AUTO-SPEC] in:title
|
||||
label:"Automation Tracking" [AUTO-INF-POOL] in:title
|
||||
```
|
||||
|
||||
@@ -279,140 +279,10 @@ Feature: Actor configuration uncovered lines coverage
|
||||
When I build an actor configuration from blob {"provider": "only-provider"}
|
||||
Then a ValueError should be raised containing "model is required"
|
||||
|
||||
Scenario: from_blob merges default and v2 and override options
|
||||
When I build an actor configuration from structured blob with defaults and overrides:
|
||||
"""
|
||||
{
|
||||
"blob": {
|
||||
"provider": "cli-provider",
|
||||
"model": "cli-model",
|
||||
"options": {"user": "blob"},
|
||||
"agents": {
|
||||
"writer": {
|
||||
"config": {
|
||||
"options": {"v2": "v2-option"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default_options": {"base": "default"},
|
||||
"option_overrides": {"user": "override", "extra": "added"}
|
||||
}
|
||||
"""
|
||||
Then the actor configuration should have provider "cli-provider" and model "cli-model"
|
||||
And the actor configuration options should equal {"base": "default", "v2": "v2-option", "user": "override", "extra": "added"}
|
||||
|
||||
Scenario: from_blob with None blob defaults to empty data
|
||||
When I build actor config from None blob with provider and model overrides
|
||||
Then the actor configuration should have provider "override-provider" and model "override-model"
|
||||
|
||||
# --- _extract_v2_actor: lines 275-307 ---
|
||||
|
||||
Scenario: v2 YAML actor config infers provider and model
|
||||
Given an actor config file "v2.yaml" with content:
|
||||
"""
|
||||
cleveragents:
|
||||
default_router: main_router
|
||||
agents:
|
||||
paper_writer:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
unsafe: true
|
||||
options:
|
||||
temperature: 0.5
|
||||
routes:
|
||||
main_router:
|
||||
type: stream
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: paper_writer
|
||||
publications:
|
||||
- __output__
|
||||
"""
|
||||
When I parse the actor configuration from file "v2.yaml" with overrides:
|
||||
"""
|
||||
{}
|
||||
"""
|
||||
Then the actor configuration should have provider "openai" and model "gpt-4"
|
||||
And the actor configuration unsafe flag should be true
|
||||
And the actor configuration graph descriptor should include key "routes"
|
||||
And the actor configuration graph descriptor should include key "agents"
|
||||
And the actor configuration graph descriptor should include key "cleveragents"
|
||||
And the actor configuration options should equal {"temperature": 0.5}
|
||||
|
||||
Scenario: v2 extraction includes merges and templates keys when present
|
||||
Given an actor config file "v2_merges.yaml" with content:
|
||||
"""
|
||||
agents:
|
||||
writer:
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
merges:
|
||||
combined:
|
||||
type: join
|
||||
templates:
|
||||
default:
|
||||
system_prompt: "hello"
|
||||
"""
|
||||
When I parse the actor configuration from file "v2_merges.yaml" with overrides:
|
||||
"""
|
||||
{}
|
||||
"""
|
||||
Then the actor configuration should have provider "openai" and model "gpt-4"
|
||||
And the actor configuration graph descriptor should include key "merges"
|
||||
And the actor configuration graph descriptor should include key "templates"
|
||||
|
||||
Scenario: v2 extraction handles agent entry without config block
|
||||
Given an actor config file "v2_no_config.yaml" with content:
|
||||
"""
|
||||
provider: fallback-provider
|
||||
model: fallback-model
|
||||
agents:
|
||||
first:
|
||||
type: llm
|
||||
"""
|
||||
When I parse the actor configuration from file "v2_no_config.yaml" with overrides:
|
||||
"""
|
||||
{}
|
||||
"""
|
||||
Then the actor configuration should have provider "fallback-provider" and model "fallback-model"
|
||||
|
||||
# --- _extract_v2_options: lines 316-326 ---
|
||||
|
||||
Scenario: v2 extraction skips non-dict agent entries
|
||||
Given an actor config file "invalid_v2.yaml" with content:
|
||||
"""
|
||||
provider: fallback-provider
|
||||
model: fallback-model
|
||||
agents:
|
||||
first: not-a-dict
|
||||
"""
|
||||
When I parse the actor configuration from file "invalid_v2.yaml" with overrides:
|
||||
"""
|
||||
{}
|
||||
"""
|
||||
Then the actor configuration should have provider "fallback-provider" and model "fallback-model"
|
||||
|
||||
Scenario: v2 options extraction returns None for agents without options
|
||||
Given an actor config file "v2_no_opts.yaml" with content:
|
||||
"""
|
||||
agents:
|
||||
writer:
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
When I parse the actor configuration from file "v2_no_opts.yaml" with overrides:
|
||||
"""
|
||||
{}
|
||||
"""
|
||||
Then the actor configuration should have provider "openai" and model "gpt-4"
|
||||
And the actor configuration options should equal {}
|
||||
|
||||
Scenario: from_blob reads provider_type and model_id aliases
|
||||
When I build an actor configuration from blob {"provider_type": "aliased-provider", "model_id": "aliased-model"}
|
||||
Then the actor configuration should have provider "aliased-provider" and model "aliased-model"
|
||||
|
||||
@@ -15,6 +15,7 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats
|
||||
And the registered actor name should be "local/my-assistant"
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
@tdd_issue @tdd_issue_4300
|
||||
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"
|
||||
@@ -53,24 +54,13 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats
|
||||
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 ──────
|
||||
# ── Top-level provider+model still picks up nested unsafe ───────────
|
||||
|
||||
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
|
||||
@@ -94,12 +84,6 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should not be marked unsafe
|
||||
|
||||
# ── Missing provider/model still rejected for non-v3 YAML ─────────
|
||||
|
||||
Scenario: registry.add() rejects non-v3 YAML without any provider or model
|
||||
When I attempt to add a YAML with no provider or model anywhere
|
||||
Then a spec-yaml ValidationError should be raised containing "provider"
|
||||
|
||||
# ── update=True path ───────────────────────────────────────────────
|
||||
|
||||
Scenario: registry.add() with update=True overwrites an existing actor using spec-compliant YAML
|
||||
@@ -139,366 +123,6 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats
|
||||
And the registered actor should be marked unsafe
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
# ── Multi-actor YAML rejection ───────────────────────────────────
|
||||
|
||||
Scenario: registry.add() rejects multi-actor YAML with a ValidationError
|
||||
When I attempt to add a multi-actor YAML with two actor entries
|
||||
Then a spec-yaml ValidationError should be raised containing "single-actor"
|
||||
|
||||
Scenario: registry.add() rejects multi-actor YAML via agents: fallback when actors: is null
|
||||
When I attempt to add a YAML with actors null and multi-entry agents map
|
||||
Then a spec-yaml ValidationError should be raised containing "single-actor"
|
||||
|
||||
# ── _extract_v2_actor handles actors: key ──────────────────────────
|
||||
|
||||
Scenario: _extract_v2_actor extracts provider/model from actors: map
|
||||
When I call _extract_v2_actor with an actors map containing combined actor field
|
||||
Then the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: _extract_v2_actor extracts from actors: map with separate fields
|
||||
When I call _extract_v2_actor with an actors map containing separate provider model
|
||||
Then the spec-yaml extracted provider should be "anthropic"
|
||||
And the spec-yaml extracted model should be "claude-3"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: _extract_v2_actor prefers actors: key over agents: key
|
||||
When I call _extract_v2_actor with both actors and agents maps
|
||||
Then the spec-yaml extracted provider should be "actors-provider"
|
||||
And the spec-yaml extracted model should be "actors-model"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: _extract_v2_actor graph descriptor contains agent key matching the actor entry name
|
||||
When I call _extract_v2_actor with an actors map containing combined actor field
|
||||
Then the spec-yaml extracted graph descriptor should contain key "agent"
|
||||
And the spec-yaml extracted graph descriptor agent value should be "my_assistant"
|
||||
|
||||
Scenario: _extract_v2_actor with actors map containing unsafe flag
|
||||
When I call _extract_v2_actor with an actors map containing unsafe true
|
||||
Then the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
And the spec-yaml extracted unsafe flag should be True
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: _extract_v2_actor returns None for empty data
|
||||
When I call _extract_v2_actor with an empty dict
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
|
||||
Scenario: _extract_v2_actor returns None for actors key with empty map
|
||||
When I call _extract_v2_actor with actors key containing empty map
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
|
||||
Scenario: _extract_v2_actor returns None for actors key with None value
|
||||
When I call _extract_v2_actor with actors key containing None value
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
|
||||
# ── _extract_v2_actor handles agents: key ─────────────────────────
|
||||
|
||||
Scenario: _extract_v2_actor returns graph descriptor with agents map_key
|
||||
When I call _extract_v2_actor with an agents map containing combined actor field
|
||||
Then the spec-yaml extracted graph descriptor should contain key "agents"
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
|
||||
# ── _extract_v2_actor edge cases: non-dict / missing config ────────
|
||||
|
||||
Scenario: _extract_v2_actor returns None for non-dict first entry
|
||||
When I call _extract_v2_actor with a non-dict first entry in actors map
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
|
||||
Scenario: _extract_v2_actor returns None for dict entry missing config block
|
||||
When I call _extract_v2_actor with a dict entry missing config block
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
|
||||
# ── _extract_v2_actor edge case: actors: {} blocks agents: fallback ─
|
||||
|
||||
Scenario: _extract_v2_actor with empty actors dict blocks agents fallback
|
||||
When I call _extract_v2_actor with empty actors dict and valid agents map
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
|
||||
# ── _extract_v2_actor edge case: actors: [] (list type) ────────────
|
||||
|
||||
Scenario: _extract_v2_actor with actors as list returns None
|
||||
When I call _extract_v2_actor with actors as a list
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted graph descriptor should be None
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
|
||||
# ── _extract_v2_options handles actors: and agents: keys ───────────
|
||||
|
||||
Scenario: _extract_v2_options extracts options from actors: map
|
||||
When I call _extract_v2_options with an actors map containing options
|
||||
Then the spec-yaml extracted options should contain key "temperature" with value 0.7
|
||||
|
||||
Scenario: _extract_v2_options extracts options from agents: map
|
||||
When I call _extract_v2_options with an agents map containing options
|
||||
Then the spec-yaml extracted options should contain key "max_tokens" with value 1024
|
||||
|
||||
Scenario: _extract_v2_options returns None for actors key with empty map
|
||||
When I call _extract_v2_options with actors key containing empty map
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
Scenario: _extract_v2_options returns None for actors key with None value
|
||||
When I call _extract_v2_options with actors key containing None value
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
Scenario: _extract_v2_options returns None for actors key with list value
|
||||
When I call _extract_v2_options with actors key containing list value
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
Scenario: _extract_v2_options returns None when config block has no options key
|
||||
When I call _extract_v2_options with an actors map where config has no options key
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
Scenario: _extract_v2_options returns None for non-dict first entry in actors map
|
||||
When I call _extract_v2_options with a non-dict first entry in actors map
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
Scenario: _extract_v2_options returns None for dict entry missing config block
|
||||
When I call _extract_v2_options with a dict entry missing config block
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
Scenario: _extract_v2_options prefers actors: key over agents: key
|
||||
When I call _extract_v2_options with both actors and agents maps containing options
|
||||
Then the spec-yaml extracted options should contain key "source" with value "actors"
|
||||
|
||||
# ── Unsafe coercion edge cases (unsafe coercion) ────────────────
|
||||
|
||||
Scenario: _extract_v2_actor treats unsafe: "no" as False (not truthy string)
|
||||
When I call _extract_v2_actor with unsafe value "no"
|
||||
Then the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
|
||||
Scenario: _extract_v2_actor treats unsafe: "yes" as False (not truthy string)
|
||||
When I call _extract_v2_actor with unsafe value "yes"
|
||||
Then the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
|
||||
Scenario: _extract_v2_actor treats unsafe: 1 (integer) as True
|
||||
When I call _extract_v2_actor with unsafe value 1
|
||||
Then the spec-yaml extracted unsafe flag should be True
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
|
||||
Scenario: registry.add() accepts actors map with unsafe: 1 (integer) and unsafe flag
|
||||
When I add a YAML with actors map where unsafe is integer 1 and the unsafe flag set
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should be marked unsafe
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
Scenario: _extract_v2_actor treats unsafe: 1.0 (float) as True
|
||||
When I call _extract_v2_actor with unsafe value 1.0
|
||||
Then the spec-yaml extracted unsafe flag should be True
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
|
||||
Scenario: _extract_v2_actor treats unsafe: 2 (integer > 1) as False
|
||||
When I call _extract_v2_actor with unsafe value 2
|
||||
Then the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
|
||||
Scenario: _extract_v2_actor treats unsafe: 0 (integer zero) as False
|
||||
When I call _extract_v2_actor with unsafe value 0
|
||||
Then the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
|
||||
# ── Top-level unsafe: 1 (integer) through registry.add() ────────────
|
||||
|
||||
Scenario: registry.add() rejects top-level unsafe: 1 (integer) without confirmation
|
||||
When I attempt to add a YAML with top-level unsafe integer 1 and no flag
|
||||
Then a spec-yaml ValidationError should be raised containing "unsafe"
|
||||
|
||||
Scenario: registry.add() accepts top-level unsafe: 1 (integer) with unsafe flag
|
||||
When I add a YAML with top-level unsafe integer 1 and the unsafe flag set
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should be marked unsafe
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
# ── Top-level graph_descriptor key through registry.add() (T7) ──────
|
||||
|
||||
Scenario: registry.add() resolves graph descriptor from top-level graph_descriptor key
|
||||
When I add a YAML with top-level provider model and graph_descriptor key
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor graph descriptor should contain key "workflow"
|
||||
|
||||
Scenario: _extract_v2_actor includes top-level routes key in graph descriptor
|
||||
When I call _extract_v2_actor with an actors map and a top-level routes key
|
||||
Then the spec-yaml extracted graph descriptor should contain key "routes"
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
# ── Legacy graph key fallback (M3) ─────────────────────────────────
|
||||
|
||||
Scenario: registry.add() resolves graph descriptor from legacy top-level graph key
|
||||
When I add a YAML with top-level provider model and legacy graph key
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor graph descriptor should contain key "workflow"
|
||||
|
||||
# ── Empty actors map through registry.add() (M4) ───────────────────
|
||||
|
||||
Scenario: registry.add() with empty actors map does not fall back to agents map for provider/model
|
||||
When I attempt to add a YAML with empty actors map and valid agents map but no top-level provider
|
||||
Then a spec-yaml ValidationError should be raised containing "provider"
|
||||
|
||||
# ── provider_type / model_id aliases in nested config (m1) ─────────
|
||||
|
||||
Scenario: _extract_v2_actor extracts provider from provider_type alias in nested config
|
||||
When I call _extract_v2_actor with an actors map using provider_type alias
|
||||
Then the spec-yaml extracted provider should be "alias-provider"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: _extract_v2_actor extracts model from model_id alias in nested config
|
||||
When I call _extract_v2_actor with an actors map using model_id alias
|
||||
Then the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "alias-model"
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
# ── _extract_v2_options with empty dict (m4) ────────────────────────
|
||||
|
||||
Scenario: _extract_v2_options returns None for empty dict input
|
||||
When I call _extract_v2_options with an empty dict
|
||||
Then the spec-yaml extracted options should be None
|
||||
|
||||
# ── compiled_metadata value assertion (m5) ──────────────────────────
|
||||
|
||||
Scenario: registry.add() forwards compiled_metadata with correct values
|
||||
When I add a spec-compliant YAML with schema_version "2.0" and compiled_metadata
|
||||
Then the registered actor compiled metadata key "key" should have value "val"
|
||||
|
||||
# ── Combined actor field edge cases ────────────────────────────────
|
||||
|
||||
Scenario: Combined actor field without slash is ignored
|
||||
When I call _extract_v2_actor with an actors map where actor field has no slash
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
|
||||
Scenario: Combined actor field does not override explicit provider but fills missing model
|
||||
When I call _extract_v2_actor with an actors map where both actor and provider exist
|
||||
Then the spec-yaml extracted provider should be "explicit-provider"
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: Combined actor field does not override explicit model but fills missing provider
|
||||
When I call _extract_v2_actor with an actors map where both actor and model exist
|
||||
Then the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "explicit-model"
|
||||
And the spec-yaml extracted unsafe flag should be False
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
# ── Combined actor field malformed input edge cases ────────────────
|
||||
|
||||
Scenario: Combined actor field with empty provider part yields no provider
|
||||
When I call _extract_v2_actor with an actors map where actor field has empty provider
|
||||
Then the spec-yaml extracted provider should be None
|
||||
And the spec-yaml extracted model should be "gpt-4"
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
Scenario: Combined actor field with empty model part yields no model
|
||||
When I call _extract_v2_actor with an actors map where actor field has empty model
|
||||
Then the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be None
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
# ── Combined actor field with multiple slashes (L1) ────────────────
|
||||
|
||||
Scenario: Combined actor field with multiple slashes splits on first slash only
|
||||
When I call _extract_v2_actor with an actors map where actor field has multiple slashes
|
||||
Then the spec-yaml extracted provider should be "openai"
|
||||
And the spec-yaml extracted model should be "gpt-4/extra"
|
||||
And the spec-yaml extracted graph descriptor should contain key "actors"
|
||||
|
||||
# ── actors: null + valid agents: through registry.add() (L2) ───────
|
||||
|
||||
Scenario: registry.add() with actors: null falls back to valid agents: map
|
||||
When I add a YAML with actors null and valid agents map
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
# ── Top-level provider_type / model_id aliases through registry.add() (T8) ──
|
||||
|
||||
Scenario: registry.add() accepts top-level provider_type alias
|
||||
When I add a YAML with top-level provider_type alias and model
|
||||
Then the actor should be registered with provider "alias-provider" and model "gpt-4"
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
Scenario: registry.add() accepts top-level model_id alias
|
||||
When I add a YAML with top-level provider and model_id alias
|
||||
Then the actor should be registered with provider "openai" and model "alias-model"
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
# ── Top-level unsafe string coercion through registry.add() (T9) ───
|
||||
|
||||
Scenario: registry.add() treats top-level unsafe: "yes" as not unsafe (no gate rejection)
|
||||
When I add a YAML with top-level unsafe string "yes" and provider model
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should not be marked unsafe
|
||||
|
||||
Scenario: registry.add() treats top-level unsafe: "no" as not unsafe (no gate rejection)
|
||||
When I add a YAML with top-level unsafe string "no" and provider model
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor should not be marked unsafe
|
||||
|
||||
# ── Nested options extraction through registry.add() (M1) ──────────
|
||||
|
||||
Scenario: registry.add() extracts and preserves nested config options
|
||||
When I add a spec-compliant YAML with actors map and nested options
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor config blob should contain options key "temperature" with value 0.9
|
||||
And the registered actor config blob should contain options key "max_tokens" with value 2000
|
||||
|
||||
Scenario: registry.add() merges nested and top-level options (nested base, top-level overrides)
|
||||
When I add a YAML with both top-level and nested options
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor config blob should contain options key "temperature" with value 0.7
|
||||
And the registered actor config blob should contain options key "max_tokens" with value 2000
|
||||
And the registered actor config blob should contain options key "top_p" with value 0.95
|
||||
|
||||
Scenario: registry.add() with non-dict top-level options uses nested options
|
||||
When I add a YAML with non-dict top-level options and nested options
|
||||
Then the actor should be registered with provider "openai" and model "gpt-4"
|
||||
And the registered actor config blob should contain options key "temperature" with value 0.9
|
||||
|
||||
Scenario: registry.add() sets source: "yaml" default in config_blob
|
||||
When I add a spec-compliant YAML with actors map and combined actor field
|
||||
Then the registered actor config blob should contain source "yaml"
|
||||
|
||||
# ── _extract_v2_options shallow copy mutation isolation (NIT-2) ─────
|
||||
|
||||
Scenario: _extract_v2_options returns a shallow copy that does not mutate the original blob
|
||||
When I call _extract_v2_options and mutate the returned dict
|
||||
Then the original blob options should be unmodified
|
||||
|
||||
# ── M4: update=True creates actor when it doesn't exist ────────────
|
||||
|
||||
Scenario: registry.add() with update=True creates actor when it doesn't exist
|
||||
@@ -518,25 +142,5 @@ Feature: ActorRegistry.add() accepts spec-compliant actor YAML formats
|
||||
When upsert_actor is configured to raise RuntimeError and I add a valid YAML
|
||||
Then a RuntimeError should have been propagated from add
|
||||
|
||||
# ── M7: actors: false (boolean) blocks agents fallback ─────────────
|
||||
|
||||
|
||||
Scenario: registry.add() with actors: false blocks agents fallback and raises provider error
|
||||
When I attempt to add a YAML with actors false and valid agents map
|
||||
Then a spec-yaml ValidationError should be raised containing "provider"
|
||||
|
||||
# ── M8: non-dict compiled_metadata causes Pydantic error ───────────
|
||||
|
||||
Scenario: registry.add() with non-dict compiled_metadata raises a Pydantic validation error
|
||||
When I attempt to add a valid YAML with compiled_metadata as a non-dict string
|
||||
Then a Pydantic validation error should be raised for compiled_metadata
|
||||
|
||||
# ── M9: provider: 0 (integer zero) falls through to provider_type ──
|
||||
|
||||
Scenario: registry.add() with provider: 0 falls through to provider_type fallback
|
||||
When I attempt to add a YAML with provider integer 0 and no provider_type
|
||||
Then a spec-yaml ValidationError should be raised containing "provider"
|
||||
|
||||
Scenario: registry.add() with provider: 0 and valid provider_type uses provider_type
|
||||
When I add a YAML with provider integer 0 and a valid provider_type
|
||||
Then the actor should be registered with provider "fallback-provider" and model "gpt-4"
|
||||
And the registered actor should exist in the actor service
|
||||
|
||||
@@ -26,12 +26,6 @@ Feature: v3 Actor YAML schema support in CLI and registry
|
||||
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
|
||||
@@ -67,11 +61,6 @@ Feature: v3 Actor YAML schema support in CLI and registry
|
||||
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
|
||||
@@ -174,27 +163,6 @@ Feature: v3 Actor YAML schema support in CLI and registry
|
||||
When I call ActorRegistry.add with a non-mapping YAML string
|
||||
Then a ValidationError should be raised mentioning mapping
|
||||
|
||||
# Coverage: registry.py — _add_legacy v3 schema validation failure
|
||||
Scenario: ActorRegistry._add_legacy rejects invalid v3 YAML via schema validation
|
||||
Given a mock actor service for v3 testing
|
||||
And a legacy v3 YAML text with invalid schema
|
||||
When I call ActorRegistry.add with the legacy invalid v3 YAML
|
||||
Then a ValidationError should be raised mentioning invalid v3 actor
|
||||
|
||||
# Coverage: registry.py — _add_legacy missing provider/model in non-v3 blob
|
||||
Scenario: ActorRegistry._add_legacy rejects non-v3 blob missing provider and model
|
||||
Given a mock actor service for v3 testing
|
||||
And a legacy non-v3 YAML text missing provider and model
|
||||
When I call ActorRegistry.add with the legacy non-v3 YAML
|
||||
Then a ValidationError should be raised mentioning provider and model
|
||||
|
||||
# Coverage: registry.py — _add_legacy unsafe flag rejection
|
||||
Scenario: ActorRegistry._add_legacy rejects unsafe legacy actor without allow_unsafe
|
||||
Given a mock actor service for v3 testing
|
||||
And a legacy actor YAML text marked unsafe
|
||||
When I call ActorRegistry.add with the legacy unsafe YAML
|
||||
Then a ValidationError should be raised mentioning unsafe
|
||||
|
||||
# Coverage: registry.py — upsert_actor v3 validation failure
|
||||
Scenario: ActorRegistry.upsert_actor rejects invalid v3 config_blob
|
||||
Given a mock actor service for v3 testing
|
||||
|
||||
@@ -459,22 +459,6 @@ Feature: Consolidated Actor
|
||||
Then the config options should include the overrides
|
||||
|
||||
|
||||
Scenario: _extract_v2_actor parses v2 agents block
|
||||
When I extract v2 actor from a v2-style config with agents block
|
||||
Then the extracted provider and model should be set
|
||||
And the graph descriptor should contain agent info
|
||||
|
||||
|
||||
Scenario: _extract_v2_actor returns None for missing agents
|
||||
When I extract v2 actor from a config without agents block
|
||||
Then all extracted values should be None
|
||||
|
||||
|
||||
Scenario: _extract_v2_options extracts options from v2 config
|
||||
When I extract v2 options from a v2-style config
|
||||
Then the extracted options should contain expected keys
|
||||
|
||||
|
||||
Scenario: from_file loads and parses config from disk
|
||||
Given a temporary JSON config file with provider and model
|
||||
When I create an ActorConfiguration from the file
|
||||
@@ -876,6 +860,8 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/test-actor
|
||||
type: llm
|
||||
description: A test actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
@@ -888,6 +874,8 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text with schema_version "2.0":
|
||||
"""
|
||||
name: local/versioned
|
||||
type: llm
|
||||
description: A versioned actor
|
||||
provider: anthropic
|
||||
model: claude-3
|
||||
"""
|
||||
@@ -899,6 +887,8 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text with compiled_metadata:
|
||||
"""
|
||||
name: local/compiled
|
||||
type: llm
|
||||
description: A compiled actor
|
||||
provider: openai
|
||||
model: gpt-4o
|
||||
"""
|
||||
@@ -910,12 +900,16 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/updatable
|
||||
type: llm
|
||||
description: An updatable actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
And I update actor from YAML text:
|
||||
"""
|
||||
name: local/updatable
|
||||
type: llm
|
||||
description: An updatable actor
|
||||
provider: openai
|
||||
model: gpt-4-turbo
|
||||
"""
|
||||
@@ -927,6 +921,8 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/removable
|
||||
type: llm
|
||||
description: A removable actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
@@ -939,12 +935,16 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/alpha
|
||||
type: llm
|
||||
description: An alpha actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
And I add actor from YAML text with update:
|
||||
"""
|
||||
name: local/beta
|
||||
type: llm
|
||||
description: A beta actor
|
||||
provider: anthropic
|
||||
model: claude-3
|
||||
"""
|
||||
@@ -957,6 +957,8 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/auto-prefixed
|
||||
type: llm
|
||||
description: An auto-prefixed actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
@@ -968,6 +970,8 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/fetchable
|
||||
type: llm
|
||||
description: A fetchable actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
@@ -978,6 +982,8 @@ Feature: Consolidated Actor
|
||||
Given an actor registry with no configured providers for persistence
|
||||
When I attempt to add actor from YAML text without name:
|
||||
"""
|
||||
type: llm
|
||||
description: No name actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
@@ -989,12 +995,16 @@ Feature: Consolidated Actor
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/duplicate
|
||||
type: llm
|
||||
description: A duplicate actor
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
And I attempt to add duplicate actor from YAML text:
|
||||
"""
|
||||
name: local/duplicate
|
||||
type: llm
|
||||
description: A duplicate actor
|
||||
provider: openai
|
||||
model: gpt-4-turbo
|
||||
"""
|
||||
@@ -1008,17 +1018,7 @@ Feature: Consolidated Actor
|
||||
And the actor "local/legacy-yaml" should have schema_version "1.5"
|
||||
|
||||
|
||||
Scenario: Default schema version is applied when not specified
|
||||
Given an actor registry with no configured providers for persistence
|
||||
When I add actor from YAML text:
|
||||
"""
|
||||
name: local/default-version
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
"""
|
||||
Then the actor "local/default-version" should have schema_version "1.0"
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Originally from: actor_runtime.feature
|
||||
# Feature: Tool-Calling Actor Runtime
|
||||
|
||||
@@ -107,12 +107,12 @@ Feature: Plan explain and decision tree CLI commands
|
||||
# plan tree - json format
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@tdd_issue @tdd_issue_4254 @tdd_expected_fail
|
||||
@tdd_issue @tdd_issue_4254
|
||||
Scenario: Tree with json format
|
||||
Given a set of test decisions forming a tree
|
||||
When I format the tree as json
|
||||
Then the json tree output should be valid json
|
||||
And the json tree output should contain "decision_id"
|
||||
Then the json tree output should be a valid json envelope
|
||||
And the json tree output should contain "command"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# plan tree - yaml format
|
||||
|
||||
@@ -65,13 +65,13 @@ Feature: Plan explain and tree CLI command coverage
|
||||
Given pec a mock DecisionService returning a list of decisions
|
||||
When pec I invoke "tree" with format "json"
|
||||
Then pec the exit code should be 0
|
||||
And pec the output should be valid json list
|
||||
And pec the output should be valid json envelope
|
||||
|
||||
Scenario: Tree CLI renders yaml format
|
||||
Given pec a mock DecisionService returning a list of decisions
|
||||
When pec I invoke "tree" with format "yaml"
|
||||
Then pec the exit code should be 0
|
||||
And pec the output should contain "decision_id:"
|
||||
And pec the output should contain "command:"
|
||||
|
||||
Scenario: Tree CLI renders table format
|
||||
Given pec a mock DecisionService returning a list of decisions
|
||||
|
||||
@@ -17,11 +17,6 @@ from cleveragents.actor.yaml_loader import (
|
||||
interpolate_env_vars,
|
||||
load_yaml_text,
|
||||
)
|
||||
from cleveragents.actor.yaml_loader import (
|
||||
_restore_template_syntax,
|
||||
interpolate_env_vars,
|
||||
load_yaml_text,
|
||||
)
|
||||
|
||||
|
||||
@then('an actor config ValueError should mention "{text}"')
|
||||
@@ -314,76 +309,6 @@ def step_assert_options(context: Context) -> None:
|
||||
assert context.actor_cfg.options["temperature"] == 0.9
|
||||
|
||||
|
||||
@when("I extract v2 actor from a v2-style config with agents block")
|
||||
def step_extract_v2_actor(context: Context) -> None:
|
||||
data: dict[str, Any] = {
|
||||
"agents": {
|
||||
"main_agent": {
|
||||
"config": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-3",
|
||||
"unsafe": True,
|
||||
"options": {"max_tokens": 1000},
|
||||
}
|
||||
}
|
||||
},
|
||||
"routes": [{"from": "main_agent", "to": "end"}],
|
||||
}
|
||||
context.extracted = ActorConfiguration._extract_v2_actor(data)
|
||||
|
||||
|
||||
@then("the extracted provider and model should be set")
|
||||
def step_assert_extracted(context: Context) -> None:
|
||||
provider, model, _graph, unsafe = context.extracted
|
||||
assert provider == "anthropic"
|
||||
assert model == "claude-3"
|
||||
assert unsafe is True
|
||||
|
||||
|
||||
@then("the graph descriptor should contain agent info")
|
||||
def step_assert_graph_descriptor(context: Context) -> None:
|
||||
_, _, graph, _ = context.extracted
|
||||
assert graph is not None
|
||||
assert "agent" in graph
|
||||
assert "routes" in graph
|
||||
|
||||
|
||||
@when("I extract v2 actor from a config without agents block")
|
||||
def step_extract_v2_no_agents(context: Context) -> None:
|
||||
context.extracted = ActorConfiguration._extract_v2_actor({"provider": "openai"})
|
||||
|
||||
|
||||
@then("all extracted values should be None")
|
||||
def step_assert_all_none(context: Context) -> None:
|
||||
provider, model, graph, unsafe = context.extracted
|
||||
assert provider is None
|
||||
assert model is None
|
||||
assert graph is None
|
||||
assert unsafe is False
|
||||
|
||||
|
||||
@when("I extract v2 options from a v2-style config")
|
||||
def step_extract_v2_options(context: Context) -> None:
|
||||
data: dict[str, Any] = {
|
||||
"agents": {
|
||||
"main_agent": {
|
||||
"config": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
"options": {"temperature": 0.7, "max_tokens": 500},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
context.options = ActorConfiguration._extract_v2_options(data)
|
||||
|
||||
|
||||
@then("the extracted options should contain expected keys")
|
||||
def step_assert_extracted_options(context: Context) -> None:
|
||||
assert context.options is not None
|
||||
assert "temperature" in context.options
|
||||
|
||||
|
||||
@when("I create an ActorConfiguration from the file")
|
||||
def step_from_file(context: Context) -> None:
|
||||
context.actor_cfg = ActorConfiguration.from_file(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -525,88 +525,6 @@ def step_validation_error_mapping(context: Any) -> None:
|
||||
assert "mapping" in msg, f"Error should mention 'mapping': {context.add_error}"
|
||||
|
||||
|
||||
@given("a legacy v3 YAML text with invalid schema")
|
||||
def step_legacy_v3_yaml_invalid_schema(context: Any) -> None:
|
||||
# is_v3_yaml detects version starting with "3" or non-null type field.
|
||||
# Provide a blob that triggers the v3 validation path but fails schema.
|
||||
context.legacy_v3_yaml_invalid = yaml.safe_dump(
|
||||
{
|
||||
"name": "local/bad-v3",
|
||||
"version": "3.0",
|
||||
"type": "invalid_type_value", # not llm/graph/tool — fails ActorConfigSchema
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@when("I call ActorRegistry.add with the legacy invalid v3 YAML")
|
||||
def step_call_registry_add_legacy_invalid_v3(context: Any) -> None:
|
||||
try:
|
||||
context.registry.add(context.legacy_v3_yaml_invalid)
|
||||
context.add_error = None
|
||||
except ValidationError as exc:
|
||||
context.add_error = exc
|
||||
|
||||
|
||||
@then("a ValidationError should be raised mentioning invalid v3 actor")
|
||||
def step_validation_error_invalid_v3(context: Any) -> None:
|
||||
assert context.add_error is not None, "Expected a ValidationError to be raised"
|
||||
msg = str(context.add_error).lower()
|
||||
assert "invalid" in msg or "v3" in msg or "validation" in msg, (
|
||||
f"Error should mention invalid v3 actor: {context.add_error}"
|
||||
)
|
||||
|
||||
|
||||
@given("a legacy non-v3 YAML text missing provider and model")
|
||||
def step_legacy_non_v3_yaml_missing_fields(context: Any) -> None:
|
||||
context.legacy_non_v3_yaml = yaml.safe_dump(
|
||||
{
|
||||
"name": "local/no-provider",
|
||||
# no type field → not v3, no provider/model → should fail
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@when("I call ActorRegistry.add with the legacy non-v3 YAML")
|
||||
def step_call_registry_add_legacy_non_v3(context: Any) -> None:
|
||||
try:
|
||||
context.registry.add(context.legacy_non_v3_yaml)
|
||||
context.add_error = None
|
||||
except ValidationError as exc:
|
||||
context.add_error = exc
|
||||
|
||||
|
||||
@then("a ValidationError should be raised mentioning provider and model")
|
||||
def step_validation_error_provider_model(context: Any) -> None:
|
||||
assert context.add_error is not None, "Expected a ValidationError to be raised"
|
||||
msg = str(context.add_error).lower()
|
||||
assert "provider" in msg or "model" in msg, (
|
||||
f"Error should mention 'provider' or 'model': {context.add_error}"
|
||||
)
|
||||
|
||||
|
||||
@given("a legacy actor YAML text marked unsafe")
|
||||
def step_legacy_actor_yaml_unsafe(context: Any) -> None:
|
||||
context.legacy_unsafe_yaml = yaml.safe_dump(
|
||||
{
|
||||
"name": "local/legacy-unsafe",
|
||||
"provider": "openai",
|
||||
"model": "gpt-4",
|
||||
"unsafe": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@when("I call ActorRegistry.add with the legacy unsafe YAML")
|
||||
def step_call_registry_add_legacy_unsafe(context: Any) -> None:
|
||||
try:
|
||||
context.registry.add(context.legacy_unsafe_yaml)
|
||||
context.add_error = None
|
||||
except ValidationError as exc:
|
||||
context.add_error = exc
|
||||
|
||||
|
||||
@when("I call ActorRegistry.upsert_actor with an invalid v3 config_blob")
|
||||
def step_call_registry_upsert_invalid_v3(context: Any) -> None:
|
||||
# Provide a blob that triggers is_v3_yaml() but fails ActorConfigSchema.
|
||||
@@ -629,6 +547,15 @@ def step_call_registry_upsert_invalid_v3(context: Any) -> None:
|
||||
context.add_error = exc
|
||||
|
||||
|
||||
@then("a ValidationError should be raised mentioning invalid v3 actor")
|
||||
def step_validation_error_invalid_v3(context: Any) -> None:
|
||||
assert context.add_error is not None, "Expected a ValidationError to be raised"
|
||||
msg = str(context.add_error).lower()
|
||||
assert "invalid" in msg or "v3" in msg or "validation" in msg, (
|
||||
f"Error should mention invalid v3 actor: {context.add_error}"
|
||||
)
|
||||
|
||||
|
||||
# ── Coverage gap: config_parser.py ───────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -93,22 +93,6 @@ def step_v3_graph_blob(context: Any) -> None:
|
||||
}
|
||||
|
||||
|
||||
@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()
|
||||
@@ -247,13 +231,6 @@ def step_v3_graph_config_dict(context: Any) -> None:
|
||||
}
|
||||
|
||||
|
||||
@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."""
|
||||
@@ -274,14 +251,6 @@ def step_call_from_blob_v3(context: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
@@ -329,11 +298,6 @@ def step_parse_v3_config(context: Any) -> None:
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -470,11 +434,6 @@ def step_reactive_has_graph_route(context: Any) -> None:
|
||||
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
|
||||
|
||||
@@ -807,6 +807,22 @@ def step_pec_output_valid_json_list(context: Context) -> None:
|
||||
assert isinstance(data, list), "Expected JSON array"
|
||||
|
||||
|
||||
@then("pec the output should be valid json envelope")
|
||||
def step_pec_output_valid_json_envelope(context: Context) -> None:
|
||||
parsed = json.loads(context.pec_result.output.strip())
|
||||
assert isinstance(parsed, dict), f"Expected JSON object, got {type(parsed)}"
|
||||
assert _ENVELOPE_KEYS.issubset(parsed.keys()), (
|
||||
f"Expected envelope keys {_ENVELOPE_KEYS}, got {set(parsed.keys())}"
|
||||
)
|
||||
data = parsed["data"]
|
||||
assert isinstance(data, dict), (
|
||||
f"Expected envelope data to be a dict, got {type(data)}"
|
||||
)
|
||||
assert "plan_id" in data, (
|
||||
f"Expected 'plan_id' in envelope data, got {set(data.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then("pec the tree should exclude the superseded grandchild")
|
||||
def step_pec_tree_excludes_orphan(context: Context) -> None:
|
||||
# Tree should have exactly one root with one child and zero grandchildren.
|
||||
|
||||
@@ -405,6 +405,18 @@ def step_tree_json_valid(context: Context) -> None:
|
||||
assert isinstance(parsed, list), "Expected a JSON array"
|
||||
|
||||
|
||||
@then("the json tree output should be a valid json envelope")
|
||||
def step_tree_json_valid_envelope(context: Context) -> None:
|
||||
parsed = json.loads(context.pe_tree_json)
|
||||
assert isinstance(parsed, dict), (
|
||||
f"Expected a JSON object (envelope), got {type(parsed)}"
|
||||
)
|
||||
_envelope_keys = {"command", "status", "exit_code", "data", "timing", "messages"}
|
||||
assert _envelope_keys.issubset(parsed.keys()), (
|
||||
f"Expected envelope keys {_envelope_keys}, got {set(parsed.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then('the json tree output should contain "{text}"')
|
||||
def step_tree_json_contains(context: Context, text: str) -> None:
|
||||
assert text in context.pe_tree_json, f"Expected '{text}' in tree JSON"
|
||||
|
||||
@@ -4,40 +4,6 @@ Library Collections
|
||||
Library Process
|
||||
|
||||
*** Test Cases ***
|
||||
V2 Actor Config Produces Provider And Graph Descriptor
|
||||
${config}= Set Variable ${OUTPUT DIR}/v2_actor.yaml
|
||||
${content}= Catenate SEPARATOR=\n
|
||||
... cleveragents:
|
||||
... ${SPACE*2}default_router: main_router
|
||||
... agents:
|
||||
... ${SPACE*2}paper_writer:
|
||||
... ${SPACE*4}type: llm
|
||||
... ${SPACE*4}config:
|
||||
... ${SPACE*6}provider: openai
|
||||
... ${SPACE*6}model: gpt-4
|
||||
... ${SPACE*6}unsafe: true
|
||||
... ${SPACE*6}options:
|
||||
... ${SPACE*8}temperature: 0.5
|
||||
... routes:
|
||||
... ${SPACE*2}main_router:
|
||||
... ${SPACE*4}type: stream
|
||||
... ${SPACE*4}operators:
|
||||
... ${SPACE*6}- type: map
|
||||
... ${SPACE*8}params:
|
||||
... ${SPACE*10}agent: paper_writer
|
||||
... ${SPACE*4}publications:
|
||||
... ${SPACE*6}- __output__
|
||||
Create File ${config} ${content}
|
||||
${result}= Run Process ${PYTHON} robot/helper_actor_config.py ${config}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
${payload}= Evaluate __import__('json').loads('''${result.stdout.strip()}''')
|
||||
Should Be Equal ${payload['provider']} openai
|
||||
Should Be Equal ${payload['model']} gpt-4
|
||||
Should Be True ${payload['unsafe']}
|
||||
List Should Contain Value ${payload['graph_keys']} agents
|
||||
List Should Contain Value ${payload['graph_keys']} routes
|
||||
Should Be Equal As Numbers ${payload['options']['temperature']} 0.5
|
||||
|
||||
Missing Config File Exits With Non-Zero Code And Stderr Message
|
||||
[Tags] tdd_issue tdd_issue_4202 tdd_expected_fail
|
||||
${missing}= Set Variable ${OUTPUT DIR}/does_not_exist_actor.yaml
|
||||
|
||||
@@ -386,11 +386,24 @@ M6 E2E Hierarchical Decomposition Via Plan Tree
|
||||
# P0-4: Hard assertion — tree command must succeed.
|
||||
Should Be Equal As Integers ${tree.rc} 0 msg=plan tree failed (rc=${tree.rc}): ${tree.stderr}
|
||||
Should Not Be Empty ${tree.stdout} Plan tree output should not be empty
|
||||
# P0-4: Hard assertion — at least one decision node must exist after execution.
|
||||
${decision_count}= Evaluate $tree.stdout.count('"decision_id"')
|
||||
Log Decision tree contains ${decision_count} decision node(s)
|
||||
Should Be True ${decision_count} >= 1
|
||||
... Plan tree should contain at least one decision node after execution (found ${decision_count})
|
||||
# P0-4: Hard assertion — tree output must contain the spec-required envelope.
|
||||
# The new envelope format wraps the tree in a command envelope with
|
||||
# "command", "status", "exit_code", "data", "timing", "messages" keys.
|
||||
# The "data" field contains "plan_id", "tree", "summary", "child_plans",
|
||||
# and "decision_ids" (a mapping of human-readable keys to decision ULIDs).
|
||||
${has_command_key}= Evaluate '"command"' in $tree.stdout
|
||||
Should Be True ${has_command_key}
|
||||
... Plan tree JSON output should contain spec-required envelope "command" key
|
||||
${has_data_key}= Evaluate '"data"' in $tree.stdout
|
||||
Should Be True ${has_data_key}
|
||||
... Plan tree JSON output should contain spec-required envelope "data" key
|
||||
${has_plan_id_key}= Evaluate '"plan_id"' in $tree.stdout
|
||||
Should Be True ${has_plan_id_key}
|
||||
... Plan tree JSON output should contain "plan_id" in envelope data
|
||||
# Check for decision_ids mapping (proves at least one decision was recorded)
|
||||
${has_decision_ids}= Evaluate '"decision_ids"' in $tree.stdout
|
||||
Should Be True ${has_decision_ids}
|
||||
... Plan tree should contain decision_ids mapping after execution
|
||||
# Check for hierarchical children (decomposition infrastructure indicator)
|
||||
${has_children_key}= Evaluate '"children"' in $tree.stdout
|
||||
IF ${has_children_key}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -42,7 +42,7 @@ class ActorConfiguration(BaseModel):
|
||||
|
||||
@staticmethod
|
||||
def load_blob_from_file(config_path: Path) -> dict[str, Any]:
|
||||
"""Load a configuration blob from a JSON or YAML file (v2 parity)."""
|
||||
"""Load a configuration blob from a JSON or YAML file."""
|
||||
path = config_path.expanduser()
|
||||
if not path.exists():
|
||||
raise ValueError(f"Config file not found: {path}")
|
||||
@@ -88,36 +88,25 @@ class ActorConfiguration(BaseModel):
|
||||
) -> ActorConfiguration:
|
||||
"""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``).
|
||||
Supports the v3 ``ActorConfigSchema`` format (identified by a top-level
|
||||
``type`` key whose value is one of ``llm``, ``graph``, or ``tool``).
|
||||
Provider and model may be at the top level OR nested inside
|
||||
``actors.<actor-name>.config``.
|
||||
"""
|
||||
|
||||
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 v3_provider
|
||||
or v2_provider
|
||||
)
|
||||
resolved_model = (
|
||||
model or data.get("model") or data.get("model_id") or v3_model or v2_model
|
||||
provider or data.get("provider") or data.get("provider_type") or v3_provider
|
||||
)
|
||||
resolved_model = model or data.get("model") or data.get("model_id") or v3_model
|
||||
resolved_graph = (
|
||||
graph_descriptor
|
||||
or data.get("graph_descriptor")
|
||||
or data.get("graph")
|
||||
or v3_graph
|
||||
or v2_graph
|
||||
)
|
||||
|
||||
options_raw = data.get("options")
|
||||
@@ -127,8 +116,6 @@ class ActorConfiguration(BaseModel):
|
||||
merged_options: dict[str, Any] = {}
|
||||
if default_options:
|
||||
merged_options.update(default_options)
|
||||
if v2_options:
|
||||
merged_options.update(v2_options)
|
||||
if options_value:
|
||||
merged_options.update(options_value)
|
||||
if option_overrides:
|
||||
@@ -136,10 +123,7 @@ class ActorConfiguration(BaseModel):
|
||||
|
||||
top_unsafe_raw = data.get("unsafe", False)
|
||||
resolved_unsafe = (
|
||||
(top_unsafe_raw is True or top_unsafe_raw == 1)
|
||||
or v3_unsafe
|
||||
or v2_unsafe
|
||||
or unsafe
|
||||
(top_unsafe_raw is True or top_unsafe_raw == 1) or v3_unsafe or unsafe
|
||||
)
|
||||
|
||||
if not resolved_provider:
|
||||
@@ -167,27 +151,6 @@ class ActorConfiguration(BaseModel):
|
||||
unsafe=resolved_unsafe,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@staticmethod
|
||||
def _resolve_actors_map(
|
||||
data: dict[str, Any],
|
||||
) -> tuple[Any, Literal["actors", "agents"]]:
|
||||
"""Resolve the actors/agents map from a parsed YAML blob.
|
||||
|
||||
The spec allows either ``actors:`` or ``agents:`` as the top-level
|
||||
map key (oneOf). ``actors:`` is preferred (spec-canonical); the
|
||||
``agents:`` key is only consulted when ``actors:`` is absent or
|
||||
explicitly ``null``.
|
||||
|
||||
Returns ``(map_obj, map_key)`` where *map_obj* is the raw value
|
||||
(may be ``None``, a non-dict, or a dict) and *map_key* is the
|
||||
string ``"actors"`` or ``"agents"``.
|
||||
"""
|
||||
actors_val = data.get("actors")
|
||||
if actors_val is not None:
|
||||
return actors_val, "actors"
|
||||
return data.get("agents"), "agents"
|
||||
|
||||
@staticmethod
|
||||
def _extract_v3_actor(
|
||||
data: dict[str, Any],
|
||||
@@ -195,13 +158,13 @@ class ActorConfiguration(BaseModel):
|
||||
"""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:
|
||||
is one of ``llm``, ``graph``, or ``tool`` — OR by a top-level
|
||||
``actors:`` map containing at least one entry whose ``config:`` block
|
||||
carries a ``type`` field with one of those values.
|
||||
|
||||
* If the model contains ``/`` the prefix is used as provider
|
||||
(e.g. ``openai/gpt-4`` → provider ``openai``).
|
||||
* Otherwise ``"custom"`` is used as a fallback.
|
||||
Provider and model may appear at the top level OR nested inside
|
||||
``actors.<actor-name>.config``. When found in the nested config,
|
||||
they take precedence over top-level values.
|
||||
|
||||
For ``type: graph`` actors the ``route`` block is wrapped into a
|
||||
graph descriptor.
|
||||
@@ -211,28 +174,65 @@ class ActorConfiguration(BaseModel):
|
||||
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
|
||||
if not isinstance(actor_type, str) or actor_type.lower() not in _V3_ACTOR_TYPES:
|
||||
actors_val = data.get("actors")
|
||||
if isinstance(actors_val, dict) and actors_val:
|
||||
for _, first_entry in actors_val.items():
|
||||
if isinstance(first_entry, dict):
|
||||
config_block = first_entry.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
nested_type = config_block.get("type")
|
||||
is_v3_type = (
|
||||
isinstance(nested_type, str)
|
||||
and nested_type.lower() in _V3_ACTOR_TYPES
|
||||
)
|
||||
if is_v3_type:
|
||||
actor_type = nested_type
|
||||
break
|
||||
break
|
||||
if not isinstance(actor_type, str):
|
||||
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".
|
||||
provider_value = (
|
||||
infer_provider_from_model(model_value) if model_value else "custom"
|
||||
)
|
||||
|
||||
provider_value = data.get("provider")
|
||||
unsafe_flag = bool(data.get("unsafe", False))
|
||||
|
||||
# Build a graph descriptor for graph actors from the route block.
|
||||
if not model_value or not isinstance(model_value, str):
|
||||
if actor_type.lower() != "tool":
|
||||
actors_val = data.get("actors")
|
||||
if isinstance(actors_val, dict) and actors_val:
|
||||
for _, first_entry in actors_val.items():
|
||||
if isinstance(first_entry, dict):
|
||||
config_block = first_entry.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
mv = config_block.get("model")
|
||||
if isinstance(mv, str) and mv:
|
||||
model_value = mv
|
||||
break
|
||||
break
|
||||
if not model_value:
|
||||
if actor_type.lower() != "tool":
|
||||
return None, None, None, False
|
||||
model_value = ""
|
||||
|
||||
if not provider_value or not isinstance(provider_value, str):
|
||||
actors_val = data.get("actors")
|
||||
if isinstance(actors_val, dict) and actors_val:
|
||||
for _, first_entry in actors_val.items():
|
||||
if isinstance(first_entry, dict):
|
||||
config_block = first_entry.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
pv = config_block.get("provider")
|
||||
if isinstance(pv, str) and pv:
|
||||
provider_value = pv
|
||||
break
|
||||
break
|
||||
|
||||
if not provider_value:
|
||||
provider_value = (
|
||||
infer_provider_from_model(model_value) if model_value else "custom"
|
||||
)
|
||||
|
||||
graph_descriptor: dict[str, Any] | None = None
|
||||
if actor_type.lower() == "graph":
|
||||
route_raw = data.get("route")
|
||||
@@ -244,127 +244,3 @@ class ActorConfiguration(BaseModel):
|
||||
}
|
||||
|
||||
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/spec YAML actor config format.
|
||||
|
||||
Supports both the legacy ``agents:`` map key and the spec-compliant
|
||||
``actors:`` map key. Within each actor's ``config:`` block, the
|
||||
combined ``actor`` field (e.g. ``"openai/gpt-4"``) is also accepted
|
||||
as an alternative to separate ``provider`` + ``model`` fields.
|
||||
"""
|
||||
|
||||
map_obj, map_key = ActorConfiguration._resolve_actors_map(data)
|
||||
|
||||
# NOTE: Any non-None value for ``actors:`` — including falsy values
|
||||
# such as ``actors: {}``, ``actors: false``, ``actors: 0``, or
|
||||
# ``actors: ""`` — blocks the ``agents:`` fallback. This is
|
||||
# intentional: a present ``actors:`` key explicitly declares the
|
||||
# actors map (even if empty or invalid), whereas ``actors: null``
|
||||
# (or absent) defers to ``agents:``.
|
||||
#
|
||||
# NOTE: Only the **first** entry in the actors/agents map is inspected
|
||||
# for provider, model, unsafe, and graph descriptor. The ``add()``
|
||||
# method rejects multi-actor YAML (>1 entry) with a ``ValidationError``
|
||||
# to prevent silent data loss. The multi-actor case is handled by the
|
||||
# v3 schema validation path.
|
||||
if isinstance(map_obj, dict) and map_obj:
|
||||
map_dict = cast(dict[str, Any], map_obj)
|
||||
# Only the first actor entry is used for provider/model extraction.
|
||||
for actor_key, first_entry in map_dict.items():
|
||||
if isinstance(first_entry, dict):
|
||||
entry_dict = cast(dict[str, Any], first_entry)
|
||||
config_block = entry_dict.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
config_dict = cast(dict[str, Any], config_block)
|
||||
|
||||
# Support the combined ``actor`` field format
|
||||
# (e.g. ``"openai/gpt-4"`` → provider + model).
|
||||
# NOTE: ``provider_type`` and ``model_id`` are
|
||||
# accepted as aliases for backward compatibility,
|
||||
# even though the spec's JSON schema for the
|
||||
# ``config:`` block only lists ``provider``,
|
||||
# ``model``, and ``actor``.
|
||||
# Use ``is not None`` (not ``or``) so that falsy
|
||||
# values like ``""`` or ``0`` are preserved rather
|
||||
# than falling through to the alias.
|
||||
provider_value = config_dict.get("provider")
|
||||
if provider_value is None:
|
||||
provider_value = config_dict.get("provider_type")
|
||||
model_value = config_dict.get("model")
|
||||
if model_value is None:
|
||||
model_value = config_dict.get("model_id")
|
||||
|
||||
combined_actor = config_dict.get("actor")
|
||||
if (
|
||||
combined_actor
|
||||
and isinstance(combined_actor, str)
|
||||
and "/" in combined_actor
|
||||
):
|
||||
parts = combined_actor.split("/", 1)
|
||||
if not provider_value:
|
||||
provider_value = parts[0]
|
||||
if not model_value:
|
||||
model_value = parts[1]
|
||||
|
||||
unsafe_raw = config_dict.get("unsafe", False)
|
||||
# Accept only boolean ``True`` or integer ``1`` as
|
||||
# unsafe. Note: ``unsafe_raw == 1`` also matches
|
||||
# ``1.0`` (float) due to Python's numeric equality
|
||||
# rules — this is fail-safe (treats 1.0 as unsafe).
|
||||
unsafe_flag = unsafe_raw is True or unsafe_raw == 1
|
||||
# The graph descriptor is always built and returned
|
||||
# when a valid ``config:`` block is found, regardless
|
||||
# of whether ``provider``/``model`` were extracted.
|
||||
# This allows the caller to preserve the graph
|
||||
# structure even when provider/model come from
|
||||
# elsewhere (e.g. top-level fields).
|
||||
descriptor: dict[str, Any] = {
|
||||
"agent": actor_key,
|
||||
map_key: dict(map_dict),
|
||||
}
|
||||
for key in (
|
||||
"routes",
|
||||
"merges",
|
||||
"templates",
|
||||
"cleveragents",
|
||||
):
|
||||
if key in data and data[key] is not None:
|
||||
descriptor[key] = data[key]
|
||||
return (
|
||||
str(provider_value) if provider_value else None,
|
||||
str(model_value) if model_value else None,
|
||||
descriptor,
|
||||
unsafe_flag,
|
||||
)
|
||||
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/spec actor config format.
|
||||
|
||||
Supports both ``actors:`` (spec-canonical) and ``agents:`` (legacy)
|
||||
map keys.
|
||||
"""
|
||||
|
||||
map_obj, _ = ActorConfiguration._resolve_actors_map(data)
|
||||
if isinstance(map_obj, dict) and map_obj:
|
||||
map_dict = cast(dict[str, Any], map_obj)
|
||||
for _, first_entry in map_dict.items():
|
||||
if isinstance(first_entry, dict):
|
||||
entry_dict = cast(dict[str, Any], first_entry)
|
||||
config_block = entry_dict.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
config_dict = cast(dict[str, Any], config_block)
|
||||
options = config_dict.get("options")
|
||||
if isinstance(options, dict):
|
||||
# Return a shallow copy so downstream mutations
|
||||
# (e.g. ``config_blob["options"]`` updates) do
|
||||
# not propagate back into the original blob.
|
||||
return dict(cast(dict[str, Any], options))
|
||||
break # first entry only
|
||||
return None
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
"""Legacy (v2) actor registration logic extracted from ``ActorRegistry``.
|
||||
|
||||
This module handles the v2 blob registration path, including provider/model
|
||||
extraction, unsafe flag enforcement, and nested option merging. It is used
|
||||
by :class:`ActorRegistry` to keep the main registry module under the 500-line
|
||||
limit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pydantic
|
||||
|
||||
from cleveragents.actor.config import ActorConfiguration
|
||||
from cleveragents.actor.schema import ActorConfigSchema, is_v3_yaml
|
||||
from cleveragents.application.services.actor_service import ActorService
|
||||
from cleveragents.core.exceptions import NotFoundError, ValidationError
|
||||
from cleveragents.domain.models.core import Actor
|
||||
|
||||
|
||||
def add_legacy(
|
||||
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_service: ActorService,
|
||||
ensure_namespaced: callable, # type: ignore[type-arg]
|
||||
) -> Actor:
|
||||
"""Register an actor from a legacy (v2) blob.
|
||||
|
||||
Args:
|
||||
blob: Parsed YAML blob.
|
||||
yaml_text: Original YAML source text.
|
||||
update: When ``True`` allow overwriting an existing actor.
|
||||
schema_version: Schema version string.
|
||||
compiled_metadata: Optional compiler-produced metadata dict.
|
||||
unsafe: Asserts that the actor IS unsafe.
|
||||
allow_unsafe: Permits registration of an already-unsafe actor.
|
||||
actor_service: The actor persistence service.
|
||||
ensure_namespaced: Function to namespace actor names.
|
||||
|
||||
Returns:
|
||||
The persisted ``Actor`` domain object.
|
||||
"""
|
||||
name_raw: str = blob.get("name", "")
|
||||
if not name_raw:
|
||||
raise ValidationError("Actor YAML must include a 'name' field.")
|
||||
|
||||
# ── Validate v3 YAML via ActorConfigSchema if detected ──────────────────
|
||||
# This ensures v3 actors are validated against the full schema, including
|
||||
# cycle detection, required fields, and enum validation.
|
||||
blob_is_v3 = is_v3_yaml(blob)
|
||||
if blob_is_v3:
|
||||
try:
|
||||
ActorConfigSchema.model_validate(blob)
|
||||
except pydantic.ValidationError as exc:
|
||||
raise ValidationError(f"Invalid v3 actor configuration: {exc}") from exc
|
||||
|
||||
name = ensure_namespaced(name_raw)
|
||||
provider_raw = blob.get("provider") or blob.get("provider_type", "")
|
||||
model_raw = blob.get("model") or blob.get("model_id", "")
|
||||
|
||||
# ── Guard: reject multi-actor YAML ─────────────────────────────────
|
||||
# ``add()`` is designed for single-actor YAML files. Provider, model,
|
||||
# unsafe, and graph extraction all operate on the *first* entry only,
|
||||
# so a multi-actor map would silently discard later entries (including
|
||||
# their ``unsafe`` flags — a spec-compliance and security concern).
|
||||
# Multi-actor configurations must be split and registered individually.
|
||||
# Uses ``_resolve_actors_map()`` for consistent ``actors:``/``agents:``
|
||||
# resolution across all call sites.
|
||||
actors_map, _ = ActorConfiguration._resolve_actors_map(blob)
|
||||
if isinstance(actors_map, dict) and len(actors_map) > 1:
|
||||
raise ValidationError(
|
||||
"registry.add() only supports single-actor YAML files. "
|
||||
"Multi-actor configurations must be registered individually."
|
||||
)
|
||||
|
||||
# Always extract from the nested ``actors:``/``agents:`` map so that
|
||||
# ``unsafe`` and ``graph_descriptor`` are captured even when top-level
|
||||
# ``provider``/``model`` are present. This matches the behaviour of
|
||||
# ``from_blob()`` which unconditionally calls ``_extract_v2_actor()``.
|
||||
nested_provider, nested_model, nested_graph, nested_unsafe = (
|
||||
ActorConfiguration._extract_v2_actor(blob)
|
||||
)
|
||||
if not provider_raw and nested_provider:
|
||||
provider_raw = nested_provider
|
||||
if not model_raw and nested_model:
|
||||
model_raw = nested_model
|
||||
|
||||
if not blob_is_v3 and (not provider_raw or not model_raw):
|
||||
raise ValidationError("Actor YAML must include 'provider' and 'model' fields.")
|
||||
|
||||
# For v3 actors without provider/model (e.g. TOOL actors), provider_raw
|
||||
# and model_raw may be empty strings here. This is expected — the legacy
|
||||
# canonicalization path in upsert_actor() / from_blob() will reject
|
||||
# provider-less TOOL actors. See follow-up issue #9971.
|
||||
provider = str(provider_raw) if provider_raw else ""
|
||||
model = str(model_raw) if model_raw else ""
|
||||
|
||||
top_unsafe_raw = blob.get("unsafe", False)
|
||||
# Accept only boolean ``True`` or integer ``1`` as unsafe. Note:
|
||||
# ``top_unsafe_raw == 1`` also matches ``1.0`` (float) due to
|
||||
# Python's numeric equality rules — this is fail-safe (treats 1.0
|
||||
# as unsafe).
|
||||
#
|
||||
# ``unsafe`` (the parameter) is an *assertion* — "this actor IS
|
||||
# unsafe" (maps to the spec's "CLI --unsafe flag").
|
||||
# ``allow_unsafe`` is a *permission* — "I PERMIT an already-unsafe
|
||||
# actor to be registered" but does NOT itself make the actor unsafe.
|
||||
# Therefore only ``unsafe`` participates in the stored value; the
|
||||
# spec rule is: "the result is true if *any* of: top-level unsafe,
|
||||
# nested unsafe, or the CLI --unsafe flag is true."
|
||||
effective_unsafe = (
|
||||
(top_unsafe_raw is True or top_unsafe_raw == 1) or nested_unsafe or unsafe
|
||||
)
|
||||
if effective_unsafe and not (unsafe or allow_unsafe):
|
||||
raise ValidationError(
|
||||
"Actor configuration is marked unsafe; pass unsafe=True "
|
||||
"or allow_unsafe=True to confirm."
|
||||
)
|
||||
if not update:
|
||||
try:
|
||||
actor_service.get_actor(name)
|
||||
except NotFoundError:
|
||||
pass # Actor does not exist yet — proceed with add
|
||||
else:
|
||||
raise ValidationError(
|
||||
f"Actor '{name}' already exists. Pass update=True to overwrite."
|
||||
)
|
||||
|
||||
# ── Extract nested options so they are not silently discarded ─────
|
||||
# ``from_blob()`` calls ``_extract_v2_options()`` internally, but
|
||||
# ``add()`` bypasses ``from_blob()``. Without this call, options
|
||||
# nested inside ``actors.<name>.config.options`` would be lost.
|
||||
v2_options = ActorConfiguration._extract_v2_options(blob)
|
||||
|
||||
config_blob: dict[str, Any] = dict(blob)
|
||||
config_blob.setdefault("source", "yaml")
|
||||
if v2_options is not None:
|
||||
existing_options = config_blob.get("options")
|
||||
if existing_options is None or not isinstance(existing_options, dict):
|
||||
# No valid top-level options dict — use nested options directly.
|
||||
config_blob["options"] = v2_options
|
||||
else:
|
||||
# Both top-level and nested options exist. Match ``from_blob()``
|
||||
# merge semantics: nested options as base, top-level overrides.
|
||||
merged = dict(v2_options)
|
||||
merged.update(existing_options)
|
||||
config_blob["options"] = merged
|
||||
|
||||
top_graph_raw = blob.get("graph_descriptor")
|
||||
top_graph = top_graph_raw if top_graph_raw is not None else blob.get("graph")
|
||||
resolved_graph = top_graph if top_graph is not None else nested_graph
|
||||
|
||||
return actor_service.upsert_actor(
|
||||
name=name,
|
||||
provider=provider,
|
||||
model=model,
|
||||
config_blob=config_blob,
|
||||
graph_descriptor=resolved_graph,
|
||||
unsafe=effective_unsafe,
|
||||
set_default=False,
|
||||
yaml_text=yaml_text,
|
||||
schema_version=schema_version,
|
||||
compiled_metadata=compiled_metadata,
|
||||
)
|
||||
@@ -26,7 +26,6 @@ import pydantic
|
||||
import yaml
|
||||
|
||||
from cleveragents.actor.config import ActorConfiguration
|
||||
from cleveragents.actor.legacy_registry import add_legacy
|
||||
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
|
||||
@@ -326,6 +325,7 @@ class ActorRegistry:
|
||||
# v3 path: validate against ActorConfigSchema when detected
|
||||
# ----------------------------------------------------------
|
||||
if is_v3_blob(blob):
|
||||
effective_allow_unsafe = allow_unsafe or unsafe
|
||||
return add_v3(
|
||||
blob,
|
||||
yaml_text=yaml_text,
|
||||
@@ -333,22 +333,12 @@ class ActorRegistry:
|
||||
schema_version=version,
|
||||
compiled_metadata=compiled_metadata,
|
||||
actor_service=self._actor_service,
|
||||
allow_unsafe=allow_unsafe,
|
||||
allow_unsafe=effective_allow_unsafe,
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Legacy (v2) path: flat provider/model extraction
|
||||
# ----------------------------------------------------------
|
||||
return add_legacy(
|
||||
blob,
|
||||
yaml_text=yaml_text,
|
||||
update=update,
|
||||
schema_version=version,
|
||||
compiled_metadata=compiled_metadata,
|
||||
unsafe=unsafe,
|
||||
allow_unsafe=allow_unsafe,
|
||||
actor_service=self._actor_service,
|
||||
ensure_namespaced=self._ensure_namespaced,
|
||||
raise ValidationError(
|
||||
"Invalid actor configuration: no valid type field found. "
|
||||
"Expected a v3 YAML with type: llm, type: graph, or type: tool."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -26,9 +26,111 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_v3_blob(blob: dict[str, Any]) -> bool:
|
||||
"""Return ``True`` when *blob* looks like a v3 ``ActorConfigSchema``."""
|
||||
"""Return ``True`` when *blob* looks like a v3 ``ActorConfigSchema``.
|
||||
|
||||
A blob is considered v3 if it has:
|
||||
1. A top-level ``type`` field with a v3 value (llm/graph/tool), OR
|
||||
2. An ``actors:`` map containing at least one entry whose ``type`` field
|
||||
has a v3 value (nested actor format per spec line 21417).
|
||||
|
||||
The nested format allows configurations like:
|
||||
|
||||
name: local/my-actor
|
||||
actors:
|
||||
my_actor:
|
||||
type: llm
|
||||
config:
|
||||
provider: anthropic
|
||||
model: claude-3.5-sonnet
|
||||
|
||||
Returns:
|
||||
True if the blob appears to be v3 format.
|
||||
"""
|
||||
actor_type = blob.get("type")
|
||||
return isinstance(actor_type, str) and actor_type.lower() in _V3_ACTOR_TYPES
|
||||
if isinstance(actor_type, str) and actor_type.lower() in _V3_ACTOR_TYPES:
|
||||
return True
|
||||
|
||||
actors_val = blob.get("actors")
|
||||
if isinstance(actors_val, dict) and actors_val:
|
||||
for _, entry in actors_val.items():
|
||||
if isinstance(entry, dict):
|
||||
nested_type = entry.get("type")
|
||||
if (
|
||||
isinstance(nested_type, str)
|
||||
and nested_type.lower() in _V3_ACTOR_TYPES
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _extract_nested_v3_config(data: dict[str, Any]) -> None:
|
||||
"""Extract provider/model/type/description/name from nested actors.<name> block.
|
||||
|
||||
Per spec line 21417, v3 YAML may have provider/model at the top level
|
||||
OR nested inside ``actors.<actor-name>.config``. When found in the nested
|
||||
config, values are promoted to the top level.
|
||||
|
||||
Similarly, ``type``, ``description``, and ``name`` may be at the top level
|
||||
OR nested inside ``actors.<actor-name>`` (for type/description) or
|
||||
``actors.<actor-name>.config`` (for name). This function promotes all
|
||||
of these from their nested locations when not present at the top level.
|
||||
|
||||
Args:
|
||||
data: The actor blob (modified in place).
|
||||
"""
|
||||
actors_val = data.get("actors")
|
||||
if not isinstance(actors_val, dict) or not actors_val:
|
||||
return
|
||||
|
||||
for _, entry in actors_val.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
if "type" not in data:
|
||||
type_val = entry.get("type")
|
||||
if isinstance(type_val, str) and type_val:
|
||||
data["type"] = type_val
|
||||
|
||||
if "description" not in data:
|
||||
desc_val = entry.get("description")
|
||||
if isinstance(desc_val, str) and desc_val:
|
||||
data["description"] = desc_val
|
||||
|
||||
if "name" not in data:
|
||||
name_val = entry.get("name")
|
||||
if isinstance(name_val, str) and name_val:
|
||||
data["name"] = name_val
|
||||
|
||||
config_block = entry.get("config")
|
||||
if isinstance(config_block, dict):
|
||||
if "model" not in data:
|
||||
mv = config_block.get("model")
|
||||
if isinstance(mv, str) and mv:
|
||||
data["model"] = mv
|
||||
|
||||
if "provider" not in data:
|
||||
pv = config_block.get("provider")
|
||||
if isinstance(pv, str) and pv:
|
||||
data["provider"] = pv
|
||||
|
||||
if "name" not in data:
|
||||
name_val = config_block.get("name")
|
||||
if isinstance(name_val, str) and name_val:
|
||||
data["name"] = name_val
|
||||
|
||||
if "description" not in data:
|
||||
desc_val = config_block.get("description")
|
||||
if isinstance(desc_val, str) and desc_val:
|
||||
data["description"] = desc_val
|
||||
|
||||
if (
|
||||
"type" in data
|
||||
and "description" in data
|
||||
and "model" in data
|
||||
and "provider" in data
|
||||
):
|
||||
break
|
||||
|
||||
|
||||
def add_v3(
|
||||
@@ -73,6 +175,10 @@ def add_v3(
|
||||
if isinstance(name_raw, str) and name_raw and "/" not in name_raw:
|
||||
data["name"] = f"local/{name_raw}"
|
||||
|
||||
# Extract provider and model from nested actors.<name>.config block if present.
|
||||
# This handles the v3 nested format per spec line 21417.
|
||||
_extract_nested_v3_config(data)
|
||||
|
||||
# Infer provider before schema validation — the schema now requires
|
||||
# provider for LLM and GRAPH actors (added by #5869).
|
||||
if not data.get("provider"):
|
||||
|
||||
@@ -27,7 +27,7 @@ import re
|
||||
import shutil
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
|
||||
|
||||
@@ -4117,6 +4117,131 @@ def _get_decision_label(decision_type: str, per_type_ordinal: int = 0) -> str:
|
||||
return base_label
|
||||
|
||||
|
||||
def _build_tree_data(
|
||||
plan_id: str,
|
||||
tree_data: list[dict[str, object]],
|
||||
decisions: list[Decision],
|
||||
show_superseded: bool = False,
|
||||
started_at: datetime | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Build the data payload for ``agents plan tree --format json/yaml``.
|
||||
|
||||
Returns the ``data`` dict that will be wrapped in the spec-required
|
||||
command envelope by ``format_output``.
|
||||
"""
|
||||
filtered = (
|
||||
decisions if show_superseded else [d for d in decisions if not d.is_superseded]
|
||||
)
|
||||
|
||||
def count_nodes(nodes: list[dict[str, object]]) -> int:
|
||||
count = 0
|
||||
for node in nodes:
|
||||
count += 1
|
||||
children = node.get("children", [])
|
||||
if isinstance(children, list):
|
||||
count += count_nodes(children)
|
||||
return count
|
||||
|
||||
def compute_depth(nodes: list[dict[str, object]]) -> int:
|
||||
if not nodes:
|
||||
return 0
|
||||
max_depth = 0
|
||||
for node in nodes:
|
||||
children = node.get("children", [])
|
||||
if isinstance(children, list) and children:
|
||||
max_depth = max(max_depth, 1 + compute_depth(children))
|
||||
return max_depth
|
||||
|
||||
nodes_count = count_nodes(tree_data)
|
||||
tree_depth = compute_depth(tree_data)
|
||||
|
||||
child_plan_ids: set[str] = set()
|
||||
for d in filtered:
|
||||
if d.decision_type in ("subplan_spawn", "subplan_parallel_spawn") and d.plan_id:
|
||||
child_plan_ids.add(d.plan_id)
|
||||
|
||||
child_plans_count = len(child_plan_ids)
|
||||
child_plans_str = f"{child_plans_count}+" if child_plans_count > 0 else "0"
|
||||
|
||||
invariants_count = sum(
|
||||
1 for d in filtered if d.decision_type == "invariant_enforced"
|
||||
)
|
||||
|
||||
superseded_count = sum(1 for d in decisions if d.is_superseded)
|
||||
|
||||
summary = {
|
||||
"nodes": nodes_count,
|
||||
"depth": tree_depth,
|
||||
"child_plans": child_plans_str,
|
||||
"invariants": invariants_count,
|
||||
"superseded": superseded_count,
|
||||
}
|
||||
|
||||
type_counts: dict[str, int] = {}
|
||||
decision_ids: dict[str, str] = {}
|
||||
|
||||
for d in filtered:
|
||||
type_counts[d.decision_type] = type_counts.get(d.decision_type, 0) + 1
|
||||
ordinal = type_counts[d.decision_type]
|
||||
|
||||
if d.decision_type == "prompt_definition":
|
||||
key = "root"
|
||||
elif d.decision_type == "invariant_enforced":
|
||||
key = f"invariant_{ordinal}"
|
||||
elif d.decision_type == "strategy_choice":
|
||||
key = "strategy"
|
||||
elif d.decision_type == "implementation_choice":
|
||||
key = f"implementation_{ordinal}"
|
||||
elif d.decision_type == "subplan_spawn":
|
||||
key = f"spawn_{ordinal}"
|
||||
elif d.decision_type == "subplan_parallel_spawn":
|
||||
key = f"parallel_{ordinal}"
|
||||
else:
|
||||
key = f"{d.decision_type}_{ordinal}"
|
||||
|
||||
decision_ids[key] = d.decision_id
|
||||
|
||||
child_plans_list: list[dict[str, object]] = []
|
||||
for d in filtered:
|
||||
if d.decision_type in ("subplan_spawn", "subplan_parallel_spawn") and d.plan_id:
|
||||
child_plans_list.append(
|
||||
{
|
||||
"id": d.plan_id,
|
||||
"phase": "execute",
|
||||
"state": "queued",
|
||||
}
|
||||
)
|
||||
|
||||
def convert_tree_node(node: dict[str, object]) -> dict[str, object]:
|
||||
"""Convert internal tree node format to spec format."""
|
||||
spec_node: dict[str, object] = {
|
||||
"type": node.get("type"),
|
||||
"description": node.get("question") or node.get("description"),
|
||||
}
|
||||
|
||||
if node.get("confidence") is not None:
|
||||
spec_node["confidence"] = node.get("confidence")
|
||||
|
||||
if node.get("type") in ("subplan_spawn", "subplan_parallel_spawn"):
|
||||
spec_node["plan_id"] = node.get("plan_id", "")
|
||||
|
||||
children = node.get("children", [])
|
||||
if isinstance(children, list) and children:
|
||||
spec_node["children"] = [convert_tree_node(child) for child in children]
|
||||
|
||||
return spec_node
|
||||
|
||||
spec_tree = convert_tree_node(tree_data[0]) if tree_data else None
|
||||
|
||||
return {
|
||||
"plan_id": plan_id,
|
||||
"tree": spec_tree,
|
||||
"summary": summary,
|
||||
"child_plans": child_plans_list,
|
||||
"decision_ids": decision_ids,
|
||||
}
|
||||
|
||||
|
||||
@app.command("tree")
|
||||
def tree_decisions_cmd(
|
||||
plan_id: Annotated[
|
||||
@@ -4139,6 +4264,7 @@ def tree_decisions_cmd(
|
||||
"""Display the decision tree for a plan."""
|
||||
from cleveragents.application.container import get_container
|
||||
|
||||
_tree_cmd_start = datetime.now(UTC)
|
||||
container = get_container()
|
||||
svc = container.decision_service()
|
||||
decisions = svc.list_decisions(plan_id)
|
||||
@@ -4153,7 +4279,17 @@ def tree_decisions_cmd(
|
||||
)
|
||||
|
||||
if fmt in (OutputFormat.JSON, OutputFormat.YAML):
|
||||
console.print(format_output(tree_data, fmt))
|
||||
tree_data_dict = _build_tree_data(
|
||||
plan_id, tree_data, decisions, show_superseded, started_at=_tree_cmd_start
|
||||
)
|
||||
console.print(
|
||||
format_output(
|
||||
tree_data_dict,
|
||||
fmt,
|
||||
command="plan tree",
|
||||
messages=[{"level": "ok", "text": "Decision tree rendered"}],
|
||||
)
|
||||
)
|
||||
elif fmt == OutputFormat.TABLE:
|
||||
# Flatten for table view
|
||||
filtered = (
|
||||
|
||||
Reference in New Issue
Block a user