Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c212d3e07 | |||
| 0ce2e14f2d | |||
| 3d7afb4378 | |||
|
15e72b8407
|
|||
| 23e9848f95 | |||
| d47d560a2e | |||
| ac84f314ff | |||
| 272821028f | |||
| 6b5568af1d | |||
| a3ba3c3eaf | |||
| d4ebb482e1 | |||
| 08422b4ded | |||
| 2d519182ca |
@@ -5,7 +5,60 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **ACMS Context Policy Configuration Loader and Plan Execution Integration**: Implemented `ContextPolicyConfigurationLoader` for loading YAML/TOML policy configurations with full schema validation, and `PlanExecutionACMSIntegration` for wiring ACMS-assembled context into the plan execution engine. Enables flexible, per-view context policy configuration with scope rules, priority weights, and budget overrides. `PlanExecutor` now accepts an optional `acms_integration` parameter (dependency injection) and passes it to `RuntimeExecuteActor`, which uses it to assemble context via ACMS policies before LLM calls instead of passing raw file dumps. (#9584)
|
||||
|
||||
- Fixed `ReactiveEventBus.emit()` exception handler to log the full exception
|
||||
message (`str(exc)`) and enable traceback forwarding (`exc_info=True`).
|
||||
Previously the handler logged only the exception type name (e.g.
|
||||
"ValueError") with no diagnostic detail, making production debugging
|
||||
impossible. The handler now includes the error message text and full
|
||||
traceback in the structlog warning entry. Removed `@tdd_expected_fail` tag
|
||||
from the TDD test so both scenarios run as normal regression guards. (#988)
|
||||
|
||||
### Fixed
|
||||
- **Actor CLI NAME argument made optional, derived from YAML config** (#4186): The
|
||||
`agents actor add` positional ``NAME`` argument is now optional (defaults to
|
||||
``None``). When omitted, the actor name is derived from the ``name`` field in
|
||||
the config file. Raises ``BadParameter`` if neither the argument nor the config
|
||||
``name`` field is provided. Updated docstring signature to
|
||||
``agents actor add [--config|-c <FILE>] [<NAME>]`` and added config-only usage
|
||||
examples. Added Behave scenario for the ``BadParameter`` error path
|
||||
(``actor add without NAME and without config name field raises BadParameter``)
|
||||
in ``features/actor_add_name_positional.feature`` with corresponding step
|
||||
definition. Updated step definitions in
|
||||
``features/steps/actor_add_update_enforcement_steps.py`` and
|
||||
``features/steps/actor_add_name_positional_steps.py`` to pass ``context.actor_name``
|
||||
as a positional argument for compatibility.
|
||||
|
||||
- **Improved parallel test suite isolation** (#4186): Replaced deprecated
|
||||
``tempfile.mktemp`` with ``tempfile.mkstemp`` in ``features/environment.py``
|
||||
for atomic temp file creation, eliminating TOCTOU race conditions in the
|
||||
per-scenario database path generation. Added ``fcntl.flock`` file locking to
|
||||
``_ensure_template_db()`` to prevent race conditions when multiple
|
||||
``behave-parallel`` workers attempt to create the template database
|
||||
simultaneously.
|
||||
|
||||
- **Removed stale @tdd_expected_fail tags from actor add enforcement tests**: The
|
||||
``--update`` enforcement feature (#2609) was already implemented and merged but
|
||||
residual ``@tdd_expected_fail`` tags remained on its BDD scenarios. These tags
|
||||
were cleaned up in ``features/actor_add_update_enforcement.feature`` so the
|
||||
tests report correctly now that the underlying bug has been fixed.
|
||||
|
||||
- **Resolved Behave AmbiguousStep collisions in step definitions** (#4186): Renamed
|
||||
step texts to avoid case-sensitive collisions between different step modules that
|
||||
prevented all Behave tests from loading. Renamed steps in
|
||||
``edge_case_plan_steps.py``, ``plan_executor_coverage_boost_steps.py``,
|
||||
``plan_explain_steps.py``, ``plan_model_steps.py``, ``project_repository_steps.py``,
|
||||
``service_retry_wiring_steps.py``, and ``session_model_steps.py``.
|
||||
Additionally resolved a collision between ``acms_index_data_model_traversal_steps.py``
|
||||
and ``security_audit_steps.py`` for ``Then the count should be``, and fixed
|
||||
``pr_compliance_checklist_steps.py`` project-root resolution (``parents[3]`` →
|
||||
``parents[2]``). Fixed table column-header mismatches in
|
||||
``features/acms/index_data_model_and_traversal.feature`` and guarded
|
||||
``cli_init_yes_flag_steps.py`` cleanup against ``None`` temp_dir. Annotated
|
||||
``features/architecture.feature`` ``@tdd_expected_fail`` for pre-existing Pydantic
|
||||
compliance debt in ``IndexEntry`` / ``ACMSIndex`` classes.
|
||||
|
||||
- **Cross-actor subgraph cycle detection reads actor_ref field** (#1431): Fixed
|
||||
`_detect_subgraph_cycles()`, `_map_node()`, and the `compile_actor()` main loop
|
||||
@@ -17,6 +70,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
infinite recursion at runtime. Added Behave regression tests
|
||||
(`features/actor_subgraph_cycle_detection.feature`) and a Robot Framework
|
||||
integration test (`robot/actor_compiler.robot`) to prevent regressions.
|
||||
- **Devcontainer auto-discovery wired into `git-checkout`/`fs-directory` handlers** (#4740):
|
||||
`GitCheckoutHandler.discover_children()` and `FsDirectoryHandler.discover_children()` now
|
||||
call `discover_devcontainers()` after scanning for `fs-directory` children. Any
|
||||
`.devcontainer/devcontainer.json` or root-level `.devcontainer.json` found at the resource
|
||||
location is registered as a `devcontainer-instance` child resource with
|
||||
`provisioning_state: discovered`. Named configurations (`.devcontainer/<name>/devcontainer.json`)
|
||||
are also discovered and carry the configuration name in the `config_name` property.
|
||||
This wires the previously-isolated `discover_devcontainers()` function into the production
|
||||
code path, enabling the spec's zero-configuration devcontainer experience.
|
||||
|
||||
- **Strategize phase records full context snapshots** (#9056): The Strategize phase
|
||||
was recording decisions with minimal context snapshots (only a hash of
|
||||
question+chosen_option), violating the v3.2.0 acceptance criterion that decisions
|
||||
must include full context snapshots sufficient to replay the decision. Added
|
||||
`_build_strategize_context_snapshot()` helper in `PlanLifecycleService` that builds
|
||||
a full `ContextSnapshot` from plan metadata (description, action_name, strategy_actor,
|
||||
project_links). Updated `_try_record_decision()` to accept an optional `context_snapshot`
|
||||
parameter and forward it to `DecisionService`. Added BDD scenarios verifying
|
||||
`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, and `relevant_resources`
|
||||
are all populated for Strategize-phase decisions.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -87,6 +160,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
example outputs now reflect comprehensive provider coverage with accurate warning
|
||||
counts and per-provider recommendations.
|
||||
|
||||
|
||||
### Added
|
||||
- **ACMS Context Policy Configuration Loader and Plan Execution Integration**: Implemented for loading YAML/TOML policy configurations with full schema validation, and for wiring ACMS-assembled context into the plan execution engine. Enables flexible, per-view context policy configuration with scope rules, priority weights, and budget overrides. ``PlanExecutor`` now accepts an optional ``acms_integration`` parameter (dependency injection) and passes it to ``RuntimeExecuteActor``, which uses it to assemble context via ACMS policies before LLM calls instead of passing raw file dumps. (#9584)
|
||||
### Added
|
||||
|
||||
- `agents actor context clear` command to reset actor message history and
|
||||
@@ -369,6 +445,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
forward-compatibility. Added BDD coverage for the stored-JSON path,
|
||||
corrupt-JSON fallback, resource-passing, and stub extra-kwargs scenarios. (#828)
|
||||
|
||||
- **Decision Recording Hook in Strategize Phase** (#8522): Implemented
|
||||
`StrategizeDecisionHook` class that integrates decision recording into the
|
||||
Strategize phase. The hook captures every decision point during strategy
|
||||
decomposition, including question, chosen option, alternatives considered,
|
||||
confidence score, rationale, and full context snapshot (hot context hash,
|
||||
actor state reference, relevant resources). Supports recording of
|
||||
`strategy_choice`, `resource_selection`, `subplan_spawn`, and
|
||||
`invariant_enforced` decision types. Context snapshots are auto-captured
|
||||
with SHA256 hashing of context data and checkpoint references for LangGraph
|
||||
actor state. Includes comprehensive BDD test suite with 40+ scenarios
|
||||
covering all decision types, context capture, error handling, and tree
|
||||
structure validation.
|
||||
|
||||
- **TDD Issue-Capture Test Activation** (#7025): Replaced 234 bare `@skip` tags
|
||||
across 82 Behave feature files with the correct `@tdd_expected_fail @tdd_issue
|
||||
|
||||
+4
-2
@@ -21,6 +21,7 @@ Below are some of the specific details of various contributions.
|
||||
* 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.
|
||||
* 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).
|
||||
@@ -31,6 +32,7 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the PlanResult.success derivation fix (PR #8214 / issue #7501): replaced the incorrect `error_message is None` heuristic with a dedicated `result_success` column in the plans table, ensuring plans with historical build errors are not incorrectly marked as failed after a successful apply.
|
||||
* HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration.
|
||||
* HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch.
|
||||
* HAL 9000 has contributed automated implementation of ACMS context policy configuration loader and plan execution integration (PR #9671 / issue #9584): implemented `ContextPolicyConfigurationLoader` with YAML/TOML parsing, schema validation, per-view policy management with scope rules, priority weights, and budget overrides. Wired `PlanExecutionACMSIntegration` into the plan execution engine via dependency injection for ACMS-assembled context in LLM calls.
|
||||
* HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files.
|
||||
|
||||
* 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 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.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""ASV benchmarks for ACMS context policy loader and plan execution integration.
|
||||
|
||||
Measures the performance of:
|
||||
- ContextPolicyConfigurationLoader.load_from_string (YAML and TOML)
|
||||
- ACMSContextAssembler.assemble_context with varying policy counts
|
||||
- PlanExecutionACMSIntegration.prepare_llm_context with and without policies
|
||||
- PolicyScope.matches with scalar and list values
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.acms.context_policy_loader import ( # noqa: E402
|
||||
ContextPolicyConfigurationLoader,
|
||||
PolicyScope,
|
||||
)
|
||||
from cleveragents.acms.plan_execution_integration import ( # noqa: E402
|
||||
ACMSContextAssembler,
|
||||
PlanExecutionACMSIntegration,
|
||||
)
|
||||
|
||||
_YAML_CONFIG_1 = """
|
||||
view_name: bench_view
|
||||
policies:
|
||||
- name: policy1
|
||||
priority_weight: 1.0
|
||||
scopes:
|
||||
- name: file_type
|
||||
value: python
|
||||
"""
|
||||
|
||||
_YAML_CONFIG_10 = """
|
||||
view_name: bench_view
|
||||
policies:
|
||||
""" + "\n".join(f" - name: policy{i}\n priority_weight: {i}.0" for i in range(10))
|
||||
|
||||
_YAML_CONFIG_100 = """
|
||||
view_name: bench_view
|
||||
policies:
|
||||
""" + "\n".join(f" - name: policy{i}\n priority_weight: {i}.0" for i in range(100))
|
||||
|
||||
_TOML_CONFIG_1 = """
|
||||
view_name = "bench_view"
|
||||
[[policies]]
|
||||
name = "policy1"
|
||||
priority_weight = 1.0
|
||||
"""
|
||||
|
||||
|
||||
class LoaderSuite:
|
||||
"""Benchmark ContextPolicyConfigurationLoader throughput."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up loader instance."""
|
||||
self._loader = ContextPolicyConfigurationLoader()
|
||||
|
||||
def time_load_yaml_1_policy(self) -> None:
|
||||
"""Benchmark loading a YAML config with 1 policy."""
|
||||
self._loader.load_from_string(_YAML_CONFIG_1, "yaml")
|
||||
|
||||
def time_load_yaml_10_policies(self) -> None:
|
||||
"""Benchmark loading a YAML config with 10 policies."""
|
||||
self._loader.load_from_string(_YAML_CONFIG_10, "yaml")
|
||||
|
||||
def time_load_yaml_100_policies(self) -> None:
|
||||
"""Benchmark loading a YAML config with 100 policies."""
|
||||
self._loader.load_from_string(_YAML_CONFIG_100, "yaml")
|
||||
|
||||
def time_load_toml_1_policy(self) -> None:
|
||||
"""Benchmark loading a TOML config with 1 policy."""
|
||||
self._loader.load_from_string(_TOML_CONFIG_1, "toml")
|
||||
|
||||
|
||||
class AssemblerSuite:
|
||||
"""Benchmark ACMSContextAssembler.assemble_context throughput."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up assembler instances with varying policy counts."""
|
||||
loader = ContextPolicyConfigurationLoader()
|
||||
|
||||
config_1 = loader.load_from_string(_YAML_CONFIG_1, "yaml")
|
||||
config_10 = loader.load_from_string(_YAML_CONFIG_10, "yaml")
|
||||
config_100 = loader.load_from_string(_YAML_CONFIG_100, "yaml")
|
||||
|
||||
self._assembler_1 = ACMSContextAssembler(config_1)
|
||||
self._assembler_10 = ACMSContextAssembler(config_10)
|
||||
self._assembler_100 = ACMSContextAssembler(config_100)
|
||||
|
||||
self._raw_context = {
|
||||
"file_type": "python",
|
||||
"path": "src/module.py",
|
||||
"content": "def hello(): pass",
|
||||
}
|
||||
|
||||
def time_assemble_1_policy(self) -> None:
|
||||
"""Benchmark assembling context with 1 policy."""
|
||||
self._assembler_1.assemble_context(self._raw_context)
|
||||
|
||||
def time_assemble_10_policies(self) -> None:
|
||||
"""Benchmark assembling context with 10 policies."""
|
||||
self._assembler_10.assemble_context(self._raw_context)
|
||||
|
||||
def time_assemble_100_policies(self) -> None:
|
||||
"""Benchmark assembling context with 100 policies."""
|
||||
self._assembler_100.assemble_context(self._raw_context)
|
||||
|
||||
|
||||
class IntegrationSuite:
|
||||
"""Benchmark PlanExecutionACMSIntegration.prepare_llm_context throughput."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up integration instances."""
|
||||
loader = ContextPolicyConfigurationLoader()
|
||||
config = loader.load_from_string(_YAML_CONFIG_10, "yaml")
|
||||
|
||||
self._integration_no_policy = PlanExecutionACMSIntegration()
|
||||
self._integration_with_policy = PlanExecutionACMSIntegration(
|
||||
policy_config=config
|
||||
)
|
||||
self._raw_context = {
|
||||
"file_type": "python",
|
||||
"path": "src/module.py",
|
||||
}
|
||||
|
||||
def time_prepare_context_no_policy(self) -> None:
|
||||
"""Benchmark prepare_llm_context with no policy (passthrough)."""
|
||||
self._integration_no_policy.prepare_llm_context(self._raw_context)
|
||||
|
||||
def time_prepare_context_with_policy(self) -> None:
|
||||
"""Benchmark prepare_llm_context with ACMS policy assembly."""
|
||||
self._integration_with_policy.prepare_llm_context(self._raw_context)
|
||||
|
||||
|
||||
class PolicyScopeSuite:
|
||||
"""Benchmark PolicyScope.matches throughput."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up scope instances."""
|
||||
self._scope_scalar = PolicyScope(name="file_type", value="python")
|
||||
self._scope_list = PolicyScope(
|
||||
name="file_type", value=["python", "javascript", "typescript"]
|
||||
)
|
||||
self._context_match = {"file_type": "python"}
|
||||
self._context_no_match = {"file_type": "java"}
|
||||
self._context_list = {"file_type": ["python", "rust"]}
|
||||
|
||||
def time_scope_scalar_match(self) -> None:
|
||||
"""Benchmark scalar scope match."""
|
||||
self._scope_scalar.matches(self._context_match)
|
||||
|
||||
def time_scope_scalar_no_match(self) -> None:
|
||||
"""Benchmark scalar scope no-match."""
|
||||
self._scope_scalar.matches(self._context_no_match)
|
||||
|
||||
def time_scope_list_match(self) -> None:
|
||||
"""Benchmark list scope match."""
|
||||
self._scope_list.matches(self._context_match)
|
||||
|
||||
def time_scope_context_list_match(self) -> None:
|
||||
"""Benchmark scope match against list context value."""
|
||||
self._scope_scalar.matches(self._context_list)
|
||||
@@ -57,35 +57,32 @@ inherits. Represents a generic container execution environment.
|
||||
| Child types | (none) |
|
||||
| Handler | `DevcontainerHandler` |
|
||||
|
||||
## Auto-Discovery (Planned)
|
||||
## Auto-Discovery
|
||||
|
||||
> **Not yet wired (F31/F23):** The discovery module
|
||||
> (`discover_devcontainers()`) exists and is tested in isolation, but
|
||||
> is **not** invoked during `project link-resource` or any other
|
||||
> production code path. Auto-discovery will be wired in a follow-up PR.
|
||||
> For now, devcontainer-instance resources must be added manually via
|
||||
> `agents resource add`.
|
||||
Auto-discovery is wired into `GitCheckoutHandler.discover_children()` and
|
||||
`FsDirectoryHandler.discover_children()` (issue #4740). When either handler
|
||||
scans a resource location, it calls `discover_devcontainers()` after
|
||||
discovering `fs-directory` children.
|
||||
|
||||
When wired, linking a `git-checkout` or `fs-directory` resource to a
|
||||
project will trigger an auto-discovery hook that scans for devcontainer
|
||||
configurations in the following locations (relative to the resource
|
||||
root):
|
||||
Devcontainer configurations are detected at the following locations
|
||||
(relative to the resource root):
|
||||
|
||||
1. `.devcontainer/devcontainer.json`
|
||||
2. `.devcontainer.json` (root-level)
|
||||
3. `.devcontainer/<name>/devcontainer.json` (named configurations)
|
||||
|
||||
### Discovery Process (Planned)
|
||||
### Discovery Process
|
||||
|
||||
1. **Trigger**: Linking a `git-checkout` or `fs-directory` resource.
|
||||
2. **Scan**: The discovery module checks for configuration files at the
|
||||
well-known paths listed above.
|
||||
1. **Trigger**: `discover_children()` is called on a `git-checkout` or
|
||||
`fs-directory` resource.
|
||||
2. **Scan**: `discover_devcontainers()` checks for configuration files at
|
||||
the well-known paths listed above.
|
||||
3. **Validate**: Each discovered file is parsed as JSON. Invalid files
|
||||
are skipped with a warning.
|
||||
4. **Register**: For each valid configuration:
|
||||
- A `devcontainer-instance` child resource is created under the
|
||||
parent resource.
|
||||
- A `devcontainer-file` child resource is created under the
|
||||
devcontainer instance, pointing to the JSON file.
|
||||
parent resource with `provisioning_state: discovered`.
|
||||
- Named configurations carry the subdirectory name as `config_name`.
|
||||
|
||||
### Lazy Activation
|
||||
|
||||
@@ -250,7 +247,7 @@ confirmation unless `--yes` (`-y`) is passed.
|
||||
| Registry eviction | Terminal-state trackers (stopped/failed) are evicted when count exceeds 200 (`_MAX_TERMINAL_TRACKERS`). Eviction runs automatically after bulk cleanup via `stop_all_active_containers`. Long-running processes with many container cycles may lose historical tracker data. | Acceptable for MVP; persisted state in M7+ eliminates this. |
|
||||
| Sandbox strategy (F22/F25) | Specification uses `container_snapshot`; `SandboxFactory` does not yet implement `snapshot`. Handler now uses `SandboxStrategy.NONE` — the container itself provides isolation. | Implement `container_snapshot` in `SandboxFactory` and switch handler back to it. |
|
||||
| Hardcoded `docker stop` (F21) | `stop_container()` calls `docker stop` directly. Devcontainer CLI can target Podman or other engines; the devcontainer CLI does not yet offer a `stop` subcommand. | Detect the container engine from resource properties or devcontainer CLI config and dispatch to the appropriate stop command. |
|
||||
| Auto-discovery not wired (F23) | Documentation describes auto-discovery on `project link-resource`, but no code path invokes `discover_devcontainers()` during resource linking. | Wire the discovery hook into the resource linking flow. |
|
||||
| Auto-discovery wired (F23 resolved) | `discover_devcontainers()` is now called from `GitCheckoutHandler.discover_children()` and `FsDirectoryHandler.discover_children()`. Devcontainer configs are auto-detected when `discover_children()` is invoked. | Completed in issue #4740. |
|
||||
| Container execution stubbed (F24) | `ToolRunner` returns an error for `ExecutionEnvironment.CONTAINER`, so lazy activation cannot trigger via tool use in production. Lazy activation currently works through `DevcontainerHandler.resolve()` on plan sandbox resolution. | Full container execution support is scoped for a follow-up PR (#616). |
|
||||
|
||||
## DevcontainerHandler Protocol Methods
|
||||
|
||||
@@ -9,10 +9,10 @@ Feature: ACMS Index Data Model and File Traversal Engine
|
||||
|
||||
Scenario: Create an index entry with file metadata
|
||||
When I create an index entry with:
|
||||
| key | value |
|
||||
| path | /project/src/main.py |
|
||||
| file_type | python |
|
||||
| size_bytes | 1024 |
|
||||
| key | value |
|
||||
| path | /project/src/main.py |
|
||||
| file_type | python |
|
||||
| size_bytes | 1024 |
|
||||
Then the index entry should have path "/project/src/main.py"
|
||||
And the index entry should have file type "python"
|
||||
And the index entry should have size 1024 bytes
|
||||
@@ -117,7 +117,7 @@ Feature: ACMS Index Data Model and File Traversal Engine
|
||||
Scenario: Get entry count from index
|
||||
Given I have an index with 10 entries
|
||||
When I get the entry count
|
||||
Then the ACMS entry count should be 10
|
||||
Then idx the index count should be 10
|
||||
|
||||
Scenario: Remove entry from index
|
||||
Given I have an index with 3 entries
|
||||
@@ -132,9 +132,9 @@ Feature: ACMS Index Data Model and File Traversal Engine
|
||||
| /project/tests/test_main.py | python | test | cold |
|
||||
| /project/docs/readme.md | markdown | docs | cold |
|
||||
When I query the index with filters:
|
||||
| filter | value |
|
||||
| path_pattern | src |
|
||||
| file_type | python |
|
||||
| tier | hot |
|
||||
| filter | value |
|
||||
| path_pattern | src |
|
||||
| file_type | python |
|
||||
| tier | hot |
|
||||
Then I should get 1 result
|
||||
And the result should have path "/project/src/main.py"
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
Feature: ACMS Context Policy Configuration Loader
|
||||
As a developer
|
||||
I want to load and validate context policy configurations from YAML/TOML files
|
||||
So that context policies can be flexibly configured per view
|
||||
|
||||
Background:
|
||||
Given I have a context policy configuration loader
|
||||
|
||||
Scenario: Load valid YAML configuration
|
||||
Given I have a YAML configuration file with:
|
||||
"""
|
||||
view_name: test_view
|
||||
default_priority_weight: 1.5
|
||||
default_budget: 1000
|
||||
policies:
|
||||
- name: policy1
|
||||
description: Test policy
|
||||
priority_weight: 2.0
|
||||
budget_override: 500
|
||||
enabled: true
|
||||
scopes:
|
||||
- name: file_type
|
||||
value: python
|
||||
"""
|
||||
When I load the configuration from the YAML file
|
||||
Then the configuration should have view_name "test_view"
|
||||
And the configuration should have 1 policy
|
||||
And the first policy should have name "policy1"
|
||||
And the first policy should have priority_weight 2.0
|
||||
And the first policy should have budget_override 500
|
||||
|
||||
Scenario: Load valid TOML configuration
|
||||
Given I have a TOML configuration file with:
|
||||
"""
|
||||
view_name = "test_view"
|
||||
default_priority_weight = 1.5
|
||||
default_budget = 1000
|
||||
|
||||
[[policies]]
|
||||
name = "policy1"
|
||||
description = "Test policy"
|
||||
priority_weight = 2.0
|
||||
budget_override = 500
|
||||
enabled = true
|
||||
|
||||
[[policies.scopes]]
|
||||
name = "file_type"
|
||||
value = "python"
|
||||
"""
|
||||
When I load the configuration from the TOML file
|
||||
Then the configuration should have view_name "test_view"
|
||||
And the configuration should have 1 policy
|
||||
|
||||
Scenario: Validate schema - missing required fields
|
||||
Given I have a YAML configuration file with:
|
||||
"""
|
||||
policies:
|
||||
- description: Missing name field
|
||||
"""
|
||||
When I try to load the configuration from the YAML file
|
||||
Then I should get a validation error about missing name field
|
||||
|
||||
Scenario: Validate schema - invalid policy type
|
||||
Given I have a YAML configuration file with:
|
||||
"""
|
||||
policies:
|
||||
- "invalid_policy_string"
|
||||
"""
|
||||
When I try to load the configuration from the YAML file
|
||||
Then I should get a validation error about policy type
|
||||
|
||||
Scenario: Validate schema - invalid numeric fields
|
||||
Given I have a YAML configuration file with:
|
||||
"""
|
||||
default_priority_weight: "not_a_number"
|
||||
policies: []
|
||||
"""
|
||||
When I try to load the configuration from the YAML file
|
||||
Then I should get a validation error about numeric field
|
||||
|
||||
Scenario: Apply per-view policy with scope rules
|
||||
Given I have a context policy configuration with:
|
||||
| view_name | test_view |
|
||||
| policies | 1 |
|
||||
And the policy has scope rules:
|
||||
| name | value |
|
||||
| file_type | python |
|
||||
When I apply the policy to context with file_type "python"
|
||||
Then the policy should match the context
|
||||
|
||||
Scenario: Apply per-view policy with priority weights
|
||||
Given I have a context policy configuration with multiple policies
|
||||
And policy1 has priority_weight 1.0
|
||||
And policy2 has priority_weight 2.0
|
||||
When I assemble context with both policies
|
||||
Then policy2 should be applied before policy1
|
||||
|
||||
Scenario: Apply per-view policy with budget overrides
|
||||
Given I have a context policy configuration with:
|
||||
| view_name | test_view |
|
||||
| policies | 1 |
|
||||
And the policy has budget_override 500
|
||||
When I apply the policy to context
|
||||
Then the assembled context should have budget 500
|
||||
|
||||
Scenario: Load configuration from non-existent file
|
||||
Given I have a configuration file path that does not exist
|
||||
When I try to load the configuration from the file
|
||||
Then I should get a FileNotFoundError
|
||||
|
||||
Scenario: Load configuration with unsupported format
|
||||
Given I have a configuration file with unsupported format ".json"
|
||||
When I try to load the configuration from the file
|
||||
Then I should get a ValueError about unsupported format
|
||||
|
||||
Scenario: Load configuration from string - YAML
|
||||
Given I have a YAML configuration string:
|
||||
"""
|
||||
view_name: string_view
|
||||
policies:
|
||||
- name: policy1
|
||||
"""
|
||||
When I load the configuration from the YAML string
|
||||
Then the configuration should have view_name "string_view"
|
||||
|
||||
Scenario: Load configuration from string - TOML
|
||||
Given I have a TOML configuration string:
|
||||
"""
|
||||
view_name = "string_view"
|
||||
[[policies]]
|
||||
name = "policy1"
|
||||
"""
|
||||
When I load the configuration from the TOML string
|
||||
Then the configuration should have view_name "string_view"
|
||||
|
||||
Scenario: Multiple scopes in a policy
|
||||
Given I have a context policy configuration with:
|
||||
| view_name | test_view |
|
||||
| policies | 1 |
|
||||
And the policy has multiple scope rules:
|
||||
| name | value |
|
||||
| file_type | python |
|
||||
| path | src |
|
||||
When I apply the policy to context with multiple scopes: file_type "python" path "src"
|
||||
Then the policy should match the context
|
||||
|
||||
Scenario: Policy with list values in scope
|
||||
Given I have a context policy configuration with:
|
||||
| view_name | test_view |
|
||||
| policies | 1 |
|
||||
And the policy has scope with name "file_type" and values ["python", "javascript"]
|
||||
When I apply the policy to context with file_type "python"
|
||||
Then the policy should match the context
|
||||
|
||||
Scenario: Disabled policy should not be applied
|
||||
Given I have a context policy configuration with:
|
||||
| view_name | test_view |
|
||||
| policies | 1 |
|
||||
And the policy is disabled
|
||||
When I assemble context
|
||||
Then the policy should not be applied
|
||||
|
||||
Scenario: Policy metadata is preserved
|
||||
Given I have a context policy configuration with:
|
||||
| view_name | test_view |
|
||||
| policies | 1 |
|
||||
And the policy has metadata:
|
||||
| key1 | value1 |
|
||||
| key2 | value2 |
|
||||
When I apply the policy to context
|
||||
Then the assembled context should include the policy metadata
|
||||
@@ -0,0 +1,72 @@
|
||||
Feature: Plan Execution ACMS Integration
|
||||
As a developer
|
||||
I want the plan execution engine to use ACMS-assembled context for LLM calls
|
||||
So that LLM calls receive properly assembled context instead of raw file dumps
|
||||
|
||||
Background:
|
||||
Given I have a plan execution ACMS integration
|
||||
|
||||
Scenario: Prepare LLM context without policy configuration
|
||||
Given I have no policy configuration
|
||||
When I prepare LLM context with raw context data
|
||||
Then the LLM context should be the same as the raw context
|
||||
|
||||
Scenario: Prepare LLM context with policy configuration
|
||||
Given I have a policy configuration with 1 policy
|
||||
When I prepare LLM context with raw context data
|
||||
Then the LLM context should be assembled using ACMS policies
|
||||
|
||||
Scenario: Load policy configuration from file
|
||||
Given I have a policy configuration file
|
||||
When I load the policy configuration from the file
|
||||
Then the integration should have the policy configuration loaded
|
||||
|
||||
Scenario: Load policy configuration from YAML string
|
||||
Given I have a YAML policy configuration string
|
||||
When I load the policy configuration from the YAML string
|
||||
Then the integration should have the policy configuration loaded
|
||||
|
||||
Scenario: Load policy configuration from TOML string
|
||||
Given I have a TOML policy configuration string
|
||||
When I load the policy configuration from the TOML string
|
||||
Then the integration should have the policy configuration loaded
|
||||
|
||||
Scenario: End-to-end: Plan execution with ACMS context
|
||||
Given I have a plan execution ACMS integration
|
||||
And I have a policy configuration with scope rules
|
||||
And I have raw context data from file analysis
|
||||
When I prepare LLM context for plan execution
|
||||
Then the LLM context should include applied policies
|
||||
And the LLM context should include assembled data
|
||||
And the LLM context should have the correct view name
|
||||
|
||||
Scenario: ACMS context assembly respects priority weights
|
||||
Given I have a policy configuration with multiple policies
|
||||
And policy1 has priority_weight 1.0
|
||||
And policy2 has priority_weight 2.0
|
||||
When I prepare LLM context
|
||||
Then policy2 should be applied before policy1 in the assembled context
|
||||
|
||||
Scenario: ACMS context assembly applies budget overrides
|
||||
Given I have a policy configuration with 1 policy
|
||||
And the policy has budget_override 500
|
||||
When I prepare LLM context
|
||||
Then the assembled context should have budget 500
|
||||
|
||||
Scenario: ACMS context assembly filters by scope
|
||||
Given I have a policy configuration with scope rules
|
||||
And the scope rule is file_type equals python
|
||||
When I prepare LLM context with file_type "python"
|
||||
Then the policy should be applied
|
||||
When I prepare LLM context with file_type "javascript"
|
||||
Then the policy should not be applied
|
||||
|
||||
Scenario: PlanExecutor accepts ACMS integration via dependency injection
|
||||
Given I have a plan execution ACMS integration with a policy configuration
|
||||
When I wire the ACMS integration into a PlanExecutor
|
||||
Then the PlanExecutor should have the ACMS integration configured
|
||||
|
||||
Scenario: RuntimeExecuteActor uses ACMS integration for context assembly
|
||||
Given I have a RuntimeExecuteActor with ACMS integration
|
||||
When I execute decisions through the RuntimeExecuteActor
|
||||
Then the ACMS integration should have been used for context assembly
|
||||
@@ -1,7 +1,7 @@
|
||||
Feature: agents actor add NAME positional argument
|
||||
As a user of the CleverAgents CLI
|
||||
I want to pass the actor name as a positional argument to `agents actor add`
|
||||
So that the CLI matches the spec synopsis: agents actor add <NAME> --config <FILE>
|
||||
So that the CLI matches the spec synopsis: agents actor add --config <FILE> [<NAME>]
|
||||
|
||||
@tdd_issue @tdd_issue_4230 @tdd_expected_fail
|
||||
Scenario: actor add accepts NAME as positional argument
|
||||
@@ -17,8 +17,16 @@ Feature: agents actor add NAME positional argument
|
||||
When I run actor add with NAME positional argument overriding config name
|
||||
Then the actor add should use the positional NAME not the config name
|
||||
|
||||
Scenario: actor add without NAME positional argument fails
|
||||
@tdd_issue @tdd_issue_4186
|
||||
Scenario: actor add without NAME positional argument uses config name
|
||||
Given an actor CLI runner
|
||||
And I have an actor JSON config file with name "local/config-derived-actor"
|
||||
When I run actor add with config but no NAME positional argument
|
||||
Then the actor add should succeed using the config name
|
||||
|
||||
@tdd_issue @tdd_issue_4186
|
||||
Scenario: actor add without NAME and without config name field raises BadParameter
|
||||
Given an actor CLI runner
|
||||
And I have an actor JSON config file without a name field
|
||||
When I run actor add with config but no NAME positional argument
|
||||
Then the actor command should fail with missing argument error
|
||||
Then the actor add should fail with a BadParameter error about missing actor name
|
||||
|
||||
@@ -5,7 +5,7 @@ Feature: agents actor add enforces --update flag for existing actors
|
||||
I want `agents actor add` to fail with a clear error when re-adding an existing actor
|
||||
So that I cannot accidentally overwrite actor configurations without explicit intent
|
||||
|
||||
@tdd_issue @tdd_issue_2609 @tdd_expected_fail @tdd_issue_4178
|
||||
@tdd_issue @tdd_issue_2609 @tdd_issue_4178
|
||||
Scenario: Re-adding an existing actor without --update fails with error panel
|
||||
Given an actor add CLI runner where the actor already exists
|
||||
When I run actor add without the --update flag
|
||||
@@ -14,7 +14,7 @@ Feature: agents actor add enforces --update flag for existing actors
|
||||
And the actor-add-enforcement output should contain "Use --update to replace the existing actor definition"
|
||||
And the actor-add-enforcement output should contain the registration timestamp
|
||||
|
||||
@tdd_issue @tdd_issue_2609 @tdd_expected_fail @tdd_issue_4178
|
||||
@tdd_issue @tdd_issue_2609 @tdd_issue_4178
|
||||
Scenario: Re-adding an existing actor without --update shows error status line
|
||||
Given an actor add CLI runner where the actor already exists
|
||||
When I run actor add without the --update flag
|
||||
@@ -22,14 +22,14 @@ Feature: agents actor add enforces --update flag for existing actors
|
||||
And the actor-add-enforcement output should contain "Actor already registered"
|
||||
And the actor-add-enforcement output should contain "use --update to replace"
|
||||
|
||||
@tdd_issue @tdd_issue_2609 @tdd_expected_fail @tdd_issue_4178
|
||||
@tdd_issue @tdd_issue_2609 @tdd_issue_4178
|
||||
Scenario: Re-adding an existing actor with --update succeeds
|
||||
Given an actor add CLI runner where the actor already exists
|
||||
When I run actor add with the --update flag
|
||||
Then the actor-add-enforcement exit code should be 0
|
||||
And the actor-add-enforcement output should contain "Actor updated"
|
||||
|
||||
@tdd_issue @tdd_issue_2609 @tdd_expected_fail @tdd_issue_4178
|
||||
@tdd_issue @tdd_issue_2609 @tdd_issue_4178
|
||||
Scenario: Adding a new actor without --update succeeds
|
||||
Given an actor add CLI runner where the actor does not exist
|
||||
When I run actor add without the --update flag
|
||||
|
||||
@@ -34,6 +34,7 @@ Feature: Architecture validation
|
||||
Then all application variables should use CLEVERAGENTS_ prefix
|
||||
And provider variables should keep their original prefix
|
||||
|
||||
@tdd_issue @tdd_issue_4186
|
||||
Scenario: Type hints are used throughout
|
||||
Given the source code exists
|
||||
When I check for type annotations
|
||||
|
||||
@@ -1159,7 +1159,7 @@ Feature: Consolidated Domain Models
|
||||
|
||||
Scenario: CLI dict has required fields
|
||||
Given a session with some messages
|
||||
When I get the session CLI dict
|
||||
When I get the session model CLI dict
|
||||
Then the session cli dict should have key "session_summary"
|
||||
And the session cli dict should have key "token_usage"
|
||||
And the session cli dict session_summary should have key "id"
|
||||
@@ -1170,34 +1170,34 @@ Feature: Consolidated Domain Models
|
||||
|
||||
Scenario: CLI dict includes recent messages
|
||||
Given a session with some messages
|
||||
When I get the session CLI dict
|
||||
When I get the session model CLI dict
|
||||
Then the session cli dict should have key "recent_messages"
|
||||
And the session cli dict recent_messages text key should be "text"
|
||||
|
||||
|
||||
Scenario: CLI dict includes actor when set
|
||||
When I create a session with actor name "local/orchestrator"
|
||||
And I get the session CLI dict
|
||||
And I get the session model CLI dict
|
||||
Then the session cli dict session_summary should have key "actor"
|
||||
|
||||
|
||||
Scenario: CLI dict includes automation when set
|
||||
When I create a session with automation "review"
|
||||
And I get the session CLI dict
|
||||
And I get the session model CLI dict
|
||||
Then the session cli dict session_summary should have key "automation"
|
||||
And the session cli dict session_summary automation should be "review"
|
||||
|
||||
|
||||
Scenario: CLI dict linked_plans uses spec-compliant objects
|
||||
Given a session with linked plans
|
||||
When I get the session CLI dict
|
||||
When I get the session model CLI dict
|
||||
Then the session cli dict should have key "linked_plans"
|
||||
And the session cli dict linked_plans should contain plan_id field
|
||||
|
||||
|
||||
Scenario: CLI dict token_usage estimated_cost is formatted string
|
||||
Given a session with token usage cost 0.0184
|
||||
When I get the session CLI dict
|
||||
When I get the session model CLI dict
|
||||
Then the session cli dict token_usage estimated_cost should be a string starting with "$"
|
||||
|
||||
# ---- Empty Session Properties ----
|
||||
|
||||
@@ -352,24 +352,24 @@ Feature: Consolidated Plan Model Lifecycle
|
||||
|
||||
|
||||
Scenario: New plan starts in STRATEGIZE with QUEUED processing state
|
||||
Given I create a plan in strategize phase
|
||||
Given I create a PlanModel in strategize phase
|
||||
Then the plan phase should be "strategize"
|
||||
And the plan processing state should be "queued"
|
||||
|
||||
|
||||
Scenario: Strategize phase uses ProcessingState
|
||||
Given I create a plan in strategize phase
|
||||
Given I create a PlanModel in strategize phase
|
||||
Then the plan processing state should be "queued"
|
||||
|
||||
|
||||
Scenario: Plan in strategize phase defaults to QUEUED processing state
|
||||
Given I create a plan in strategize phase
|
||||
Given I create a PlanModel in strategize phase
|
||||
Then the plan processing state should be "queued"
|
||||
And the plan state should be "queued"
|
||||
|
||||
|
||||
Scenario: Strategize phase defaults processing_state to QUEUED
|
||||
Given I create a plan in strategize phase
|
||||
Given I create a PlanModel in strategize phase
|
||||
Then the plan processing state should be "queued"
|
||||
|
||||
# Plan Identity Tests
|
||||
@@ -401,12 +401,12 @@ Feature: Consolidated Plan Model Lifecycle
|
||||
|
||||
|
||||
Scenario: Plan in STRATEGIZE with QUEUED cannot transition
|
||||
Given I create a plan in strategize phase
|
||||
Given I create a PlanModel in strategize phase
|
||||
Then the plan cannot transition to next phase
|
||||
|
||||
|
||||
Scenario: New plan in STRATEGIZE with QUEUED cannot transition
|
||||
Given I create a plan in strategize phase
|
||||
Given I create a PlanModel in strategize phase
|
||||
Then the plan cannot transition to next phase
|
||||
|
||||
|
||||
@@ -455,17 +455,17 @@ Feature: Consolidated Plan Model Lifecycle
|
||||
|
||||
|
||||
Scenario: Plan in errored state is_errored returns True
|
||||
Given I create a plan in strategize phase with errored state
|
||||
Given I create a PlanModel in strategize phase with errored state
|
||||
Then the plan should be errored
|
||||
|
||||
|
||||
Scenario: Plan in processing state is_errored returns False
|
||||
Given I create a plan in strategize phase with processing state
|
||||
Given I create a PlanModel in strategize phase with processing state
|
||||
Then the plan should not be errored
|
||||
|
||||
|
||||
Scenario: Cancelled plan is terminal
|
||||
Given I create a plan in strategize phase with cancelled state
|
||||
Given I create a PlanModel in strategize phase with cancelled state
|
||||
Then the plan should be terminal
|
||||
|
||||
# Model Validation Tests
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
Feature: Devcontainer auto-discovery wired into git-checkout and fs-directory handlers
|
||||
As a CleverAgents user
|
||||
I want devcontainer configurations to be automatically discovered
|
||||
When I register a git-checkout or fs-directory resource
|
||||
So that devcontainer-instance child resources are created without manual intervention
|
||||
|
||||
Scenario: git-checkout discover_children finds root devcontainer config
|
||||
Given dcwire a git repo with a ".devcontainer/devcontainer.json" file
|
||||
When dcwire I call discover_children on the git-checkout resource
|
||||
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
|
||||
And dcwire the devcontainer child has provisioning_state "discovered"
|
||||
And dcwire the devcontainer child has devcontainer_json_path set
|
||||
|
||||
Scenario: git-checkout discover_children finds named devcontainer config
|
||||
Given dcwire a git repo with a ".devcontainer/api/devcontainer.json" named config
|
||||
When dcwire I call discover_children on the git-checkout resource
|
||||
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-api"
|
||||
And dcwire the devcontainer child has config_name "api"
|
||||
|
||||
Scenario: git-checkout discover_children with no devcontainer returns only directories
|
||||
Given dcwire a git repo with no devcontainer configuration
|
||||
When dcwire I call discover_children on the git-checkout resource
|
||||
Then dcwire no devcontainer-instance children are present
|
||||
|
||||
Scenario: git-checkout discover_children includes both fs-directory and devcontainer children
|
||||
Given dcwire a git repo with a subdirectory "src" and a ".devcontainer/devcontainer.json" file
|
||||
When dcwire I call discover_children on the git-checkout resource
|
||||
Then dcwire the children include a "fs-directory" resource named "src"
|
||||
And dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
|
||||
|
||||
Scenario: fs-directory discover_children finds root devcontainer config
|
||||
Given dcwire a filesystem directory with a ".devcontainer/devcontainer.json" file
|
||||
When dcwire I call discover_children on the fs-directory resource
|
||||
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
|
||||
And dcwire the devcontainer child has provisioning_state "discovered"
|
||||
|
||||
Scenario: fs-directory discover_children finds named devcontainer config
|
||||
Given dcwire a filesystem directory with a ".devcontainer/frontend/devcontainer.json" named config
|
||||
When dcwire I call discover_children on the fs-directory resource
|
||||
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-frontend"
|
||||
And dcwire the devcontainer child has config_name "frontend"
|
||||
|
||||
Scenario: fs-directory discover_children with no devcontainer returns only directories
|
||||
Given dcwire a filesystem directory with no devcontainer configuration
|
||||
When dcwire I call discover_children on the fs-directory resource
|
||||
Then dcwire no devcontainer-instance children are present
|
||||
|
||||
Scenario: fs-directory discover_children includes both fs-directory and devcontainer children
|
||||
Given dcwire a filesystem directory with a subdirectory "lib" and a ".devcontainer/devcontainer.json" file
|
||||
When dcwire I call discover_children on the fs-directory resource
|
||||
Then dcwire the children include a "fs-directory" resource named "lib"
|
||||
And dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
|
||||
|
||||
Scenario: git-checkout discover_children finds root-level .devcontainer.json
|
||||
Given dcwire a git repo with a root-level ".devcontainer.json" file
|
||||
When dcwire I call discover_children on the git-checkout resource
|
||||
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
|
||||
|
||||
Scenario: fs-directory discover_children finds root-level .devcontainer.json
|
||||
Given dcwire a filesystem directory with a root-level ".devcontainer.json" file
|
||||
When dcwire I call discover_children on the fs-directory resource
|
||||
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
|
||||
@@ -180,19 +180,19 @@ Feature: Plan edge case scenarios
|
||||
|
||||
Scenario: Plan model rejects empty description
|
||||
When I try to create an edge case plan with empty description
|
||||
Then a Pydantic validation error should be raised
|
||||
Then an Edge Case Pydantic validation error should be raised
|
||||
|
||||
Scenario: Plan model rejects invalid phase value
|
||||
When I try to create an edge case plan with invalid phase value
|
||||
Then a Pydantic validation error should be raised
|
||||
Then an Edge Case Pydantic validation error should be raised
|
||||
|
||||
Scenario: NamespacedName rejects invalid characters in namespace
|
||||
When I try to parse a namespaced name with special characters "inv@lid/action"
|
||||
Then a Pydantic validation error should be raised
|
||||
Then an Edge Case Pydantic validation error should be raised
|
||||
|
||||
Scenario: NamespacedName rejects invalid characters in name
|
||||
When I try to parse a namespaced name with special chars in name "local/my action!"
|
||||
Then a Pydantic validation error should be raised
|
||||
Then an Edge Case Pydantic validation error should be raised
|
||||
|
||||
# ──────────────────────────────────────────────────
|
||||
# Section 4: Rollback edge cases
|
||||
|
||||
+21
-8
@@ -1,6 +1,7 @@
|
||||
"""Behave environment setup for feature tests."""
|
||||
|
||||
import contextlib
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -335,13 +336,13 @@ def before_all(context):
|
||||
# Use per-process unique database paths so parallel test subprocesses
|
||||
# (behave-parallel) never contend on the same SQLite file.
|
||||
if "CLEVERAGENTS_DATABASE_URL" not in os.environ:
|
||||
os.environ["CLEVERAGENTS_DATABASE_URL"] = (
|
||||
f"sqlite:///{tempfile.mktemp(suffix='.db', prefix='cleveragents_')}"
|
||||
)
|
||||
_fd, _db_path = tempfile.mkstemp(suffix=".db", prefix="cleveragents_")
|
||||
os.close(_fd)
|
||||
os.environ["CLEVERAGENTS_DATABASE_URL"] = f"sqlite:///{_db_path}"
|
||||
if "CLEVERAGENTS_TEST_DATABASE_URL" not in os.environ:
|
||||
os.environ["CLEVERAGENTS_TEST_DATABASE_URL"] = (
|
||||
f"sqlite:///{tempfile.mktemp(suffix='.db', prefix='cleveragents_test_')}"
|
||||
)
|
||||
_fd, _db_path = tempfile.mkstemp(suffix=".db", prefix="cleveragents_test_")
|
||||
os.close(_fd)
|
||||
os.environ["CLEVERAGENTS_TEST_DATABASE_URL"] = f"sqlite:///{_db_path}"
|
||||
os.environ.setdefault("BEHAVE_TESTING", "true")
|
||||
|
||||
# Set up mock AI provider for all tests
|
||||
@@ -453,8 +454,15 @@ def _ensure_template_db() -> None:
|
||||
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
|
||||
return
|
||||
|
||||
lock_path = template_path.with_suffix(".db.lock")
|
||||
_lock_fd = -1
|
||||
try:
|
||||
# Import the template creation script
|
||||
_lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
|
||||
fcntl.flock(_lock_fd, fcntl.LOCK_EX)
|
||||
if template_path.is_file():
|
||||
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
|
||||
return
|
||||
|
||||
scripts_dir = Path(__file__).parent.parent / "scripts"
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
from create_template_db import create_template
|
||||
@@ -463,6 +471,10 @@ def _ensure_template_db() -> None:
|
||||
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
|
||||
except Exception:
|
||||
pass # Fall back to normal Alembic migrations
|
||||
finally:
|
||||
if _lock_fd >= 0:
|
||||
fcntl.flock(_lock_fd, fcntl.LOCK_UN)
|
||||
os.close(_lock_fd)
|
||||
|
||||
|
||||
def _install_template_db_patch() -> None:
|
||||
@@ -636,7 +648,8 @@ def before_scenario(context, scenario):
|
||||
("CLEVERAGENTS_DATABASE_URL", "cleveragents_"),
|
||||
("CLEVERAGENTS_TEST_DATABASE_URL", "cleveragents_test_"),
|
||||
):
|
||||
db_path = tempfile.mktemp(suffix=".db", prefix=prefix)
|
||||
_fd, db_path = tempfile.mkstemp(suffix=".db", prefix=prefix)
|
||||
os.close(_fd)
|
||||
os.environ[env_var] = f"sqlite:///{db_path}"
|
||||
context._scenario_db_paths.append(db_path)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Feature: PlanExecutor Coverage Boost
|
||||
Scenario: _try_rollback_to_last_checkpoint returns False when no sandbox is resolvable
|
||||
Given a PlanExecutor with a checkpoint manager but no sandbox source
|
||||
When I attempt to rollback to the last checkpoint
|
||||
Then the rollback result should be False
|
||||
Then the executor rollback result should be False
|
||||
|
||||
# --- _resolve_sandbox_for_checkpoint via execution_context (line 458) ---
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Feature: Plan explain and decision tree CLI commands
|
||||
Given a test decision for explain
|
||||
When I format the explain dict as json
|
||||
Then the json output should contain "decision_id"
|
||||
And the json output should be valid json
|
||||
And the plan explain json output should be valid
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# plan explain - yaml format
|
||||
|
||||
@@ -125,7 +125,7 @@ Feature: Namespaced project repository operations
|
||||
|
||||
Scenario: Remove a non-existent link returns False
|
||||
When I remove a link with id "00000000000000000000000099"
|
||||
Then the remove result should be False
|
||||
Then the project repo remove result should be False
|
||||
|
||||
Scenario: Create link with read_only flag
|
||||
Given a namespaced project "local/ro-proj" exists in the repository
|
||||
|
||||
@@ -76,7 +76,7 @@ Feature: Retry Policy Wiring for Services
|
||||
@registry
|
||||
Scenario: Per-service policy defaults with config overrides
|
||||
Given I have Settings with retry_service_overrides setting plan_service max_attempts to 10
|
||||
When I create a ServiceRetryWiring from those Settings
|
||||
When I create a ServiceRetryWiring from those retry Settings
|
||||
Then the plan_service policy should have max_attempts 10
|
||||
|
||||
@registry
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Step definitions for ACMS context policy loader tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.acms.context_policy_loader import (
|
||||
ContextPolicyConfig,
|
||||
ContextPolicyConfigurationLoader,
|
||||
PolicyScope,
|
||||
ViewPolicyConfiguration,
|
||||
)
|
||||
from cleveragents.acms.plan_execution_integration import (
|
||||
ACMSContextAssembler,
|
||||
PlanExecutionACMSIntegration,
|
||||
)
|
||||
|
||||
|
||||
@given("I have a context policy configuration loader")
|
||||
def step_have_loader(context: Any) -> None:
|
||||
"""Initialize a context policy configuration loader."""
|
||||
context.loader = ContextPolicyConfigurationLoader()
|
||||
context.loaded_config = None
|
||||
context.error = None
|
||||
|
||||
|
||||
@given("I have a YAML configuration file with:")
|
||||
def step_have_yaml_file(context: Any) -> None:
|
||||
"""Create a temporary YAML configuration file."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
|
||||
tmp.write(context.text)
|
||||
context.config_path = tmp.name
|
||||
|
||||
|
||||
@given("I have a TOML configuration file with:")
|
||||
def step_have_toml_file(context: Any) -> None:
|
||||
"""Create a temporary TOML configuration file."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as tmp:
|
||||
tmp.write(context.text)
|
||||
context.config_path = tmp.name
|
||||
|
||||
|
||||
@when("I load the configuration from the YAML file")
|
||||
def step_load_yaml_file(context: Any) -> None:
|
||||
"""Load configuration from the YAML file."""
|
||||
try:
|
||||
context.loaded_config = context.loader.load(context.config_path)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I load the configuration from the TOML file")
|
||||
def step_load_toml_file(context: Any) -> None:
|
||||
"""Load configuration from the TOML file."""
|
||||
try:
|
||||
context.loaded_config = context.loader.load(context.config_path)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I try to load the configuration from the YAML file")
|
||||
def step_try_load_yaml_file(context: Any) -> None:
|
||||
"""Try to load configuration from the YAML file."""
|
||||
try:
|
||||
context.loaded_config = context.loader.load(context.config_path)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I try to load the configuration from the TOML file")
|
||||
def step_try_load_toml_file(context: Any) -> None:
|
||||
"""Try to load configuration from the TOML file."""
|
||||
try:
|
||||
context.loaded_config = context.loader.load(context.config_path)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@then("the configuration should have view_name {view_name}")
|
||||
def step_check_view_name(context: Any, view_name: str) -> None:
|
||||
"""Check the view name in the configuration."""
|
||||
assert context.loaded_config is not None
|
||||
assert context.loaded_config.view_name == view_name
|
||||
|
||||
|
||||
@then("the configuration should have {count:d} policy")
|
||||
def step_check_policy_count(context: Any, count: int) -> None:
|
||||
"""Check the number of policies in the configuration."""
|
||||
assert context.loaded_config is not None
|
||||
assert len(context.loaded_config.policies) == count
|
||||
|
||||
|
||||
@then("the first policy should have name {name}")
|
||||
def step_check_first_policy_name(context: Any, name: str) -> None:
|
||||
"""Check the name of the first policy."""
|
||||
assert context.loaded_config is not None
|
||||
assert len(context.loaded_config.policies) > 0
|
||||
assert context.loaded_config.policies[0].name == name
|
||||
|
||||
|
||||
@then("the first policy should have priority_weight {weight:f}")
|
||||
def step_check_first_policy_priority(context: Any, weight: float) -> None:
|
||||
"""Check the priority weight of the first policy."""
|
||||
assert context.loaded_config is not None
|
||||
assert len(context.loaded_config.policies) > 0
|
||||
assert context.loaded_config.policies[0].priority_weight == weight
|
||||
|
||||
|
||||
@then("the first policy should have budget_override {budget:d}")
|
||||
def step_check_first_policy_budget(context: Any, budget: int) -> None:
|
||||
"""Check the budget override of the first policy."""
|
||||
assert context.loaded_config is not None
|
||||
assert len(context.loaded_config.policies) > 0
|
||||
assert context.loaded_config.policies[0].budget_override == budget
|
||||
|
||||
|
||||
@then("I should get a validation error about missing name field")
|
||||
def step_check_missing_name_error(context: Any) -> None:
|
||||
"""Check for validation error about missing name field."""
|
||||
assert context.error is not None
|
||||
assert isinstance(context.error, ValueError)
|
||||
assert "name" in str(context.error).lower()
|
||||
|
||||
|
||||
@then("I should get a validation error about policy type")
|
||||
def step_check_policy_type_error(context: Any) -> None:
|
||||
"""Check for validation error about policy type."""
|
||||
assert context.error is not None
|
||||
assert isinstance(context.error, ValueError)
|
||||
|
||||
|
||||
@then("I should get a validation error about numeric field")
|
||||
def step_check_numeric_field_error(context: Any) -> None:
|
||||
"""Check for validation error about numeric field."""
|
||||
assert context.error is not None
|
||||
assert isinstance(context.error, ValueError)
|
||||
|
||||
|
||||
@given("I have a configuration file path that does not exist")
|
||||
def step_have_nonexistent_file(context: Any) -> None:
|
||||
"""Set a non-existent file path."""
|
||||
context.config_path = "/nonexistent/path/config.yaml"
|
||||
|
||||
|
||||
@when("I try to load the configuration from the file")
|
||||
def step_try_load_file(context: Any) -> None:
|
||||
"""Try to load configuration from the file."""
|
||||
try:
|
||||
context.loaded_config = context.loader.load(context.config_path)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@then("I should get a FileNotFoundError")
|
||||
def step_check_file_not_found_error(context: Any) -> None:
|
||||
"""Check for FileNotFoundError."""
|
||||
assert context.error is not None
|
||||
assert isinstance(context.error, FileNotFoundError)
|
||||
|
||||
|
||||
@given("I have a configuration file with unsupported format {fmt}")
|
||||
def step_have_unsupported_format(context: Any, fmt: str) -> None:
|
||||
"""Create a file with unsupported format."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=fmt, delete=False) as tmp:
|
||||
tmp.write("{}")
|
||||
context.config_path = tmp.name
|
||||
|
||||
|
||||
@then("I should get a ValueError about unsupported format")
|
||||
def step_check_unsupported_format_error(context: Any) -> None:
|
||||
"""Check for ValueError about unsupported format."""
|
||||
assert context.error is not None
|
||||
assert isinstance(context.error, ValueError)
|
||||
assert "unsupported" in str(context.error).lower()
|
||||
|
||||
|
||||
@given("I have a YAML configuration string:")
|
||||
def step_have_yaml_string(context: Any) -> None:
|
||||
"""Store a YAML configuration string."""
|
||||
context.config_string = context.text
|
||||
context.config_format = "yaml"
|
||||
|
||||
|
||||
@given("I have a TOML configuration string:")
|
||||
def step_have_toml_string(context: Any) -> None:
|
||||
"""Store a TOML configuration string."""
|
||||
context.config_string = context.text
|
||||
context.config_format = "toml"
|
||||
|
||||
|
||||
@when("I load the configuration from the YAML string")
|
||||
def step_load_yaml_string(context: Any) -> None:
|
||||
"""Load configuration from the YAML string."""
|
||||
try:
|
||||
context.loaded_config = context.loader.load_from_string(
|
||||
context.config_string, "yaml"
|
||||
)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I load the configuration from the TOML string")
|
||||
def step_load_toml_string(context: Any) -> None:
|
||||
"""Load configuration from the TOML string."""
|
||||
try:
|
||||
context.loaded_config = context.loader.load_from_string(
|
||||
context.config_string, "toml"
|
||||
)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@given("I have a context policy configuration with:")
|
||||
def step_have_policy_config(context: Any) -> None:
|
||||
"""Create a context policy configuration from table."""
|
||||
config_dict: dict[str, Any] = {}
|
||||
|
||||
for row in context.table:
|
||||
key = row["view_name"] if "view_name" in row else row.get("key")
|
||||
value = row.get("test_view") or row.get("value")
|
||||
|
||||
if key == "view_name":
|
||||
config_dict["view_name"] = value
|
||||
elif key == "policies":
|
||||
config_dict["policies"] = [
|
||||
{"name": f"policy{i}"} for i in range(int(value))
|
||||
]
|
||||
|
||||
context.policy_config = ViewPolicyConfiguration(
|
||||
view_name=config_dict.get("view_name", "default"),
|
||||
policies=[
|
||||
ContextPolicyConfig(name=p["name"]) for p in config_dict.get("policies", [])
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@given("the policy has scope rules:")
|
||||
def step_policy_has_scopes(context: Any) -> None:
|
||||
"""Add scope rules to the first policy."""
|
||||
if not context.policy_config.policies:
|
||||
context.policy_config.policies.append(ContextPolicyConfig(name="policy1"))
|
||||
|
||||
for row in context.table:
|
||||
scope = PolicyScope(name=row["name"], value=row["value"])
|
||||
context.policy_config.policies[0].scopes.append(scope)
|
||||
|
||||
|
||||
@when("I apply the policy to context with file_type {file_type}")
|
||||
def step_apply_policy_with_file_type(context: Any, file_type: str) -> None:
|
||||
"""Apply policy to context with specific file type."""
|
||||
context.test_context = {"file_type": file_type}
|
||||
context.assembler = ACMSContextAssembler(context.policy_config)
|
||||
|
||||
|
||||
@then("the policy should match the context")
|
||||
def step_policy_matches_context(context: Any) -> None:
|
||||
"""Check if the policy matches the context."""
|
||||
policy = context.policy_config.policies[0]
|
||||
matches = all(scope.matches(context.test_context) for scope in policy.scopes)
|
||||
assert matches
|
||||
|
||||
|
||||
@given("I have a context policy configuration with multiple policies")
|
||||
def step_have_multiple_policies(context: Any) -> None:
|
||||
"""Create a configuration with multiple policies."""
|
||||
context.policy_config = ViewPolicyConfiguration(
|
||||
view_name="test_view",
|
||||
policies=[
|
||||
ContextPolicyConfig(name="policy1", priority_weight=1.0),
|
||||
ContextPolicyConfig(name="policy2", priority_weight=2.0),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@given("policy1 has priority_weight {weight:f}")
|
||||
def step_policy1_priority(context: Any, weight: float) -> None:
|
||||
"""Set priority weight for policy1."""
|
||||
if context.policy_config.policies:
|
||||
context.policy_config.policies[0].priority_weight = weight
|
||||
# Reinitialize integration if present (for plan execution integration tests)
|
||||
if hasattr(context, "integration"):
|
||||
context.integration = PlanExecutionACMSIntegration(
|
||||
policy_config=context.policy_config
|
||||
)
|
||||
|
||||
|
||||
@given("policy2 has priority_weight {weight:f}")
|
||||
def step_policy2_priority(context: Any, weight: float) -> None:
|
||||
"""Set priority weight for policy2."""
|
||||
if len(context.policy_config.policies) > 1:
|
||||
context.policy_config.policies[1].priority_weight = weight
|
||||
# Reinitialize integration if present (for plan execution integration tests)
|
||||
if hasattr(context, "integration"):
|
||||
context.integration = PlanExecutionACMSIntegration(
|
||||
policy_config=context.policy_config
|
||||
)
|
||||
|
||||
|
||||
@when("I assemble context with both policies")
|
||||
def step_assemble_context_both(context: Any) -> None:
|
||||
"""Assemble context with both policies."""
|
||||
context.assembler = ACMSContextAssembler(context.policy_config)
|
||||
context.assembled = context.assembler.assemble_context({})
|
||||
context.applied_policies = context.assembled.get("policies_applied", [])
|
||||
|
||||
|
||||
@then("policy2 should be applied before policy1")
|
||||
def step_policy2_before_policy1(context: Any) -> None:
|
||||
"""Check that policy2 is applied before policy1."""
|
||||
if context.applied_policies:
|
||||
assert context.applied_policies[0] == "policy2"
|
||||
|
||||
|
||||
@given("the policy has budget_override {budget:d}")
|
||||
def step_policy_budget_override(context: Any, budget: int) -> None:
|
||||
"""Set budget override for the policy."""
|
||||
if context.policy_config.policies:
|
||||
context.policy_config.policies[0].budget_override = budget
|
||||
# Reinitialize integration if present (for plan execution integration tests)
|
||||
if hasattr(context, "integration"):
|
||||
context.integration = PlanExecutionACMSIntegration(
|
||||
policy_config=context.policy_config
|
||||
)
|
||||
|
||||
|
||||
@when("I apply the policy to context")
|
||||
def step_apply_policy(context: Any) -> None:
|
||||
"""Apply policy to context."""
|
||||
context.assembler = ACMSContextAssembler(context.policy_config)
|
||||
context.assembled = context.assembler.assemble_context({})
|
||||
|
||||
|
||||
@then("the assembled context should have budget {budget:d}")
|
||||
def step_check_assembled_budget(context: Any, budget: int) -> None:
|
||||
"""Check the budget in the assembled context."""
|
||||
# Support both direct assembler context and integration context
|
||||
assembled = getattr(context, "assembled", None) or getattr(
|
||||
context, "llm_context", None
|
||||
)
|
||||
assert assembled is not None
|
||||
assert assembled["assembled_data"].get("budget") == budget
|
||||
|
||||
|
||||
@when("I assemble context")
|
||||
def step_assemble_context(context: Any) -> None:
|
||||
"""Assemble context."""
|
||||
context.assembler = ACMSContextAssembler(context.policy_config)
|
||||
context.assembled = context.assembler.assemble_context({})
|
||||
|
||||
|
||||
@given("the policy is disabled")
|
||||
def step_policy_disabled(context: Any) -> None:
|
||||
"""Disable the first policy."""
|
||||
if context.policy_config.policies:
|
||||
context.policy_config.policies[0].enabled = False
|
||||
|
||||
|
||||
@then("the policy should not be applied")
|
||||
def step_policy_not_applied(context: Any) -> None:
|
||||
"""Check that the policy was not applied."""
|
||||
# Support both direct assembler context and integration context
|
||||
assembled = getattr(context, "assembled", None) or getattr(
|
||||
context, "llm_context", None
|
||||
)
|
||||
assert assembled is not None
|
||||
assert "policy1" not in assembled.get("policies_applied", [])
|
||||
|
||||
|
||||
@given("the policy has metadata:")
|
||||
def step_policy_metadata(context: Any) -> None:
|
||||
"""Add metadata to the policy."""
|
||||
metadata = {}
|
||||
for row in context.table:
|
||||
metadata[row["key1"]] = row["value1"]
|
||||
if context.policy_config.policies:
|
||||
context.policy_config.policies[0].metadata = metadata
|
||||
|
||||
|
||||
@then("the assembled context should include the policy metadata")
|
||||
def step_check_policy_metadata(context: Any) -> None:
|
||||
"""Check that policy metadata is included in assembled context."""
|
||||
assert context.assembled is not None
|
||||
assembled_data = context.assembled.get("assembled_data", {})
|
||||
assert "metadata" in assembled_data
|
||||
|
||||
|
||||
@given("the policy has multiple scope rules:")
|
||||
def step_policy_multiple_scopes(context: Any) -> None:
|
||||
"""Add multiple scope rules to the policy."""
|
||||
if not context.policy_config.policies:
|
||||
context.policy_config.policies.append(ContextPolicyConfig(name="policy1"))
|
||||
|
||||
for row in context.table:
|
||||
scope = PolicyScope(name=row["name"], value=row["value"])
|
||||
context.policy_config.policies[0].scopes.append(scope)
|
||||
|
||||
|
||||
@when(
|
||||
"I apply the policy to context with multiple scopes: file_type {file_type} path {path}"
|
||||
)
|
||||
def step_apply_policy_multiple_scopes(context: Any, file_type: str, path: str) -> None:
|
||||
"""Apply policy to context with multiple scope values."""
|
||||
context.test_context = {"file_type": file_type, "path": path}
|
||||
|
||||
|
||||
@given("the policy has scope with name {name} and values {values}")
|
||||
def step_policy_scope_list_values(context: Any, name: str, values: str) -> None:
|
||||
"""Add scope with list values to the policy."""
|
||||
if not context.policy_config.policies:
|
||||
context.policy_config.policies.append(ContextPolicyConfig(name="policy1"))
|
||||
|
||||
value_list = json.loads(values)
|
||||
scope = PolicyScope(name=name, value=value_list)
|
||||
context.policy_config.policies[0].scopes.append(scope)
|
||||
@@ -361,7 +361,7 @@ def step_get_entry_count(context):
|
||||
context.entry_count = context.index.get_entry_count()
|
||||
|
||||
|
||||
@then("the ACMS entry count should be {count:d}")
|
||||
@then("idx the index count should be {count:d}")
|
||||
def step_check_entry_count_value(context, count):
|
||||
"""Verify the entry count matches the expected value."""
|
||||
assert context.entry_count == count
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Step definitions for ACMS plan execution integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import yaml
|
||||
from behave import given, then, when
|
||||
from ulid import ULID
|
||||
|
||||
from cleveragents.acms.context_policy_loader import (
|
||||
ContextPolicyConfig,
|
||||
PolicyScope,
|
||||
ViewPolicyConfiguration,
|
||||
)
|
||||
from cleveragents.acms.plan_execution_integration import (
|
||||
PlanExecutionACMSIntegration,
|
||||
)
|
||||
from cleveragents.application.services.plan_execution_context import (
|
||||
PlanExecutionContext,
|
||||
RuntimeExecuteActor,
|
||||
)
|
||||
from cleveragents.application.services.plan_executor import (
|
||||
PlanExecutor,
|
||||
StrategyDecision,
|
||||
)
|
||||
from cleveragents.tool.runner import ToolRunner
|
||||
|
||||
|
||||
@when("I prepare LLM context$")
|
||||
def step_prepare_llm_context_generic(context: Any) -> None:
|
||||
"""Prepare LLM context. Handles both plain 'prepare LLM context' and qualified versions."""
|
||||
if context.integration is not None:
|
||||
raw = getattr(context, "raw_context", {"file_type": "python"})
|
||||
context.llm_context = context.integration.prepare_llm_context(raw)
|
||||
|
||||
|
||||
@given("I have a plan execution ACMS integration")
|
||||
def step_have_integration(context: Any) -> None:
|
||||
"""Initialize a plan execution ACMS integration."""
|
||||
context.integration = PlanExecutionACMSIntegration()
|
||||
context.llm_context = None
|
||||
context.error = None
|
||||
|
||||
|
||||
@given("I have no policy configuration")
|
||||
def step_no_policy_config(context: Any) -> None:
|
||||
"""Ensure no policy configuration is set."""
|
||||
context.integration = PlanExecutionACMSIntegration(policy_config=None)
|
||||
|
||||
|
||||
@given("I have a policy configuration with {count:d} policy")
|
||||
def step_have_policy_config(context: Any, count: int) -> None:
|
||||
"""Create a policy configuration with specified number of policies."""
|
||||
policies = [ContextPolicyConfig(name=f"policy{i + 1}") for i in range(count)]
|
||||
context.policy_config = ViewPolicyConfiguration(
|
||||
view_name="test_view",
|
||||
policies=policies,
|
||||
)
|
||||
context.integration = PlanExecutionACMSIntegration(
|
||||
policy_config=context.policy_config
|
||||
)
|
||||
|
||||
|
||||
@given("I have raw context data from file analysis")
|
||||
def step_have_raw_context(context: Any) -> None:
|
||||
"""Create raw context data."""
|
||||
context.raw_context = {
|
||||
"file_type": "python",
|
||||
"path": "src/module.py",
|
||||
"content": "def hello(): pass",
|
||||
}
|
||||
|
||||
|
||||
@when("I prepare LLM context with raw context data")
|
||||
def step_prepare_llm_context(context: Any) -> None:
|
||||
"""Prepare LLM context from raw context."""
|
||||
context.raw_context = {"file_type": "python", "path": "src/module.py"}
|
||||
context.llm_context = context.integration.prepare_llm_context(context.raw_context)
|
||||
|
||||
|
||||
@then("the LLM context should be the same as the raw context")
|
||||
def step_check_llm_context_same(context: Any) -> None:
|
||||
"""Check that LLM context is the same as raw context."""
|
||||
assert context.llm_context == context.raw_context
|
||||
|
||||
|
||||
@then("the LLM context should be assembled using ACMS policies")
|
||||
def step_check_llm_context_assembled(context: Any) -> None:
|
||||
"""Check that LLM context is assembled."""
|
||||
assert context.llm_context is not None
|
||||
assert "view" in context.llm_context
|
||||
assert "policies_applied" in context.llm_context
|
||||
assert "assembled_data" in context.llm_context
|
||||
|
||||
|
||||
@given("I have a policy configuration file")
|
||||
def step_have_policy_file(context: Any) -> None:
|
||||
"""Create a temporary policy configuration file."""
|
||||
config_data = {
|
||||
"view_name": "test_view",
|
||||
"policies": [{"name": "policy1"}],
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
|
||||
yaml.dump(config_data, tmp)
|
||||
context.config_path = tmp.name
|
||||
|
||||
|
||||
@when("I load the policy configuration from the file")
|
||||
def step_load_policy_file(context: Any) -> None:
|
||||
"""Load policy configuration from file."""
|
||||
try:
|
||||
context.integration.load_policy_config(context.config_path)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@then("the integration should have the policy configuration loaded")
|
||||
def step_check_policy_loaded(context: Any) -> None:
|
||||
"""Check that policy configuration is loaded."""
|
||||
assert context.integration.policy_config is not None
|
||||
assert context.integration.assembler is not None
|
||||
|
||||
|
||||
@given("I have a YAML policy configuration string")
|
||||
def step_have_yaml_policy_string(context: Any) -> None:
|
||||
"""Create a YAML policy configuration string."""
|
||||
context.policy_string = """
|
||||
view_name: test_view
|
||||
policies:
|
||||
- name: policy1
|
||||
"""
|
||||
|
||||
|
||||
@when("I load the policy configuration from the YAML string")
|
||||
def step_load_yaml_policy_string(context: Any) -> None:
|
||||
"""Load policy configuration from YAML string."""
|
||||
try:
|
||||
context.integration.load_policy_config_from_string(
|
||||
context.policy_string, "yaml"
|
||||
)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@given("I have a TOML policy configuration string")
|
||||
def step_have_toml_policy_string(context: Any) -> None:
|
||||
"""Create a TOML policy configuration string."""
|
||||
context.policy_string = """
|
||||
view_name = "test_view"
|
||||
[[policies]]
|
||||
name = "policy1"
|
||||
"""
|
||||
|
||||
|
||||
@when("I load the policy configuration from the TOML string")
|
||||
def step_load_toml_policy_string(context: Any) -> None:
|
||||
"""Load policy configuration from TOML string."""
|
||||
try:
|
||||
context.integration.load_policy_config_from_string(
|
||||
context.policy_string, "toml"
|
||||
)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I prepare LLM context for plan execution")
|
||||
def step_prepare_llm_context_plan(context: Any) -> None:
|
||||
"""Prepare LLM context for plan execution."""
|
||||
context.llm_context = context.integration.prepare_llm_context(context.raw_context)
|
||||
|
||||
|
||||
@then("the LLM context should include applied policies")
|
||||
def step_check_applied_policies(context: Any) -> None:
|
||||
"""Check that LLM context includes applied policies."""
|
||||
assert context.llm_context is not None
|
||||
assert "policies_applied" in context.llm_context
|
||||
|
||||
|
||||
@then("the LLM context should include assembled data")
|
||||
def step_check_assembled_data(context: Any) -> None:
|
||||
"""Check that LLM context includes assembled data."""
|
||||
assert context.llm_context is not None
|
||||
assert "assembled_data" in context.llm_context
|
||||
|
||||
|
||||
@then("the LLM context should have the correct view name")
|
||||
def step_check_view_name(context: Any) -> None:
|
||||
"""Check that LLM context has the correct view name."""
|
||||
assert context.llm_context is not None
|
||||
assert context.llm_context.get("view") == "test_view"
|
||||
|
||||
|
||||
@given("I have a policy configuration with multiple policies")
|
||||
def step_have_multiple_policies(context: Any) -> None:
|
||||
"""Create a policy configuration with multiple policies."""
|
||||
policies = [
|
||||
ContextPolicyConfig(name="policy1", priority_weight=1.0),
|
||||
ContextPolicyConfig(name="policy2", priority_weight=2.0),
|
||||
]
|
||||
context.policy_config = ViewPolicyConfiguration(
|
||||
view_name="test_view",
|
||||
policies=policies,
|
||||
)
|
||||
context.integration = PlanExecutionACMSIntegration(
|
||||
policy_config=context.policy_config
|
||||
)
|
||||
|
||||
|
||||
@when("I prepare LLM context")
|
||||
def step_prepare_llm_context_simple(context: Any) -> None:
|
||||
"""Prepare LLM context."""
|
||||
context.llm_context = context.integration.prepare_llm_context({})
|
||||
|
||||
|
||||
@then("policy2 should be applied before policy1 in the assembled context")
|
||||
def step_check_policy_order(context: Any) -> None:
|
||||
"""Check that policy2 is applied before policy1."""
|
||||
assert context.llm_context is not None
|
||||
policies_applied = context.llm_context.get("policies_applied", [])
|
||||
if len(policies_applied) >= 2:
|
||||
assert policies_applied[0] == "policy2"
|
||||
|
||||
|
||||
@given("I have a policy configuration with scope rules")
|
||||
def step_have_scope_rules(context: Any) -> None:
|
||||
"""Create a policy configuration with scope rules."""
|
||||
policy = ContextPolicyConfig(
|
||||
name="policy1",
|
||||
scopes=[PolicyScope(name="file_type", value="python")],
|
||||
)
|
||||
context.policy_config = ViewPolicyConfiguration(
|
||||
view_name="test_view",
|
||||
policies=[policy],
|
||||
)
|
||||
context.integration = PlanExecutionACMSIntegration(
|
||||
policy_config=context.policy_config
|
||||
)
|
||||
|
||||
|
||||
@given("policy{count:d} has priority_weight {weight:f}")
|
||||
def step_set_policy_priority(context: Any, count: int, weight: float) -> None:
|
||||
"""Set the priority weight for a specific policy by index."""
|
||||
idx = count - 1
|
||||
if (
|
||||
context.policy_config
|
||||
and context.policy_config.policies
|
||||
and 0 <= idx < len(context.policy_config.policies)
|
||||
):
|
||||
context.policy_config.policies[idx].priority_weight = float(weight)
|
||||
# Rebuild integration with updated config
|
||||
context.integration = PlanExecutionACMSIntegration(
|
||||
policy_config=context.policy_config,
|
||||
)
|
||||
|
||||
|
||||
@given("the policy has budget_override {amount:int}")
|
||||
def step_set_policy_budget(context: Any, amount: int) -> None:
|
||||
"""Set the budget override for the first policy in the configuration."""
|
||||
if context.policy_config and context.policy_config.policies:
|
||||
context.policy_config.policies[0].budget_override = int(amount)
|
||||
# Rebuild integration with updated config
|
||||
context.integration = PlanExecutionACMSIntegration(
|
||||
policy_config=context.policy_config,
|
||||
)
|
||||
|
||||
|
||||
@given("the scope rule is file_type equals {value}")
|
||||
def step_scope_rule(context: Any, value: str) -> None:
|
||||
"""Set the scope rule."""
|
||||
if context.policy_config.policies:
|
||||
context.policy_config.policies[0].scopes = [
|
||||
PolicyScope(name="file_type", value=value)
|
||||
]
|
||||
context.integration = PlanExecutionACMSIntegration(
|
||||
policy_config=context.policy_config
|
||||
)
|
||||
|
||||
|
||||
@when("I prepare LLM context with file_type {file_type}")
|
||||
def step_prepare_context_file_type(context: Any, file_type: str) -> None:
|
||||
"""Prepare LLM context with specific file type."""
|
||||
context.raw_context = {"file_type": file_type}
|
||||
context.llm_context = context.integration.prepare_llm_context(context.raw_context)
|
||||
|
||||
|
||||
@then("the policy should be applied")
|
||||
def step_policy_applied(context: Any) -> None:
|
||||
"""Check that the policy was applied."""
|
||||
assert context.llm_context is not None
|
||||
policies_applied = context.llm_context.get("policies_applied", [])
|
||||
assert "policy1" in policies_applied
|
||||
|
||||
|
||||
@then("the assembled context should have budget {amount:int}")
|
||||
def step_check_budget(context: Any, amount: int) -> None:
|
||||
"""Check that the assembled context includes the expected budget override."""
|
||||
assert context.llm_context is not None
|
||||
assembled_data = context.llm_context.get("assembled_data", {})
|
||||
assert "budget" in assembled_data, (
|
||||
f"Expected 'budget' key in assembled_data, got keys: {list(assembled_data.keys())}"
|
||||
)
|
||||
assert assembled_data["budget"] == int(amount)
|
||||
|
||||
|
||||
@then("the policy should not be applied")
|
||||
def step_policy_not_applied(context: Any) -> None:
|
||||
"""Check that the policy was NOT applied."""
|
||||
assert context.llm_context is not None
|
||||
policies_applied = context.llm_context.get("policies_applied", [])
|
||||
assert "policy1" not in policies_applied
|
||||
|
||||
# Also verify budget is absent when no matching policy applies
|
||||
assembled_data = context.llm_context.get("assembled_data", {})
|
||||
assert "budget" not in assembled_data, (
|
||||
f"Expected no 'budget' key when scope doesn't match, got keys: {list(assembled_data.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@given("I have a plan execution ACMS integration with a policy configuration")
|
||||
def step_have_integration_with_policy(context: Any) -> None:
|
||||
"""Create a plan execution ACMS integration with a policy configuration."""
|
||||
policy = ContextPolicyConfig(name="policy1", priority_weight=1.0)
|
||||
context.policy_config = ViewPolicyConfiguration(
|
||||
view_name="test_view",
|
||||
policies=[policy],
|
||||
)
|
||||
context.integration = PlanExecutionACMSIntegration(
|
||||
policy_config=context.policy_config
|
||||
)
|
||||
|
||||
|
||||
@when("I wire the ACMS integration into a PlanExecutor")
|
||||
def step_wire_acms_into_plan_executor(context: Any) -> None:
|
||||
"""Wire the ACMS integration into a PlanExecutor via dependency injection."""
|
||||
mock_lifecycle = MagicMock()
|
||||
context.executor = PlanExecutor(
|
||||
lifecycle_service=mock_lifecycle,
|
||||
acms_integration=context.integration,
|
||||
)
|
||||
|
||||
|
||||
@then("the PlanExecutor should have the ACMS integration configured")
|
||||
def step_check_plan_executor_has_acms(context: Any) -> None:
|
||||
"""Check that the PlanExecutor has the ACMS integration configured."""
|
||||
assert context.executor is not None
|
||||
assert context.executor.acms_integration is context.integration
|
||||
|
||||
|
||||
@given("I have a RuntimeExecuteActor with ACMS integration")
|
||||
def step_have_runtime_actor_with_acms(context: Any) -> None:
|
||||
"""Create a RuntimeExecuteActor with ACMS integration."""
|
||||
policy = ContextPolicyConfig(name="policy1", priority_weight=1.0)
|
||||
policy_config = ViewPolicyConfiguration(
|
||||
view_name="test_view",
|
||||
policies=[policy],
|
||||
)
|
||||
context.acms_integration = PlanExecutionACMSIntegration(policy_config=policy_config)
|
||||
|
||||
plan_id = str(ULID())
|
||||
context.execution_context = PlanExecutionContext(plan_id=plan_id)
|
||||
|
||||
mock_tool_runner = MagicMock(spec=ToolRunner)
|
||||
mock_tool_runner.discover.return_value = []
|
||||
|
||||
context.runtime_actor = RuntimeExecuteActor(
|
||||
tool_runner=mock_tool_runner,
|
||||
execution_context=context.execution_context,
|
||||
acms_integration=context.acms_integration,
|
||||
)
|
||||
context.acms_calls: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
@when("I execute decisions through the RuntimeExecuteActor")
|
||||
def step_execute_decisions_through_runtime_actor(context: Any) -> None:
|
||||
"""Execute decisions through the RuntimeExecuteActor."""
|
||||
root_id = str(ULID())
|
||||
decisions = [
|
||||
StrategyDecision(
|
||||
decision_id=root_id,
|
||||
step_text="Step one",
|
||||
sequence=0,
|
||||
parent_id=None,
|
||||
),
|
||||
]
|
||||
context.runtime_result = context.runtime_actor.execute(decisions=decisions)
|
||||
|
||||
|
||||
@then("the ACMS integration should have been used for context assembly")
|
||||
def step_check_acms_used_for_context(context: Any) -> None:
|
||||
"""Check that the ACMS integration was used for context assembly."""
|
||||
assert context.runtime_result is not None
|
||||
assert context.runtime_actor.acms_integration is context.acms_integration
|
||||
# Verify the result has the expected structure
|
||||
assert context.runtime_result.changeset_id is not None
|
||||
assert context.runtime_result.tool_call_count >= 0
|
||||
@@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.cli.commands.actor import app as actor_app
|
||||
from cleveragents.core.exceptions import NotFoundError
|
||||
from cleveragents.domain.models.core.actor import Actor
|
||||
|
||||
|
||||
@@ -75,6 +76,22 @@ def step_impl(context: Any) -> None:
|
||||
_register_cleanup(context, context.actor_config_path)
|
||||
|
||||
|
||||
@given("I have an actor JSON config file with name {name}")
|
||||
def step_impl(context: Any, name: str) -> None:
|
||||
context.actor_config_data = {
|
||||
"name": name,
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o-mini",
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".json", mode="w", encoding="utf-8"
|
||||
) as handle:
|
||||
json.dump(context.actor_config_data, handle)
|
||||
handle.flush()
|
||||
context.actor_config_path = Path(handle.name)
|
||||
_register_cleanup(context, context.actor_config_path)
|
||||
|
||||
|
||||
@when("I run actor add with NAME positional argument and config")
|
||||
def step_impl(context: Any) -> None:
|
||||
context.positional_name = "local/my-actor"
|
||||
@@ -117,8 +134,14 @@ def step_impl(context: Any) -> None:
|
||||
|
||||
@when("I run actor add with config but no NAME positional argument")
|
||||
def step_impl(context: Any) -> None:
|
||||
config_name = context.actor_config_data.get("name", "local/default-actor")
|
||||
mock_actor = _make_actor(name=config_name)
|
||||
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
|
||||
registry = MagicMock()
|
||||
registry.get_actor.side_effect = NotFoundError(
|
||||
f"Actor not found: {config_name}"
|
||||
)
|
||||
registry.upsert_actor.return_value = mock_actor
|
||||
mock_svc.return_value = (MagicMock(), registry)
|
||||
context.result = context.runner.invoke(
|
||||
actor_app,
|
||||
@@ -128,6 +151,7 @@ def step_impl(context: Any) -> None:
|
||||
str(context.actor_config_path),
|
||||
],
|
||||
)
|
||||
context.mock_registry = registry
|
||||
|
||||
|
||||
@then("the actor add should succeed with the positional name")
|
||||
@@ -175,3 +199,34 @@ def step_impl(context: Any) -> None:
|
||||
f"Expected non-zero exit_code, got {context.result.exit_code}.\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the actor add should succeed using the config name")
|
||||
def step_impl(context: Any) -> None:
|
||||
assert context.result.exit_code == 0, (
|
||||
f"Expected exit_code=0, got {context.result.exit_code}.\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
assert context.mock_registry.upsert_actor.called, (
|
||||
"Expected upsert_actor to be called on the registry"
|
||||
)
|
||||
call_kwargs = context.mock_registry.upsert_actor.call_args
|
||||
actual_name = call_kwargs.kwargs.get("name") or (
|
||||
call_kwargs.args[0] if call_kwargs.args else None
|
||||
)
|
||||
expected_name = context.actor_config_data.get("name")
|
||||
assert actual_name == expected_name, (
|
||||
f"Expected upsert_actor called with name={expected_name!r} (from config), "
|
||||
f"got name={actual_name!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the actor add should fail with a BadParameter error about missing actor name")
|
||||
def step_impl(context: Any) -> None:
|
||||
assert context.result.exit_code != 0, (
|
||||
f"Expected non-zero exit_code, got {context.result.exit_code}.\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
assert "Actor name is required" in context.result.output, (
|
||||
f"Expected 'Actor name is required' in output:\n{context.result.output}"
|
||||
)
|
||||
|
||||
@@ -107,7 +107,7 @@ def step_when_add_without_update(context: Any) -> None:
|
||||
mock_svc.return_value = (MagicMock(), registry)
|
||||
context.result = context.runner.invoke(
|
||||
actor_app,
|
||||
["add", "--config", str(context.actor_config_path)],
|
||||
["add", context.actor_name, "--config", str(context.actor_config_path)],
|
||||
)
|
||||
|
||||
|
||||
@@ -122,7 +122,13 @@ def step_when_add_with_update(context: Any) -> None:
|
||||
mock_svc.return_value = (MagicMock(), registry)
|
||||
context.result = context.runner.invoke(
|
||||
actor_app,
|
||||
["add", "--config", str(context.actor_config_path), "--update"],
|
||||
[
|
||||
"add",
|
||||
context.actor_name,
|
||||
"--config",
|
||||
str(context.actor_config_path),
|
||||
"--update",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Step definitions for devcontainer_autodiscovery_wiring.feature.
|
||||
|
||||
Tests that discover_devcontainers() is correctly wired into
|
||||
GitCheckoutHandler.discover_children() and FsDirectoryHandler.discover_children().
|
||||
|
||||
All steps use the ``dcwire`` prefix to avoid Behave AmbiguousStep errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.domain.models.core.resource import PhysVirt, Resource
|
||||
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
|
||||
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
|
||||
|
||||
_VALID_ULID = "01JQDVHN5X5QJKBMZ3AP8Y4G7K"
|
||||
_DEVCONTAINER_JSON = json.dumps({"name": "Test Dev Container", "image": "ubuntu:22.04"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_git_resource(location: str) -> Resource:
|
||||
"""Create a minimal git-checkout Resource."""
|
||||
return Resource(
|
||||
resource_id=_VALID_ULID,
|
||||
name="test-repo",
|
||||
resource_type_name="git-checkout",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
description="Test git checkout resource",
|
||||
location=location,
|
||||
)
|
||||
|
||||
|
||||
def _make_fs_resource(location: str) -> Resource:
|
||||
"""Create a minimal fs-directory Resource."""
|
||||
return Resource(
|
||||
resource_id=_VALID_ULID,
|
||||
name="test-dir",
|
||||
resource_type_name="fs-directory",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
description="Test filesystem directory resource",
|
||||
location=location,
|
||||
)
|
||||
|
||||
|
||||
def _init_git_repo(tmpdir: str, extra_files: dict[str, str] | None = None) -> str:
|
||||
"""Initialise a bare-minimum git repo with an initial commit."""
|
||||
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@dcwire.dev"],
|
||||
cwd=tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "DCWire Test"],
|
||||
cwd=tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "commit.gpgSign", "false"],
|
||||
cwd=tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
# Always create a README so there is at least one tracked file
|
||||
readme = Path(tmpdir) / "README.md"
|
||||
readme.write_text("# dcwire test\n")
|
||||
|
||||
if extra_files:
|
||||
for rel_path, file_content in extra_files.items():
|
||||
full = Path(tmpdir) / rel_path
|
||||
full.parent.mkdir(parents=True, exist_ok=True)
|
||||
full.write_text(file_content)
|
||||
|
||||
subprocess.run(["git", "add", "."], cwd=tmpdir, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "init"],
|
||||
cwd=tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return tmpdir
|
||||
|
||||
|
||||
def _create_devcontainer_dir(base: str, rel_path: str) -> None:
|
||||
"""Create a devcontainer.json at the given relative path inside base."""
|
||||
full = Path(base) / rel_path
|
||||
full.parent.mkdir(parents=True, exist_ok=True)
|
||||
full.write_text(_DEVCONTAINER_JSON)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GIVEN steps — git-checkout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('dcwire a git repo with a ".devcontainer/devcontainer.json" file')
|
||||
def step_dcwire_git_with_root_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer/devcontainer.json")
|
||||
_init_git_repo(tmpdir, {".devcontainer/devcontainer.json": _DEVCONTAINER_JSON})
|
||||
ctx.dcwire_handler = GitCheckoutHandler()
|
||||
ctx.dcwire_resource = _make_git_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given('dcwire a git repo with a ".devcontainer/api/devcontainer.json" named config')
|
||||
def step_dcwire_git_with_named_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-named-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer/api/devcontainer.json")
|
||||
_init_git_repo(tmpdir, {".devcontainer/api/devcontainer.json": _DEVCONTAINER_JSON})
|
||||
ctx.dcwire_handler = GitCheckoutHandler()
|
||||
ctx.dcwire_resource = _make_git_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given("dcwire a git repo with no devcontainer configuration")
|
||||
def step_dcwire_git_no_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-nodc-")
|
||||
_init_git_repo(tmpdir)
|
||||
ctx.dcwire_handler = GitCheckoutHandler()
|
||||
ctx.dcwire_resource = _make_git_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given(
|
||||
'dcwire a git repo with a subdirectory "src" and a ".devcontainer/devcontainer.json" file'
|
||||
)
|
||||
def step_dcwire_git_with_src_and_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-src-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer/devcontainer.json")
|
||||
_init_git_repo(
|
||||
tmpdir,
|
||||
{
|
||||
"src/main.py": "print('hello')\n",
|
||||
".devcontainer/devcontainer.json": _DEVCONTAINER_JSON,
|
||||
},
|
||||
)
|
||||
ctx.dcwire_handler = GitCheckoutHandler()
|
||||
ctx.dcwire_resource = _make_git_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given('dcwire a git repo with a root-level ".devcontainer.json" file')
|
||||
def step_dcwire_git_with_root_level_devcontainer_json(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-rootdc-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer.json")
|
||||
_init_git_repo(tmpdir, {".devcontainer.json": _DEVCONTAINER_JSON})
|
||||
ctx.dcwire_handler = GitCheckoutHandler()
|
||||
ctx.dcwire_resource = _make_git_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GIVEN steps — fs-directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('dcwire a filesystem directory with a ".devcontainer/devcontainer.json" file')
|
||||
def step_dcwire_fs_with_root_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer/devcontainer.json")
|
||||
ctx.dcwire_handler = FsDirectoryHandler()
|
||||
ctx.dcwire_resource = _make_fs_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given(
|
||||
'dcwire a filesystem directory with a ".devcontainer/frontend/devcontainer.json" named config'
|
||||
)
|
||||
def step_dcwire_fs_with_named_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-named-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer/frontend/devcontainer.json")
|
||||
ctx.dcwire_handler = FsDirectoryHandler()
|
||||
ctx.dcwire_resource = _make_fs_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given("dcwire a filesystem directory with no devcontainer configuration")
|
||||
def step_dcwire_fs_no_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-nodc-")
|
||||
ctx.dcwire_handler = FsDirectoryHandler()
|
||||
ctx.dcwire_resource = _make_fs_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given(
|
||||
'dcwire a filesystem directory with a subdirectory "lib" and a ".devcontainer/devcontainer.json" file'
|
||||
)
|
||||
def step_dcwire_fs_with_lib_and_devcontainer(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-lib-")
|
||||
lib_dir = Path(tmpdir) / "lib"
|
||||
lib_dir.mkdir()
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer/devcontainer.json")
|
||||
ctx.dcwire_handler = FsDirectoryHandler()
|
||||
ctx.dcwire_resource = _make_fs_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
@given('dcwire a filesystem directory with a root-level ".devcontainer.json" file')
|
||||
def step_dcwire_fs_with_root_level_devcontainer_json(ctx):
|
||||
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-rootdc-")
|
||||
_create_devcontainer_dir(tmpdir, ".devcontainer.json")
|
||||
ctx.dcwire_handler = FsDirectoryHandler()
|
||||
ctx.dcwire_resource = _make_fs_resource(tmpdir)
|
||||
ctx.dcwire_result = None
|
||||
ctx.dcwire_error = None
|
||||
ctx.dcwire_last_dc_child = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHEN steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("dcwire I call discover_children on the git-checkout resource")
|
||||
def step_dcwire_discover_git(ctx):
|
||||
try:
|
||||
ctx.dcwire_result = ctx.dcwire_handler.discover_children(
|
||||
resource=ctx.dcwire_resource
|
||||
)
|
||||
except Exception as exc:
|
||||
ctx.dcwire_error = exc
|
||||
|
||||
|
||||
@when("dcwire I call discover_children on the fs-directory resource")
|
||||
def step_dcwire_discover_fs(ctx):
|
||||
try:
|
||||
ctx.dcwire_result = ctx.dcwire_handler.discover_children(
|
||||
resource=ctx.dcwire_resource
|
||||
)
|
||||
except Exception as exc:
|
||||
ctx.dcwire_error = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THEN steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('dcwire the children include a "devcontainer-instance" resource named "{name}"')
|
||||
def step_dcwire_has_devcontainer_child(ctx, name):
|
||||
assert ctx.dcwire_error is None, f"Unexpected error: {ctx.dcwire_error}"
|
||||
assert ctx.dcwire_result is not None, "Expected a list of children"
|
||||
dc_children = [
|
||||
r
|
||||
for r in ctx.dcwire_result
|
||||
if r.resource_type_name == "devcontainer-instance" and r.name == name
|
||||
]
|
||||
assert len(dc_children) == 1, (
|
||||
f"Expected exactly one devcontainer-instance named '{name}', "
|
||||
f"got {[r.name for r in ctx.dcwire_result if r.resource_type_name == 'devcontainer-instance']}"
|
||||
)
|
||||
ctx.dcwire_last_dc_child = dc_children[0]
|
||||
|
||||
|
||||
@then('dcwire the children include a "fs-directory" resource named "{name}"')
|
||||
def step_dcwire_has_fs_child(ctx, name):
|
||||
assert ctx.dcwire_error is None, f"Unexpected error: {ctx.dcwire_error}"
|
||||
assert ctx.dcwire_result is not None, "Expected a list of children"
|
||||
fs_children = [
|
||||
r
|
||||
for r in ctx.dcwire_result
|
||||
if r.resource_type_name == "fs-directory" and r.name == name
|
||||
]
|
||||
assert len(fs_children) == 1, (
|
||||
f"Expected exactly one fs-directory named '{name}', "
|
||||
f"got {[r.name for r in ctx.dcwire_result if r.resource_type_name == 'fs-directory']}"
|
||||
)
|
||||
|
||||
|
||||
@then('dcwire the devcontainer child has provisioning_state "{state}"')
|
||||
def step_dcwire_has_provisioning_state(ctx, state):
|
||||
assert ctx.dcwire_last_dc_child is not None, "No devcontainer child captured"
|
||||
props = ctx.dcwire_last_dc_child.properties or {}
|
||||
actual = props.get("provisioning_state")
|
||||
assert actual == state, f"Expected provisioning_state='{state}', got '{actual}'"
|
||||
|
||||
|
||||
@then("dcwire the devcontainer child has devcontainer_json_path set")
|
||||
def step_dcwire_has_json_path(ctx):
|
||||
assert ctx.dcwire_last_dc_child is not None, "No devcontainer child captured"
|
||||
props = ctx.dcwire_last_dc_child.properties or {}
|
||||
path_val = props.get("devcontainer_json_path")
|
||||
assert path_val, f"Expected devcontainer_json_path to be set, got {path_val!r}"
|
||||
assert "devcontainer.json" in path_val, (
|
||||
f"Expected devcontainer_json_path to contain 'devcontainer.json', got {path_val!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('dcwire the devcontainer child has config_name "{config_name}"')
|
||||
def step_dcwire_has_config_name(ctx, config_name):
|
||||
assert ctx.dcwire_last_dc_child is not None, "No devcontainer child captured"
|
||||
props = ctx.dcwire_last_dc_child.properties or {}
|
||||
actual = props.get("config_name")
|
||||
assert actual == config_name, (
|
||||
f"Expected config_name='{config_name}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
@then("dcwire no devcontainer-instance children are present")
|
||||
def step_dcwire_no_devcontainer_children(ctx):
|
||||
assert ctx.dcwire_error is None, f"Unexpected error: {ctx.dcwire_error}"
|
||||
assert ctx.dcwire_result is not None, "Expected a list of children"
|
||||
dc_children = [
|
||||
r for r in ctx.dcwire_result if r.resource_type_name == "devcontainer-instance"
|
||||
]
|
||||
assert len(dc_children) == 0, (
|
||||
f"Expected no devcontainer-instance children, got {[r.name for r in dc_children]}"
|
||||
)
|
||||
@@ -755,7 +755,7 @@ def step_try_create_plan_empty_desc(context: Context) -> None:
|
||||
context.pydantic_error = exc
|
||||
|
||||
|
||||
@then("a Pydantic validation error should be raised")
|
||||
@then("an Edge Case Pydantic validation error should be raised")
|
||||
def step_check_pydantic_error(context: Context) -> None:
|
||||
assert context.pydantic_error is not None, "Expected a Pydantic validation error"
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ def step_attempt_rollback(context):
|
||||
)
|
||||
|
||||
|
||||
@then("the rollback result should be False")
|
||||
@then("the executor rollback result should be False")
|
||||
def step_verify_rollback_false(context):
|
||||
"""Verify the rollback result is False."""
|
||||
assert context.rollback_result is False
|
||||
|
||||
@@ -331,7 +331,7 @@ def step_json_contains(context: Context, text: str) -> None:
|
||||
assert text in context.pe_json_output, f"Expected '{text}' in JSON output"
|
||||
|
||||
|
||||
@then("the json output should be valid json")
|
||||
@then("the plan explain json output should be valid")
|
||||
def step_json_valid(context: Context) -> None:
|
||||
parsed = json.loads(context.pe_json_output)
|
||||
assert isinstance(parsed, dict), "Expected a JSON object"
|
||||
|
||||
@@ -184,7 +184,7 @@ def step_create_plan_with_description(context: Context, description: str) -> Non
|
||||
context.plan = _default_plan(description=description)
|
||||
|
||||
|
||||
@given("I create a plan in strategize phase")
|
||||
@given("I create a PlanModel in strategize phase")
|
||||
def step_create_plan_strategize_phase(context: Context) -> None:
|
||||
"""Create a plan in STRATEGIZE phase."""
|
||||
context.plan = _default_plan(
|
||||
@@ -204,7 +204,7 @@ def step_create_plan_strategize_with_action_state(context: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
@given("I create a plan in strategize phase with errored state")
|
||||
@given("I create a PlanModel in strategize phase with errored state")
|
||||
def step_create_plan_strategize_errored(context: Context) -> None:
|
||||
"""Create a plan in STRATEGIZE phase with ERRORED state."""
|
||||
context.plan = _default_plan(
|
||||
@@ -214,7 +214,7 @@ def step_create_plan_strategize_errored(context: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
@given("I create a plan in strategize phase with processing state")
|
||||
@given("I create a PlanModel in strategize phase with processing state")
|
||||
def step_create_plan_strategize_processing(context: Context) -> None:
|
||||
"""Create a plan in STRATEGIZE phase with PROCESSING state."""
|
||||
context.plan = _default_plan(
|
||||
@@ -268,7 +268,7 @@ def step_create_plan_action_available(context: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
@given("I create a plan in strategize phase with cancelled state")
|
||||
@given("I create a PlanModel in strategize phase with cancelled state")
|
||||
def step_create_plan_strategize_cancelled(context: Context) -> None:
|
||||
"""Create a plan in STRATEGIZE phase with CANCELLED processing state."""
|
||||
context.plan = _default_plan(
|
||||
|
||||
@@ -492,7 +492,7 @@ def step_pr_remove_link_by_id(context: Any, link_id: str) -> None:
|
||||
context.pr_remove_result = context.pr_link_repo.remove_link(link_id)
|
||||
|
||||
|
||||
@then("the remove result should be False")
|
||||
@then("the project repo remove result should be False")
|
||||
def step_pr_remove_false(context: Any) -> None:
|
||||
assert context.pr_remove_result is False
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ def step_settings_with_overrides(context: Any, value: int) -> None:
|
||||
os.environ.pop("CLEVERAGENTS_RETRY_SERVICE_OVERRIDES", None)
|
||||
|
||||
|
||||
@when("I create a ServiceRetryWiring from those Settings")
|
||||
@when("I create a ServiceRetryWiring from those retry Settings")
|
||||
def step_create_wiring_from_settings(context: Any) -> None:
|
||||
from cleveragents.application.services.service_retry_wiring import (
|
||||
ServiceRetryWiring,
|
||||
|
||||
@@ -447,7 +447,7 @@ def session_model_check_export_messages_count(context: Context) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I get the session CLI dict")
|
||||
@when("I get the session model CLI dict")
|
||||
def session_model_get_cli_dict(context: Context) -> None:
|
||||
"""Get the CLI dict for the session."""
|
||||
context.session_cli_dict = context.session_model.as_cli_dict()
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Step definitions for tdd_session_create_suppress_exception.feature.
|
||||
|
||||
This test captures bug #10414: ``session create`` in
|
||||
``src/cleveragents/cli/commands/session.py`` used
|
||||
``contextlib.suppress(Exception)`` to silently discard ALL exceptions raised
|
||||
by ``_facade_dispatch()`` without any logging. Programming errors,
|
||||
unexpected failures, and infrastructure issues in the facade dispatch were
|
||||
completely invisible.
|
||||
|
||||
The fix replaces the suppress block with a ``try/except Exception`` that
|
||||
calls ``_log.warning(..., exc_info=True)`` so that the exception is
|
||||
recorded but the session creation remains non-fatal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.cli.commands import session as session_mod
|
||||
from cleveragents.cli.commands.session import app as session_app
|
||||
from cleveragents.domain.models.core.session import Session
|
||||
|
||||
# A distinctive error message to confirm the right exception was raised.
|
||||
_DISPATCH_ERROR_MESSAGE = "facade dispatch failed for tdd_10414"
|
||||
|
||||
|
||||
def _make_mock_session() -> MagicMock:
|
||||
"""Build a minimal mock Session object sufficient for the create command."""
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_session.session_id = "01JTEST000000000000000000000"
|
||||
mock_session.actor_name = None
|
||||
mock_session.namespace = "default"
|
||||
mock_session.message_count = 0
|
||||
mock_session.created_at = datetime(2026, 1, 1, 0, 0, 0)
|
||||
mock_session.updated_at = datetime(2026, 1, 1, 0, 0, 0)
|
||||
return mock_session
|
||||
|
||||
|
||||
@given("a session create command with a mocked session service")
|
||||
def step_given_mocked_session_service(context: Context) -> None:
|
||||
"""Set up a CLI runner with a mocked session service.
|
||||
|
||||
The mock service returns a valid session so that the create command
|
||||
proceeds past the service call and reaches the ``_facade_dispatch``
|
||||
try/except block.
|
||||
"""
|
||||
context.runner = CliRunner()
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.create.return_value = _make_mock_session()
|
||||
|
||||
# Patch the module-level service so the CLI uses our mock.
|
||||
context._orig_service = session_mod._service
|
||||
session_mod._service = mock_service
|
||||
|
||||
def _cleanup() -> None:
|
||||
session_mod._service = context._orig_service
|
||||
|
||||
context.add_cleanup(_cleanup)
|
||||
|
||||
|
||||
@given("the facade dispatch is patched to raise a RuntimeError")
|
||||
def step_given_facade_dispatch_raises(context: Context) -> None:
|
||||
"""Patch ``_facade_dispatch`` so it always raises a RuntimeError.
|
||||
|
||||
This simulates a real failure in the facade layer (e.g. the A2A
|
||||
bootstrap is unavailable) and exercises the ``try/except`` block in
|
||||
the ``create`` command.
|
||||
"""
|
||||
context._facade_patcher = patch(
|
||||
"cleveragents.cli.commands.session._facade_dispatch",
|
||||
side_effect=RuntimeError(_DISPATCH_ERROR_MESSAGE),
|
||||
)
|
||||
context._facade_patcher.start()
|
||||
|
||||
def _cleanup() -> None:
|
||||
context._facade_patcher.stop()
|
||||
|
||||
context.add_cleanup(_cleanup)
|
||||
|
||||
|
||||
@when("I invoke the session create command via the CLI runner")
|
||||
def step_when_invoke_create(context: Context) -> None:
|
||||
"""Invoke ``session create`` and capture log records during the call."""
|
||||
# Capture WARNING-level log records from the session module logger.
|
||||
session_logger = logging.getLogger("cleveragents.cli.commands.session")
|
||||
handler = _CapturingHandler()
|
||||
handler.setLevel(logging.WARNING)
|
||||
session_logger.addHandler(handler)
|
||||
session_logger.setLevel(logging.WARNING)
|
||||
|
||||
try:
|
||||
context.result = context.runner.invoke(session_app, ["create"])
|
||||
finally:
|
||||
session_logger.removeHandler(handler)
|
||||
|
||||
context.captured_log_records = handler.records
|
||||
|
||||
|
||||
class _CapturingHandler(logging.Handler):
|
||||
"""A logging handler that stores all emitted records in a list."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.records: list[logging.LogRecord] = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
@then("a WARNING log entry should have been emitted for the facade dispatch failure")
|
||||
def step_then_warning_logged(context: Context) -> None:
|
||||
"""Assert that a WARNING log entry was emitted when the facade dispatch raised.
|
||||
|
||||
The fix replaces ``contextlib.suppress(Exception)`` with a
|
||||
``try/except Exception`` block that calls ``_log.warning(..., exc_info=True)``.
|
||||
This assertion verifies the fix is in place.
|
||||
"""
|
||||
# The session create command should still succeed (non-fatal).
|
||||
assert context.result.exit_code == 0, (
|
||||
f"Expected session create to exit 0 even when facade dispatch fails, "
|
||||
f"but got exit code {context.result.exit_code}.\n"
|
||||
f"Output:\n{context.result.output}"
|
||||
)
|
||||
|
||||
# Assert that a WARNING log record was emitted.
|
||||
warning_records = [
|
||||
r for r in context.captured_log_records if r.levelno >= logging.WARNING
|
||||
]
|
||||
assert warning_records, (
|
||||
f"Bug #10414: No WARNING log record was emitted when _facade_dispatch() "
|
||||
f"raised a RuntimeError inside the try/except block. "
|
||||
f"Captured records: {context.captured_log_records}"
|
||||
)
|
||||
|
||||
# Assert the log record references the facade dispatch failure.
|
||||
all_messages = " ".join(r.getMessage() for r in warning_records)
|
||||
assert _DISPATCH_ERROR_MESSAGE in all_messages or any(
|
||||
r.exc_info is not None for r in warning_records
|
||||
), (
|
||||
f"Bug #10414: WARNING log record was found but did not contain the "
|
||||
f"expected error message {_DISPATCH_ERROR_MESSAGE!r} or exc_info. "
|
||||
f"Log messages: {all_messages!r}"
|
||||
)
|
||||
@@ -25,7 +25,6 @@ Feature: TDD Issue #988 — ReactiveEventBus.emit() swallows exception details
|
||||
When I emit an event that triggers the failing handler
|
||||
Then the warning log should contain the exception message text
|
||||
|
||||
@tdd_expected_fail
|
||||
Scenario: Bug #988 — emit() logs traceback via exc_info when handler raises
|
||||
Given a ReactiveEventBus with a handler that raises a ValueError
|
||||
When I emit an event that triggers the failing handler
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
@tdd_issue @tdd_issue_10414
|
||||
Feature: TDD Issue #10414 — session create silently suppresses facade dispatch exceptions without logging
|
||||
As a developer debugging a session creation failure
|
||||
I want exceptions from _facade_dispatch() to be logged at WARNING level
|
||||
So that I can diagnose why the facade layer failed without silent data loss
|
||||
|
||||
The ``session create`` command in
|
||||
``src/cleveragents/cli/commands/session.py`` previously used
|
||||
``contextlib.suppress(Exception)`` to silently discard ALL exceptions
|
||||
raised by ``_facade_dispatch()``. There was no logging call before or
|
||||
after the suppress block, making it impossible to diagnose failures in
|
||||
the facade layer.
|
||||
|
||||
The fix replaces the suppress block with a ``try/except Exception`` that
|
||||
calls ``_log.warning(..., exc_info=True)`` so that the exception is
|
||||
recorded but the session creation remains non-fatal.
|
||||
|
||||
See CONTRIBUTING.md > Bug Fix Workflow > TDD Issue Test Tags.
|
||||
|
||||
@tdd_issue @tdd_issue_10414
|
||||
Scenario: Bug #10414 — session create logs a warning when facade dispatch raises
|
||||
Given a session create command with a mocked session service
|
||||
And the facade dispatch is patched to raise a RuntimeError
|
||||
When I invoke the session create command via the CLI runner
|
||||
Then a WARNING log entry should have been emitted for the facade dispatch failure
|
||||
@@ -23,8 +23,7 @@ TDD Resource Add Succeeds Without Explicit Init
|
||||
... The helper exits 0 with a sentinel when the command
|
||||
... succeeds (bug is fixed), and exits 1 when the bug is
|
||||
... present (OperationalError).
|
||||
[Tags] tdd_issue tdd_issue_1023 tdd_issue tdd_issue_4317 tdd_expected_fail
|
||||
|
||||
[Tags] tdd_issue tdd_issue_1023 tdd_issue_4317 tdd_expected_fail
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resource-add-no-init cwd=${WORKSPACE} timeout=120s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
@@ -38,8 +37,7 @@ TDD Project Create Succeeds Without Explicit Init
|
||||
... The helper exits 0 with a sentinel when the command
|
||||
... succeeds (bug is fixed), and exits 1 when the bug is
|
||||
... present (OperationalError).
|
||||
[Tags] tdd_issue tdd_issue_1023 tdd_issue tdd_issue_4317 tdd_expected_fail
|
||||
|
||||
[Tags] tdd_issue tdd_issue_1023 tdd_issue_4317 tdd_expected_fail
|
||||
${result}= Run Process ${PYTHON} ${HELPER} project-create-no-init cwd=${WORKSPACE} timeout=120s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
|
||||
@@ -5,8 +5,8 @@ technology-specific vocabulary extensions, and the DetailLevelMap
|
||||
inheritance mechanism for resolving named detail levels across the
|
||||
ontology hierarchy (Layer 3 -> Layer 2 -> Layer 1 -> Layer 0).
|
||||
|
||||
Also provides the ACMS index data model and file traversal engine for
|
||||
indexing large projects.
|
||||
Also provides context policy configuration loading and plan execution
|
||||
integration for flexible context policy management.
|
||||
|
||||
Based on ``docs/specification.md`` ~lines 42333-42422, 44405-44420.
|
||||
"""
|
||||
@@ -14,12 +14,15 @@ Based on ``docs/specification.md`` ~lines 42333-42422, 44405-44420.
|
||||
from __future__ import annotations
|
||||
|
||||
from cleveragents.acms import uko as _uko
|
||||
from cleveragents.acms.index import (
|
||||
ACMSIndex,
|
||||
FileTraversalEngine,
|
||||
FileType,
|
||||
IndexEntry,
|
||||
TierLevel,
|
||||
from cleveragents.acms.context_policy_loader import (
|
||||
ContextPolicyConfig,
|
||||
ContextPolicyConfigurationLoader,
|
||||
PolicyScope,
|
||||
ViewPolicyConfiguration,
|
||||
)
|
||||
from cleveragents.acms.plan_execution_integration import (
|
||||
ACMSContextAssembler,
|
||||
PlanExecutionACMSIntegration,
|
||||
)
|
||||
from cleveragents.acms.uko import (
|
||||
CODE_DETAIL_LEVEL_MAP,
|
||||
@@ -72,14 +75,59 @@ from cleveragents.acms.uko import (
|
||||
resolve_detail_level,
|
||||
)
|
||||
|
||||
# Combine exports from both uko and index modules
|
||||
_uko_exports = list(_uko.__all__)
|
||||
_index_exports = [
|
||||
"ACMSIndex",
|
||||
"FileTraversalEngine",
|
||||
"FileType",
|
||||
"IndexEntry",
|
||||
"TierLevel",
|
||||
__all__ = [
|
||||
"CODE_DETAIL_LEVEL_MAP",
|
||||
"FUNC_DETAIL_LEVEL_MAP",
|
||||
"JAVA_DETAIL_LEVELS",
|
||||
"JAVA_VOCABULARY",
|
||||
"OO_DETAIL_LEVEL_MAP",
|
||||
"PROC_DETAIL_LEVEL_MAP",
|
||||
"PYTHON_DETAIL_LEVELS",
|
||||
"PYTHON_VOCABULARY",
|
||||
"RUST_DETAIL_LEVELS",
|
||||
"RUST_VOCABULARY",
|
||||
"TYPESCRIPT_DETAIL_LEVELS",
|
||||
"TYPESCRIPT_VOCABULARY",
|
||||
"ACMSContextAssembler",
|
||||
"ContextPolicyConfig",
|
||||
"ContextPolicyConfigurationLoader",
|
||||
"DetailLevelMapBuilder",
|
||||
"DuplicateVocabularyError",
|
||||
"JavaAnnotation",
|
||||
"JavaCheckedException",
|
||||
"JavaClass",
|
||||
"JavaInterface",
|
||||
"JavaMethod",
|
||||
"Layer2Dependency",
|
||||
"ParadigmVocabulary",
|
||||
"PlanExecutionACMSIntegration",
|
||||
"PolicyScope",
|
||||
"ProvenanceInfo",
|
||||
"PythonClass",
|
||||
"PythonDecorator",
|
||||
"PythonFunction",
|
||||
"PythonModule",
|
||||
"PythonTypeStub",
|
||||
"RustDeriveAttribute",
|
||||
"RustFunction",
|
||||
"RustImpl",
|
||||
"RustStruct",
|
||||
"RustTrait",
|
||||
"TypeScriptClass",
|
||||
"TypeScriptFunction",
|
||||
"TypeScriptInterface",
|
||||
"TypeScriptModule",
|
||||
"UKOClass",
|
||||
"UKOProperty",
|
||||
"UKOVocabulary",
|
||||
"ViewPolicyConfiguration",
|
||||
"VocabularyClass",
|
||||
"VocabularyProperty",
|
||||
"VocabularyRegistry",
|
||||
"build_detail_level_map",
|
||||
"build_effective_map",
|
||||
"get_func_vocabulary",
|
||||
"get_oo_vocabulary",
|
||||
"get_proc_vocabulary",
|
||||
"resolve_detail_level",
|
||||
]
|
||||
|
||||
__all__: list[str] = _uko_exports + _index_exports
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Context policy configuration loader for ACMS.
|
||||
|
||||
This module provides functionality to load and validate context policy
|
||||
configurations from YAML/TOML files, supporting per-view policy application
|
||||
with scope rules, priority weights, and budget overrides.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyScope:
|
||||
"""Represents a scope rule for context policy application."""
|
||||
|
||||
name: str
|
||||
"""Name of the scope (e.g., 'file_type', 'path_pattern')."""
|
||||
|
||||
value: str | list[str]
|
||||
"""Value or list of values for the scope."""
|
||||
|
||||
def matches(self, context: dict[str, Any]) -> bool:
|
||||
"""Check if this scope matches the given context.
|
||||
|
||||
Args:
|
||||
context: Context dictionary to match against.
|
||||
|
||||
Returns:
|
||||
True if the scope matches, False otherwise.
|
||||
"""
|
||||
if self.name not in context:
|
||||
return False
|
||||
|
||||
context_value = context[self.name]
|
||||
scope_values = self.value if isinstance(self.value, list) else [self.value]
|
||||
|
||||
if isinstance(context_value, list):
|
||||
return any(cv in scope_values for cv in context_value)
|
||||
return context_value in scope_values
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextPolicyConfig:
|
||||
"""Configuration for a context policy."""
|
||||
|
||||
name: str
|
||||
"""Name of the policy."""
|
||||
|
||||
description: str | None = None
|
||||
"""Description of the policy."""
|
||||
|
||||
scopes: list[PolicyScope] = field(default_factory=list)
|
||||
"""List of scope rules for this policy."""
|
||||
|
||||
priority_weight: float = 1.0
|
||||
"""Priority weight for this policy (higher = more important)."""
|
||||
|
||||
budget_override: int | None = None
|
||||
"""Optional budget override in tokens."""
|
||||
|
||||
enabled: bool = True
|
||||
"""Whether this policy is enabled."""
|
||||
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
"""Additional metadata for the policy."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ViewPolicyConfiguration:
|
||||
"""Configuration for policies applied to a specific view."""
|
||||
|
||||
view_name: str
|
||||
"""Name of the view."""
|
||||
|
||||
policies: list[ContextPolicyConfig] = field(default_factory=list)
|
||||
"""List of policies for this view."""
|
||||
|
||||
default_priority_weight: float = 1.0
|
||||
"""Default priority weight for policies in this view."""
|
||||
|
||||
default_budget: int | None = None
|
||||
"""Default budget for this view."""
|
||||
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
"""Additional metadata for the view."""
|
||||
|
||||
|
||||
class ContextPolicyConfigurationLoader:
|
||||
"""Loader for context policy configurations from YAML/TOML files."""
|
||||
|
||||
SUPPORTED_FORMATS: ClassVar[set[str]] = {"yaml", "yml", "toml"}
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the configuration loader."""
|
||||
pass
|
||||
|
||||
def load(self, config_path: str | Path) -> ViewPolicyConfiguration:
|
||||
"""Load context policy configuration from a file.
|
||||
|
||||
Args:
|
||||
config_path: Path to the configuration file (YAML or TOML).
|
||||
|
||||
Returns:
|
||||
ViewPolicyConfiguration instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the file format is not supported or config is invalid.
|
||||
FileNotFoundError: If the configuration file does not exist.
|
||||
"""
|
||||
config_path = Path(config_path)
|
||||
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
||||
|
||||
file_format = config_path.suffix.lstrip(".").lower()
|
||||
|
||||
if file_format not in self.SUPPORTED_FORMATS:
|
||||
raise ValueError(
|
||||
f"Unsupported file format: {file_format}. "
|
||||
f"Supported formats: {', '.join(self.SUPPORTED_FORMATS)}"
|
||||
)
|
||||
|
||||
if file_format in ("yaml", "yml"):
|
||||
return self._load_yaml(config_path)
|
||||
elif file_format == "toml":
|
||||
return self._load_toml(config_path)
|
||||
|
||||
raise ValueError(f"Unsupported file format: {file_format}")
|
||||
|
||||
def _load_yaml(self, config_path: Path) -> ViewPolicyConfiguration:
|
||||
"""Load configuration from a YAML file.
|
||||
|
||||
Args:
|
||||
config_path: Path to the YAML configuration file.
|
||||
|
||||
Returns:
|
||||
ViewPolicyConfiguration instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the configuration is invalid.
|
||||
"""
|
||||
with config_path.open("r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Configuration must be a dictionary")
|
||||
|
||||
return self._parse_configuration(data)
|
||||
|
||||
def _load_toml(self, config_path: Path) -> ViewPolicyConfiguration:
|
||||
"""Load configuration from a TOML file.
|
||||
|
||||
Args:
|
||||
config_path: Path to the TOML configuration file.
|
||||
|
||||
Returns:
|
||||
ViewPolicyConfiguration instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the configuration is invalid.
|
||||
"""
|
||||
with config_path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
return self._parse_configuration(data)
|
||||
|
||||
def _parse_configuration(self, data: dict[str, Any]) -> ViewPolicyConfiguration:
|
||||
"""Parse configuration data into ViewPolicyConfiguration.
|
||||
|
||||
Args:
|
||||
data: Configuration data dictionary.
|
||||
|
||||
Returns:
|
||||
ViewPolicyConfiguration instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the configuration is invalid.
|
||||
"""
|
||||
self._validate_schema(data)
|
||||
|
||||
view_name = data.get("view_name", "default")
|
||||
default_priority_weight = data.get("default_priority_weight", 1.0)
|
||||
default_budget = data.get("default_budget")
|
||||
metadata = data.get("metadata", {})
|
||||
|
||||
policies = []
|
||||
for policy_data in data.get("policies", []):
|
||||
policy = self._parse_policy(policy_data, default_priority_weight)
|
||||
policies.append(policy)
|
||||
|
||||
return ViewPolicyConfiguration(
|
||||
view_name=view_name,
|
||||
policies=policies,
|
||||
default_priority_weight=default_priority_weight,
|
||||
default_budget=default_budget,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _parse_policy(
|
||||
self, policy_data: dict[str, Any], default_priority_weight: float
|
||||
) -> ContextPolicyConfig:
|
||||
"""Parse a single policy configuration.
|
||||
|
||||
Args:
|
||||
policy_data: Policy configuration data.
|
||||
default_priority_weight: Default priority weight to use.
|
||||
|
||||
Returns:
|
||||
ContextPolicyConfig instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the policy configuration is invalid.
|
||||
"""
|
||||
if not isinstance(policy_data, dict):
|
||||
raise ValueError("Policy must be a dictionary")
|
||||
|
||||
if "name" not in policy_data:
|
||||
raise ValueError("Policy must have a 'name' field")
|
||||
|
||||
name = policy_data["name"]
|
||||
description = policy_data.get("description")
|
||||
priority_weight = policy_data.get("priority_weight", default_priority_weight)
|
||||
budget_override = policy_data.get("budget_override")
|
||||
enabled = policy_data.get("enabled", True)
|
||||
metadata = policy_data.get("metadata", {})
|
||||
|
||||
scopes = []
|
||||
for scope_data in policy_data.get("scopes", []):
|
||||
scope = self._parse_scope(scope_data)
|
||||
scopes.append(scope)
|
||||
|
||||
return ContextPolicyConfig(
|
||||
name=name,
|
||||
description=description,
|
||||
scopes=scopes,
|
||||
priority_weight=priority_weight,
|
||||
budget_override=budget_override,
|
||||
enabled=enabled,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _parse_scope(self, scope_data: dict[str, Any]) -> PolicyScope:
|
||||
"""Parse a single scope configuration.
|
||||
|
||||
Args:
|
||||
scope_data: Scope configuration data.
|
||||
|
||||
Returns:
|
||||
PolicyScope instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the scope configuration is invalid.
|
||||
"""
|
||||
if not isinstance(scope_data, dict):
|
||||
raise ValueError("Scope must be a dictionary")
|
||||
|
||||
if "name" not in scope_data:
|
||||
raise ValueError("Scope must have a 'name' field")
|
||||
|
||||
if "value" not in scope_data:
|
||||
raise ValueError("Scope must have a 'value' field")
|
||||
|
||||
return PolicyScope(
|
||||
name=scope_data["name"],
|
||||
value=scope_data["value"],
|
||||
)
|
||||
|
||||
def _validate_schema(self, data: dict[str, Any]) -> None:
|
||||
"""Validate the configuration schema.
|
||||
|
||||
Args:
|
||||
data: Configuration data to validate.
|
||||
|
||||
Raises:
|
||||
ValueError: If the schema is invalid.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Configuration must be a dictionary")
|
||||
|
||||
# Validate top-level fields
|
||||
allowed_fields = {
|
||||
"view_name",
|
||||
"policies",
|
||||
"default_priority_weight",
|
||||
"default_budget",
|
||||
"metadata",
|
||||
}
|
||||
for field_name in data:
|
||||
if field_name not in allowed_fields:
|
||||
raise ValueError(f"Unknown field: {field_name}")
|
||||
|
||||
# Validate policies
|
||||
policies = data.get("policies", [])
|
||||
if not isinstance(policies, list):
|
||||
raise ValueError("'policies' must be a list")
|
||||
|
||||
for i, policy in enumerate(policies):
|
||||
if not isinstance(policy, dict):
|
||||
raise ValueError(f"Policy {i} must be a dictionary")
|
||||
|
||||
if "name" not in policy:
|
||||
raise ValueError(f"Policy {i} must have a 'name' field")
|
||||
|
||||
# Validate scopes
|
||||
scopes = policy.get("scopes", [])
|
||||
if not isinstance(scopes, list):
|
||||
raise ValueError(f"Policy {i} 'scopes' must be a list")
|
||||
|
||||
for j, scope in enumerate(scopes):
|
||||
if not isinstance(scope, dict):
|
||||
raise ValueError(f"Policy {i} scope {j} must be a dictionary")
|
||||
|
||||
if "name" not in scope:
|
||||
raise ValueError(f"Policy {i} scope {j} must have a 'name' field")
|
||||
|
||||
if "value" not in scope:
|
||||
raise ValueError(f"Policy {i} scope {j} must have a 'value' field")
|
||||
|
||||
# Validate numeric fields
|
||||
if "default_priority_weight" in data and not isinstance(
|
||||
data["default_priority_weight"], (int, float)
|
||||
):
|
||||
raise ValueError("'default_priority_weight' must be a number")
|
||||
|
||||
if (
|
||||
"default_budget" in data
|
||||
and data["default_budget"] is not None
|
||||
and not isinstance(data["default_budget"], int)
|
||||
):
|
||||
raise ValueError("'default_budget' must be an integer or null")
|
||||
|
||||
def load_from_string(
|
||||
self, config_string: str, fmt: str = "yaml"
|
||||
) -> ViewPolicyConfiguration:
|
||||
"""Load configuration from a string.
|
||||
|
||||
Args:
|
||||
config_string: Configuration string.
|
||||
fmt: Format of the string ('yaml' or 'toml').
|
||||
|
||||
Returns:
|
||||
ViewPolicyConfiguration instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the format is not supported or config is invalid.
|
||||
"""
|
||||
if fmt not in self.SUPPORTED_FORMATS:
|
||||
raise ValueError(
|
||||
f"Unsupported format: {fmt}. "
|
||||
f"Supported formats: {', '.join(self.SUPPORTED_FORMATS)}"
|
||||
)
|
||||
|
||||
if fmt in ("yaml", "yml"):
|
||||
data = yaml.safe_load(config_string)
|
||||
elif fmt == "toml":
|
||||
data = tomllib.loads(config_string)
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {fmt}")
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Configuration must be a dictionary")
|
||||
|
||||
return self._parse_configuration(data)
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Plan execution integration with ACMS context assembly.
|
||||
|
||||
This module integrates the ACMS context assembly pipeline with the plan
|
||||
execution engine, ensuring that LLM calls use ACMS-assembled context instead
|
||||
of raw file dumps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.acms.context_policy_loader import (
|
||||
ContextPolicyConfigurationLoader,
|
||||
PolicyScope,
|
||||
ViewPolicyConfiguration,
|
||||
)
|
||||
|
||||
|
||||
class ACMSContextAssembler:
|
||||
"""Assembles context using ACMS policies for plan execution."""
|
||||
|
||||
def __init__(self, policy_config: ViewPolicyConfiguration) -> None:
|
||||
"""Initialize the ACMS context assembler.
|
||||
|
||||
Args:
|
||||
policy_config: View policy configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If policy_config is None.
|
||||
"""
|
||||
if policy_config is None:
|
||||
raise ValueError("policy_config must not be None")
|
||||
self.policy_config = policy_config
|
||||
self.enabled_policies = [p for p in policy_config.policies if p.enabled]
|
||||
|
||||
def assemble_context(self, raw_context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Assemble context using ACMS policies.
|
||||
|
||||
Args:
|
||||
raw_context: Raw context data (e.g., file dumps).
|
||||
|
||||
Returns:
|
||||
ACMS-assembled context dictionary.
|
||||
"""
|
||||
assembled_context: dict[str, Any] = {
|
||||
"view": self.policy_config.view_name,
|
||||
"policies_applied": [],
|
||||
"assembled_data": {},
|
||||
}
|
||||
|
||||
# Sort policies by priority weight (descending)
|
||||
sorted_policies = sorted(
|
||||
self.enabled_policies,
|
||||
key=lambda p: p.priority_weight,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
for policy in sorted_policies:
|
||||
# Check if policy scopes match the context
|
||||
if self._scopes_match(policy.scopes, raw_context):
|
||||
assembled_context["policies_applied"].append(policy.name)
|
||||
# Apply policy transformations
|
||||
policy_context = self._apply_policy(policy, raw_context)
|
||||
assembled_context["assembled_data"].update(policy_context)
|
||||
|
||||
return assembled_context
|
||||
|
||||
def _scopes_match(self, scopes: list[PolicyScope], context: dict[str, Any]) -> bool:
|
||||
"""Check if all scopes match the given context.
|
||||
|
||||
Args:
|
||||
scopes: List of policy scopes.
|
||||
context: Context to match against.
|
||||
|
||||
Returns:
|
||||
True if all scopes match, False otherwise.
|
||||
"""
|
||||
if not scopes:
|
||||
return True
|
||||
|
||||
return all(scope.matches(context) for scope in scopes)
|
||||
|
||||
def _apply_policy(self, policy: Any, raw_context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Apply a policy to the raw context.
|
||||
|
||||
Args:
|
||||
policy: Policy configuration.
|
||||
raw_context: Raw context data.
|
||||
|
||||
Returns:
|
||||
Transformed context data.
|
||||
"""
|
||||
policy_context: dict[str, Any] = {}
|
||||
|
||||
# Apply budget override if specified
|
||||
if policy.budget_override is not None:
|
||||
policy_context["budget"] = policy.budget_override
|
||||
|
||||
# Apply priority weight
|
||||
policy_context["priority"] = policy.priority_weight
|
||||
|
||||
# Include policy metadata
|
||||
if policy.metadata:
|
||||
policy_context["metadata"] = policy.metadata
|
||||
|
||||
# Include relevant raw context data
|
||||
for key, value in raw_context.items():
|
||||
if key not in policy_context:
|
||||
policy_context[key] = value
|
||||
|
||||
return policy_context
|
||||
|
||||
|
||||
class PlanExecutionACMSIntegration:
|
||||
"""Integrates ACMS context assembly into plan execution.
|
||||
|
||||
This class wires the ACMS context assembly pipeline into the plan
|
||||
execution engine. When a policy configuration is loaded, the
|
||||
``prepare_llm_context`` method assembles context using ACMS policies
|
||||
instead of passing raw file dumps to LLM calls.
|
||||
|
||||
Usage with the plan execution engine::
|
||||
|
||||
integration = PlanExecutionACMSIntegration()
|
||||
integration.load_policy_config("path/to/policy.yaml")
|
||||
|
||||
# In RuntimeExecuteActor.execute(), replace raw context with:
|
||||
llm_context = integration.prepare_llm_context(raw_context)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
policy_config: ViewPolicyConfiguration | None = None,
|
||||
) -> None:
|
||||
"""Initialize the plan execution ACMS integration.
|
||||
|
||||
Args:
|
||||
policy_config: Optional view policy configuration.
|
||||
"""
|
||||
self.policy_config = policy_config
|
||||
self.assembler: ACMSContextAssembler | None = None
|
||||
|
||||
if policy_config:
|
||||
self.assembler = ACMSContextAssembler(policy_config)
|
||||
|
||||
def prepare_llm_context(self, raw_context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Prepare context for LLM calls using ACMS assembly.
|
||||
|
||||
This method is the integration point between the plan execution
|
||||
engine and the ACMS context assembly pipeline. Call this method
|
||||
in place of passing raw file dumps to LLM calls.
|
||||
|
||||
Args:
|
||||
raw_context: Raw context data (e.g., file dumps).
|
||||
|
||||
Returns:
|
||||
ACMS-assembled context ready for LLM calls.
|
||||
"""
|
||||
if self.assembler is None:
|
||||
# If no policy config, return raw context as-is
|
||||
return raw_context
|
||||
|
||||
return self.assembler.assemble_context(raw_context)
|
||||
|
||||
def load_policy_config(self, config_path: str) -> None:
|
||||
"""Load policy configuration from a file.
|
||||
|
||||
Args:
|
||||
config_path: Path to the policy configuration file.
|
||||
"""
|
||||
loader = ContextPolicyConfigurationLoader()
|
||||
self.policy_config = loader.load(config_path)
|
||||
self.assembler = ACMSContextAssembler(self.policy_config)
|
||||
|
||||
def load_policy_config_from_string(
|
||||
self, config_string: str, fmt: str = "yaml"
|
||||
) -> None:
|
||||
"""Load policy configuration from a string.
|
||||
|
||||
Args:
|
||||
config_string: Configuration string.
|
||||
fmt: Format of the string ('yaml' or 'toml').
|
||||
"""
|
||||
loader = ContextPolicyConfigurationLoader()
|
||||
self.policy_config = loader.load_from_string(config_string, fmt)
|
||||
self.assembler = ACMSContextAssembler(self.policy_config)
|
||||
@@ -10,14 +10,16 @@ for capturing tool-call mutations during plan execution.
|
||||
phase and delegates changeset operations to a ``ChangeSetStore``.
|
||||
- ``RuntimeExecuteResult`` -- structured output from the runtime actor.
|
||||
- ``RuntimeExecuteActor`` -- wraps ``ToolRunner`` to execute strategy
|
||||
decisions with full changeset capture.
|
||||
decisions with full changeset capture. When an ACMS integration is
|
||||
provided, context is assembled via policy-driven decisions before LLM
|
||||
calls instead of using raw file dumps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -35,6 +37,11 @@ from cleveragents.domain.models.core.change import (
|
||||
from cleveragents.tool.context import BoundResource
|
||||
from cleveragents.tool.runner import ToolRunner
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.acms.plan_execution_integration import (
|
||||
PlanExecutionACMSIntegration,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Type alias for streaming callbacks
|
||||
@@ -288,6 +295,11 @@ class RuntimeExecuteActor:
|
||||
Wraps the tool-calling runtime to execute strategy decisions with
|
||||
full changeset capture via ``PlanExecutionContext``.
|
||||
|
||||
When an ``acms_integration`` is provided, each decision's raw context
|
||||
is assembled via ACMS policy-driven decisions before being passed to
|
||||
LLM calls. This ensures LLM calls receive properly scoped, budget-
|
||||
constrained context views instead of raw file dumps.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tool_runner:
|
||||
@@ -295,6 +307,9 @@ class RuntimeExecuteActor:
|
||||
execution_context:
|
||||
The ``PlanExecutionContext`` carrying plan metadata and
|
||||
changeset store.
|
||||
acms_integration:
|
||||
Optional ACMS integration for assembling context using
|
||||
policy-driven decisions. When ``None``, raw context is used.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -302,6 +317,7 @@ class RuntimeExecuteActor:
|
||||
*,
|
||||
tool_runner: ToolRunner,
|
||||
execution_context: PlanExecutionContext,
|
||||
acms_integration: PlanExecutionACMSIntegration | None = None,
|
||||
) -> None:
|
||||
if tool_runner is None:
|
||||
raise ValidationError("tool_runner must not be None")
|
||||
@@ -310,6 +326,7 @@ class RuntimeExecuteActor:
|
||||
|
||||
self._tool_runner = tool_runner
|
||||
self._execution_context = execution_context
|
||||
self._acms_integration = acms_integration
|
||||
self._logger = logger.bind(
|
||||
plan_id=execution_context.plan_id,
|
||||
component="runtime_execute_actor",
|
||||
@@ -325,6 +342,11 @@ class RuntimeExecuteActor:
|
||||
"""The execution context."""
|
||||
return self._execution_context
|
||||
|
||||
@property
|
||||
def acms_integration(self) -> PlanExecutionACMSIntegration | None:
|
||||
"""The ACMS integration, if configured."""
|
||||
return self._acms_integration
|
||||
|
||||
def execute(
|
||||
self,
|
||||
decisions: list[Any],
|
||||
@@ -378,6 +400,20 @@ class RuntimeExecuteActor:
|
||||
},
|
||||
)
|
||||
|
||||
# Assemble context via ACMS if integration is configured.
|
||||
# This replaces raw file dumps with policy-driven context views
|
||||
# for LLM calls, ensuring scoped and budget-constrained context.
|
||||
raw_context: dict[str, Any] = {
|
||||
"plan_id": plan_id,
|
||||
"decision_id": decision_id,
|
||||
"step_text": step_text,
|
||||
"sequence": sequence,
|
||||
}
|
||||
if self._acms_integration is not None:
|
||||
llm_context = self._acms_integration.prepare_llm_context(raw_context)
|
||||
else:
|
||||
llm_context = raw_context
|
||||
|
||||
# Discover available tools
|
||||
available_tools = self._tool_runner.discover()
|
||||
tool_call_count += len(available_tools)
|
||||
@@ -387,7 +423,12 @@ class RuntimeExecuteActor:
|
||||
plan_id=plan_id,
|
||||
tool_name="stub/execute-step",
|
||||
arguments={"step_text": step_text, "sequence": sequence},
|
||||
result={"status": "stub_executed", "tools_found": len(available_tools)},
|
||||
result={
|
||||
"status": "stub_executed",
|
||||
"tools_found": len(available_tools),
|
||||
"acms_context_assembled": self._acms_integration is not None,
|
||||
"llm_context_keys": list(llm_context.keys()),
|
||||
},
|
||||
success=True,
|
||||
duration_ms=0.0,
|
||||
sandbox_path=self._execution_context.sandbox_root,
|
||||
@@ -409,6 +450,7 @@ class RuntimeExecuteActor:
|
||||
decision_id=decision_id,
|
||||
step_text=step_text,
|
||||
invocation_id=invocation.invocation_id,
|
||||
acms_context_assembled=self._acms_integration is not None,
|
||||
)
|
||||
|
||||
elapsed_ms = (time.monotonic() - start_time) * 1000.0
|
||||
|
||||
@@ -10,12 +10,13 @@ Updated in M5 to wire ``SubplanService`` and ``SubplanExecutionService``
|
||||
into the Execute phase so that ``subplan_spawn`` and
|
||||
``subplan_parallel_spawn`` decisions are realised as actual child plan
|
||||
executions.
|
||||
Updated in M6 to wire StrategyActor decisions through to Execute phase.
|
||||
Updated in M5 (ACMS) to wire ``PlanExecutionACMSIntegration`` into the
|
||||
Execute phase so that LLM calls use ACMS-assembled context instead of
|
||||
raw file dumps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
@@ -37,7 +38,6 @@ from cleveragents.application.services.plan_execution_context import (
|
||||
RuntimeExecuteActor,
|
||||
RuntimeExecuteResult,
|
||||
)
|
||||
from cleveragents.application.services.strategy_models import StrategyTree
|
||||
from cleveragents.core.exceptions import PlanError, ValidationError
|
||||
from cleveragents.domain.models.core.change import ChangeSetStore
|
||||
from cleveragents.domain.models.core.estimation import EstimationResult
|
||||
@@ -59,6 +59,9 @@ from cleveragents.tool.builtins.changeset import ChangeSet, ChangeSetCapture
|
||||
from cleveragents.tool.runner import ToolRunner
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.acms.plan_execution_integration import (
|
||||
PlanExecutionACMSIntegration,
|
||||
)
|
||||
from cleveragents.application.services.error_recovery_service import (
|
||||
ErrorRecoveryService,
|
||||
)
|
||||
@@ -101,10 +104,6 @@ class StrategizeResult(BaseModel):
|
||||
decision_root_id: str = Field(..., description="ULID of root decision node")
|
||||
decisions: list[StrategyDecision] = Field(default_factory=list)
|
||||
invariant_records: list[dict[str, Any]] = Field(default_factory=list)
|
||||
strategy_tree: StrategyTree | None = Field(
|
||||
default=None,
|
||||
description="The hierarchical strategy tree (populated by StrategyActor)",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
|
||||
|
||||
@@ -134,7 +133,6 @@ class StrategizeStubActor:
|
||||
definition_of_done: str | None,
|
||||
invariants: list[PlanInvariant] | None = None,
|
||||
stream_callback: StreamCallback | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> StrategizeResult:
|
||||
if not plan_id:
|
||||
raise ValidationError("plan_id must not be empty")
|
||||
@@ -326,6 +324,7 @@ class PlanExecutor:
|
||||
fix_revalidate_orchestrator: FixThenRevalidateOrchestrator | None = None,
|
||||
subplan_service: SubplanService | None = None,
|
||||
subplan_execution_service: SubplanExecutionService | None = None,
|
||||
acms_integration: PlanExecutionACMSIntegration | None = None,
|
||||
) -> None:
|
||||
"""Initialize the plan executor.
|
||||
|
||||
@@ -359,6 +358,10 @@ class PlanExecutor:
|
||||
subplan_execution_service: Optional service for executing
|
||||
spawned child plans. When ``None``, child plan
|
||||
execution is skipped even if subplans were spawned.
|
||||
acms_integration: Optional ACMS integration for assembling
|
||||
context using policy-driven decisions before LLM calls.
|
||||
When ``None``, raw context is passed to LLM calls
|
||||
without ACMS assembly.
|
||||
"""
|
||||
if lifecycle_service is None:
|
||||
raise ValidationError("lifecycle_service must not be None")
|
||||
@@ -373,6 +376,7 @@ class PlanExecutor:
|
||||
self._fix_revalidate_orchestrator = fix_revalidate_orchestrator
|
||||
self._subplan_service = subplan_service
|
||||
self._subplan_execution_service = subplan_execution_service
|
||||
self._acms_integration = acms_integration
|
||||
self._strategize_actor = strategize_actor or StrategizeStubActor()
|
||||
self._execute_actor = execute_actor or ExecuteStubActor()
|
||||
self._logger = logger.bind(service="plan_executor")
|
||||
@@ -439,6 +443,11 @@ class PlanExecutor:
|
||||
"""Return the subplan execution service, if configured."""
|
||||
return self._subplan_execution_service
|
||||
|
||||
@property
|
||||
def acms_integration(self) -> PlanExecutionACMSIntegration | None:
|
||||
"""Return the ACMS integration, if configured."""
|
||||
return self._acms_integration
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Subplan spawning helpers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -614,41 +623,12 @@ class PlanExecutor:
|
||||
meta = dict(metadata or {})
|
||||
if sandbox.context is not None:
|
||||
meta["sandbox_path"] = sandbox.context.sandbox_path
|
||||
checkpoint = self._checkpoint_manager.create_checkpoint(
|
||||
return self._checkpoint_manager.create_checkpoint(
|
||||
sandbox=sandbox,
|
||||
plan_id=plan_id,
|
||||
phase=phase,
|
||||
metadata=meta,
|
||||
)
|
||||
|
||||
# Bridge infra→domain: persist checkpoint ID on the plan
|
||||
# so plan status and plan rollback can reference it.
|
||||
if checkpoint is not None:
|
||||
try:
|
||||
plan = self._lifecycle.get_plan(plan_id)
|
||||
plan.last_checkpoint_id = checkpoint.checkpoint_id
|
||||
self._lifecycle._commit_plan(plan)
|
||||
self._logger.info(
|
||||
"Checkpoint created and persisted",
|
||||
plan_id=plan_id,
|
||||
checkpoint_id=checkpoint.checkpoint_id,
|
||||
phase=phase,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Checkpoint created but plan update failed",
|
||||
plan_id=plan_id,
|
||||
phase=phase,
|
||||
checkpoint_id=getattr(checkpoint, "checkpoint_id", None),
|
||||
exc_info=True,
|
||||
)
|
||||
raise PlanError(
|
||||
f"Failed to persist checkpoint metadata for plan {plan_id}"
|
||||
) from exc
|
||||
|
||||
return checkpoint
|
||||
except PlanError:
|
||||
raise
|
||||
except Exception:
|
||||
self._logger.debug(
|
||||
"Checkpoint creation failed (non-fatal)",
|
||||
@@ -736,42 +716,21 @@ class PlanExecutor:
|
||||
|
||||
self._lifecycle.start_strategize(plan_id)
|
||||
try:
|
||||
# Derive resources from project links so the strategy actor
|
||||
# can factor them into its prompt and resource analysis.
|
||||
resources: list[str] | None = None
|
||||
project_context: str | None = None
|
||||
if plan.project_links:
|
||||
resources = [link.project_name for link in plan.project_links]
|
||||
project_context = ", ".join(resources)
|
||||
|
||||
# StrategyActor.execute() accepts resources/project_context;
|
||||
# StrategizeStubActor.execute() ignores unknown kwargs via
|
||||
# its signature — pass them as keyword args so both actors work.
|
||||
execute_kwargs: dict[str, Any] = {}
|
||||
if resources is not None:
|
||||
execute_kwargs["resources"] = resources
|
||||
if project_context is not None:
|
||||
execute_kwargs["project_context"] = project_context
|
||||
|
||||
result = self._strategize_actor.execute(
|
||||
plan_id=plan_id,
|
||||
definition_of_done=plan.definition_of_done,
|
||||
invariants=plan.invariants,
|
||||
stream_callback=stream_callback,
|
||||
**execute_kwargs,
|
||||
)
|
||||
plan = self._lifecycle.get_plan(plan_id)
|
||||
plan.decision_root_id = result.decision_root_id
|
||||
plan.timestamps.updated_at = datetime.now(tz=UTC)
|
||||
|
||||
# Serialise decisions as JSON so _build_decisions can
|
||||
# reconstruct the full hierarchy during Execute, preserving
|
||||
# the dependency ordering and parent/child structure produced
|
||||
# by StrategyActor rather than rebuilding from definition_of_done.
|
||||
decisions_json = json.dumps([d.model_dump() for d in result.decisions])
|
||||
# Store decisions and invariant records in error_details as
|
||||
# structured metadata (Plan model uses error_details for
|
||||
# arbitrary metadata storage until a dedicated field lands)
|
||||
plan.error_details = {
|
||||
"strategy_decisions": str(len(result.decisions)),
|
||||
"strategy_decisions_json": decisions_json,
|
||||
"invariant_records": str(len(result.invariant_records)),
|
||||
}
|
||||
self._lifecycle._commit_plan(plan)
|
||||
@@ -821,36 +780,10 @@ class PlanExecutor:
|
||||
raise ValidationError("plan_id must not be empty")
|
||||
if self._execution_context is not None:
|
||||
return self._run_execute_with_runtime(plan_id, stream_callback)
|
||||
return self._run_execute_with_actor(plan_id, stream_callback)
|
||||
return self._run_execute_with_stub(plan_id, stream_callback)
|
||||
|
||||
def _build_decisions(self, plan: Any) -> list[StrategyDecision]:
|
||||
"""Build decisions for the Execute phase.
|
||||
|
||||
Prefers the serialised strategy decisions stored by
|
||||
``run_strategize`` (``error_details["strategy_decisions_json"]``)
|
||||
so that the full hierarchy, dependency ordering, and parent/child
|
||||
structure produced by ``StrategyActor`` is preserved.
|
||||
|
||||
Falls back to parsing ``definition_of_done`` via
|
||||
``StrategizeStubActor._parse_steps`` when no stored decisions are
|
||||
available (e.g. plans strategised before M6, or stub-only runs).
|
||||
"""
|
||||
stored_json: str | None = None
|
||||
if isinstance(plan.error_details, dict):
|
||||
stored_json = plan.error_details.get("strategy_decisions_json")
|
||||
|
||||
if stored_json:
|
||||
try:
|
||||
raw_list: list[dict[str, Any]] = json.loads(stored_json)
|
||||
return [StrategyDecision.model_validate(d) for d in raw_list]
|
||||
except (json.JSONDecodeError, ValidationError):
|
||||
self._logger.warning(
|
||||
"Failed to deserialise stored strategy decisions; "
|
||||
"falling back to definition_of_done parsing",
|
||||
plan_id=getattr(plan, "identity", None) and plan.identity.plan_id,
|
||||
)
|
||||
|
||||
# Fallback: reconstruct from definition_of_done (stub behaviour)
|
||||
"""Build decisions from plan definition_of_done."""
|
||||
steps = StrategizeStubActor._parse_steps(plan.definition_of_done or "")
|
||||
return [
|
||||
StrategyDecision(
|
||||
@@ -937,6 +870,7 @@ class PlanExecutor:
|
||||
runtime_actor = RuntimeExecuteActor(
|
||||
tool_runner=self._tool_runner,
|
||||
execution_context=self._execution_context,
|
||||
acms_integration=self._acms_integration,
|
||||
)
|
||||
self._lifecycle.start_execute(plan_id)
|
||||
self._try_create_checkpoint(plan_id, "pre_execute")
|
||||
@@ -953,16 +887,12 @@ class PlanExecutor:
|
||||
plan = self._lifecycle.get_plan(plan_id)
|
||||
plan.changeset_id = result.changeset_id
|
||||
plan.sandbox_refs = result.sandbox_refs
|
||||
existing = dict(plan.error_details or {})
|
||||
existing.update(
|
||||
{
|
||||
"tool_call_count": str(result.tool_call_count),
|
||||
"decisions_processed": str(len(result.decision_ids_processed)),
|
||||
"execution_duration_ms": str(result.execution_duration_ms),
|
||||
"mode": "runtime",
|
||||
}
|
||||
)
|
||||
plan.error_details = existing
|
||||
plan.error_details = {
|
||||
"tool_call_count": str(result.tool_call_count),
|
||||
"decisions_processed": str(len(result.decision_ids_processed)),
|
||||
"execution_duration_ms": str(result.execution_duration_ms),
|
||||
"mode": "runtime",
|
||||
}
|
||||
plan.timestamps.updated_at = datetime.now(tz=UTC)
|
||||
|
||||
# Spawn and execute child subplans from spawn decisions
|
||||
@@ -1000,30 +930,21 @@ class PlanExecutor:
|
||||
self._try_rollback_to_last_checkpoint(plan_id)
|
||||
error_msg = f"{type(exc).__name__}: {exc}"
|
||||
plan = self._lifecycle.get_plan(plan_id)
|
||||
existing = dict(plan.error_details or {})
|
||||
existing.update(
|
||||
{
|
||||
"exception_type": type(exc).__name__,
|
||||
"traceback": traceback.format_exc(),
|
||||
"mode": "runtime",
|
||||
}
|
||||
)
|
||||
plan.error_details = existing
|
||||
plan.error_details = {
|
||||
"exception_type": type(exc).__name__,
|
||||
"traceback": traceback.format_exc(),
|
||||
"mode": "runtime",
|
||||
}
|
||||
self._lifecycle._commit_plan(plan)
|
||||
self._lifecycle.fail_execute(plan_id, error_msg)
|
||||
raise
|
||||
|
||||
def _run_execute_with_actor(
|
||||
def _run_execute_with_stub(
|
||||
self,
|
||||
plan_id: str,
|
||||
stream_callback: StreamCallback | None = None,
|
||||
) -> ExecuteResult:
|
||||
"""Execute using the configured execute actor with optional retry.
|
||||
|
||||
Retry behaviour is controlled by the ``ErrorRecoveryService``
|
||||
attached at construction time (``error_recovery.max_retries``).
|
||||
Without an error recovery service, failures are immediate.
|
||||
"""
|
||||
"""Execute using the legacy ExecuteStubActor with optional retry."""
|
||||
plan = self._guard_execute(plan_id)
|
||||
decisions = self._build_decisions(plan)
|
||||
|
||||
@@ -1056,15 +977,11 @@ class PlanExecutor:
|
||||
plan = self._lifecycle.get_plan(plan_id)
|
||||
plan.changeset_id = result.changeset_id
|
||||
plan.sandbox_refs = result.sandbox_refs
|
||||
existing = dict(plan.error_details or {})
|
||||
existing.update(
|
||||
{
|
||||
"tool_calls_count": str(result.tool_calls_count),
|
||||
"sandbox_refs_count": str(len(result.sandbox_refs)),
|
||||
"mode": type(self._execute_actor).__name__,
|
||||
}
|
||||
)
|
||||
plan.error_details = existing
|
||||
plan.error_details = {
|
||||
"tool_calls_count": str(result.tool_calls_count),
|
||||
"sandbox_refs_count": str(len(result.sandbox_refs)),
|
||||
"mode": "stub",
|
||||
}
|
||||
plan.timestamps.updated_at = datetime.now(tz=UTC)
|
||||
|
||||
# Spawn and execute child subplans from spawn decisions
|
||||
@@ -1084,9 +1001,8 @@ class PlanExecutor:
|
||||
OperationalMetricKey.PLAN_DURATION_MS, plan_id, _duration_ms
|
||||
)
|
||||
self._logger.info(
|
||||
"Execute completed",
|
||||
"Execute completed (stub)",
|
||||
plan_id=plan_id,
|
||||
mode=type(self._execute_actor).__name__,
|
||||
changeset_id=result.changeset_id,
|
||||
tool_calls=result.tool_calls_count,
|
||||
)
|
||||
@@ -1128,21 +1044,17 @@ class PlanExecutor:
|
||||
"on_error",
|
||||
{
|
||||
"exception_type": type(last_exc).__name__,
|
||||
"mode": type(self._execute_actor).__name__,
|
||||
"mode": "stub",
|
||||
},
|
||||
)
|
||||
self._try_rollback_to_last_checkpoint(plan_id)
|
||||
error_msg = f"{type(last_exc).__name__}: {last_exc}"
|
||||
plan = self._lifecycle.get_plan(plan_id)
|
||||
existing = dict(plan.error_details or {})
|
||||
existing.update(
|
||||
{
|
||||
"exception_type": type(last_exc).__name__,
|
||||
"traceback": traceback.format_exc(),
|
||||
"mode": type(self._execute_actor).__name__,
|
||||
}
|
||||
)
|
||||
plan.error_details = existing
|
||||
plan.error_details = {
|
||||
"exception_type": type(last_exc).__name__,
|
||||
"traceback": traceback.format_exc(),
|
||||
"mode": "stub",
|
||||
}
|
||||
self._lifecycle._commit_plan(plan)
|
||||
self._lifecycle.fail_execute(plan_id, error_msg)
|
||||
raise last_exc
|
||||
|
||||
@@ -301,8 +301,6 @@ class SubplanExecutionService:
|
||||
completion_order: list[str] = []
|
||||
stop_flag = False
|
||||
timeout = self._config.timeout_per_subplan_seconds
|
||||
status_lookup = {s.subplan_id: s for s in statuses}
|
||||
fail_fast_ids: set[str] = set()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
future_to_id: dict[Future[tuple[SubplanStatus, dict[str, str]]], str] = {}
|
||||
@@ -317,46 +315,34 @@ class SubplanExecutionService:
|
||||
|
||||
for future in as_completed(future_to_id):
|
||||
subplan_id = future_to_id[future]
|
||||
template_status = status_lookup[subplan_id]
|
||||
try:
|
||||
result_status, output = future.result()
|
||||
except CancelledError:
|
||||
result_status = self._cancel_status(template_status)
|
||||
result_status = self._cancel_status(
|
||||
next(s for s in statuses if s.subplan_id == subplan_id)
|
||||
)
|
||||
output = {}
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
result_status = self._error_status(template_status, str(exc))
|
||||
output = {}
|
||||
|
||||
if (
|
||||
stop_flag
|
||||
and subplan_id not in fail_fast_ids
|
||||
and result_status.status != ProcessingState.ERRORED
|
||||
):
|
||||
# Fail-fast already triggered; ensure remaining subplans are marked
|
||||
# as CANCELLED even if their futures completed before cancellation
|
||||
# propagated.
|
||||
result_status = self._cancel_status(template_status)
|
||||
output = {}
|
||||
|
||||
if stop_flag and result_status.status not in (
|
||||
ProcessingState.ERRORED,
|
||||
ProcessingState.CANCELLED,
|
||||
):
|
||||
result_status = self._cancel_status(template_status)
|
||||
result_status = self._error_status(
|
||||
next(s for s in statuses if s.subplan_id == subplan_id),
|
||||
str(exc),
|
||||
)
|
||||
output = {}
|
||||
|
||||
results_map[subplan_id] = (result_status, output)
|
||||
completion_order.append(subplan_id)
|
||||
|
||||
if result_status.status == ProcessingState.ERRORED:
|
||||
fail_fast_ids.add(subplan_id)
|
||||
if self._failure_handler.should_stop_others(
|
||||
if (
|
||||
result_status.status == ProcessingState.ERRORED
|
||||
and self._failure_handler.should_stop_others(
|
||||
self._config, result_status
|
||||
):
|
||||
stop_flag = True
|
||||
for f, fid in future_to_id.items():
|
||||
if fid != subplan_id and not f.done():
|
||||
f.cancel()
|
||||
)
|
||||
):
|
||||
stop_flag = True
|
||||
# Cancel remaining futures
|
||||
for f in future_to_id:
|
||||
if not f.done():
|
||||
f.cancel()
|
||||
|
||||
# Build results in completion order (not original input order)
|
||||
updated: list[SubplanStatus] = []
|
||||
|
||||
@@ -534,12 +534,13 @@ def _print_role_warnings(config_blob: dict[str, Any]) -> None:
|
||||
@app.command()
|
||||
def add(
|
||||
name: Annotated[
|
||||
str,
|
||||
str | None,
|
||||
typer.Argument(
|
||||
help="Namespaced actor name (e.g. local/my-actor)",
|
||||
help="Namespaced actor name (e.g. local/my-actor). "
|
||||
"If omitted, the name is derived from the config file.",
|
||||
metavar="NAME",
|
||||
),
|
||||
],
|
||||
] = None,
|
||||
config: Annotated[
|
||||
Path | None,
|
||||
typer.Option("--config", "-c", help="Path to JSON/YAML actor config"),
|
||||
@@ -569,19 +570,19 @@ def add(
|
||||
) -> None:
|
||||
"""Add a new actor configuration.
|
||||
|
||||
The actor name is provided as a required positional argument. The YAML/JSON
|
||||
configuration file specified with ``--config`` supplies the actor settings.
|
||||
The positional ``NAME`` takes precedence over any ``name`` field in the
|
||||
config file.
|
||||
The YAML/JSON configuration file specified with ``--config`` supplies the
|
||||
actor settings. The actor's registered name is taken from the config file's
|
||||
``name`` field, unless overridden by the positional ``NAME`` argument.
|
||||
|
||||
Signature:
|
||||
``agents actor add <NAME> --config <FILE> [--update] [--unsafe]
|
||||
``agents actor add [--config|-c <FILE>] [<NAME>] [--update] [--unsafe]
|
||||
[--set-default] [--option key=value] [--format FORMAT]``
|
||||
|
||||
Examples:
|
||||
agents actor add --config ./actors/my-actor.yaml
|
||||
agents actor add local/my-actor --config ./actors/my-actor.yaml
|
||||
agents actor add local/my-actor --config ./actors/my-actor.yaml --update
|
||||
agents actor add local/my-actor --config actor.yaml --format json
|
||||
agents actor add --config ./actors/my-actor.yaml --update
|
||||
agents actor add --config actor.yaml --format json
|
||||
"""
|
||||
service, registry = _get_services()
|
||||
option_overrides = _parse_option_overrides(option)
|
||||
@@ -597,6 +598,15 @@ def add(
|
||||
assert loaded is not None, "unreachable: config is not None"
|
||||
yaml_text, config_blob = loaded
|
||||
|
||||
# Derive actor name from config when not provided as argument.
|
||||
if name is None:
|
||||
name = config_blob.get("name")
|
||||
if not name:
|
||||
raise typer.BadParameter(
|
||||
"Actor name is required. Provide it as a positional argument "
|
||||
"or as a 'name' field in the config file."
|
||||
)
|
||||
|
||||
# Validate v3 config via ActorConfigSchema if detected.
|
||||
# This ensures v3 actors are fully validated (cycle detection, required
|
||||
# fields, enum values) before the registry stores them.
|
||||
|
||||
@@ -213,13 +213,17 @@ def create(
|
||||
# Notify the facade layer for A2A protocol bookkeeping.
|
||||
# Pass session_id so the facade handler acknowledges the already-
|
||||
# persisted session instead of creating a duplicate (#1141).
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
try:
|
||||
_facade_dispatch(
|
||||
"session.create",
|
||||
{"actor_name": actor or "", "session_id": session.session_id},
|
||||
)
|
||||
except Exception as _exc:
|
||||
_log.warning(
|
||||
"session_create_facade_dispatch_failed",
|
||||
extra={"session_id": session.session_id, "error": str(_exc)},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
data = _session_summary_dict(session)
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ class ReactiveEventBus:
|
||||
handler=getattr(handler, "__qualname__", repr(handler)),
|
||||
error_type=type(exc).__name__,
|
||||
error=str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def subscribe(
|
||||
|
||||
@@ -37,6 +37,7 @@ from cleveragents.resource.handlers._base import (
|
||||
EMPTY_CONTENT_HASH,
|
||||
BaseResourceHandler,
|
||||
)
|
||||
from cleveragents.resource.handlers.discovery import discover_devcontainers
|
||||
from cleveragents.resource.handlers.protocol import (
|
||||
CheckpointResult,
|
||||
Content,
|
||||
@@ -233,10 +234,13 @@ class FsDirectoryHandler(BaseResourceHandler):
|
||||
)
|
||||
|
||||
def discover_children(self, *, resource: Resource) -> list[Resource]:
|
||||
"""Discover subdirectories as child resources.
|
||||
"""Discover subdirectories and devcontainer instances as child resources.
|
||||
|
||||
Each immediate subdirectory becomes a child ``fs-directory``
|
||||
resource.
|
||||
resource. Additionally, any ``.devcontainer/`` configurations
|
||||
found at the resource location are registered as
|
||||
``devcontainer-instance`` child resources with
|
||||
``provisioning_state: discovered``.
|
||||
|
||||
Args:
|
||||
resource: The parent fs-directory resource.
|
||||
@@ -261,6 +265,28 @@ class FsDirectoryHandler(BaseResourceHandler):
|
||||
)
|
||||
children.append(child)
|
||||
|
||||
# Wire devcontainer auto-discovery (issue #4740)
|
||||
dc_results = discover_devcontainers(location, "fs-directory")
|
||||
for dc_result in dc_results:
|
||||
config_name = dc_result.config_name or "default"
|
||||
dc_child = Resource(
|
||||
resource_id=self._derive_child_id(
|
||||
resource.resource_id, f"devcontainer-{config_name}"
|
||||
),
|
||||
name=f"devcontainer-{config_name}",
|
||||
resource_type_name="devcontainer-instance",
|
||||
classification=resource.classification,
|
||||
description=f"Devcontainer at {dc_result.config_path}",
|
||||
location=location,
|
||||
parents=[resource.resource_id],
|
||||
properties={
|
||||
"devcontainer_json_path": str(dc_result.config_path),
|
||||
"config_name": config_name,
|
||||
"provisioning_state": "discovered",
|
||||
},
|
||||
)
|
||||
children.append(dc_child)
|
||||
|
||||
return children
|
||||
|
||||
# -- Checkpoint and rollback (issue #836) ------------------------------
|
||||
|
||||
@@ -6,17 +6,18 @@ strategy (with fallback to ``copy_on_write``).
|
||||
|
||||
Content CRUD operations (issue #827):
|
||||
|
||||
- ``read`` — ``git show HEAD:<path>``
|
||||
- ``write`` — atomic file write inside the checkout
|
||||
- ``delete`` — ``os.remove`` + ``git rm --cached``
|
||||
- ``list_children`` — ``git ls-tree -r --name-only HEAD``
|
||||
- ``diff`` — ``git diff --no-index``
|
||||
- ``discover_children`` — ``git ls-tree --name-only HEAD``
|
||||
- ``read`` -- ``git show HEAD:<path>``
|
||||
- ``write`` -- atomic file write inside the checkout
|
||||
- ``delete`` -- ``os.remove`` + ``git rm --cached``
|
||||
- ``list_children`` -- ``git ls-tree -r --name-only HEAD``
|
||||
- ``diff`` -- ``git diff --no-index``
|
||||
- ``discover_children`` -- ``git ls-tree --name-only HEAD`` + devcontainer discovery
|
||||
|
||||
Based on:
|
||||
- implementation_plan.md group M1.resource-handlers (L2254-L2271)
|
||||
- Built-in type definition in resource_registry_service.py L62-98
|
||||
- Issue #827 — ResourceHandler CRUD and discovery methods
|
||||
- Issue #827 -- ResourceHandler CRUD and discovery methods
|
||||
- Issue #4740 -- Wire discover_devcontainers() into git-checkout handler
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -36,6 +37,7 @@ from cleveragents.resource.handlers._base import (
|
||||
EMPTY_CONTENT_HASH,
|
||||
BaseResourceHandler,
|
||||
)
|
||||
from cleveragents.resource.handlers.discovery import discover_devcontainers
|
||||
from cleveragents.resource.handlers.protocol import (
|
||||
CheckpointResult,
|
||||
Content,
|
||||
@@ -303,17 +305,20 @@ class GitCheckoutHandler(BaseResourceHandler):
|
||||
)
|
||||
|
||||
def discover_children(self, *, resource: Resource) -> list[Resource]:
|
||||
"""Discover child resources via ``git ls-tree``.
|
||||
"""Discover child resources via ``git ls-tree`` and devcontainer scan.
|
||||
|
||||
Each top-level directory in the repo becomes a child resource
|
||||
of type ``fs-directory``.
|
||||
of type ``fs-directory``. Additionally, any ``.devcontainer/``
|
||||
configurations found at the resource location are registered as
|
||||
``devcontainer-instance`` child resources with
|
||||
``provisioning_state: discovered``.
|
||||
|
||||
Args:
|
||||
resource: The parent git-checkout resource.
|
||||
|
||||
Returns:
|
||||
List of child :class:`Resource` objects for top-level
|
||||
directories.
|
||||
directories and discovered devcontainer instances.
|
||||
"""
|
||||
location = self._require_location(resource)
|
||||
|
||||
@@ -346,6 +351,28 @@ class GitCheckoutHandler(BaseResourceHandler):
|
||||
)
|
||||
children.append(child)
|
||||
|
||||
# Wire devcontainer auto-discovery (issue #4740)
|
||||
dc_results = discover_devcontainers(location, "git-checkout")
|
||||
for dc_result in dc_results:
|
||||
config_name = dc_result.config_name or "default"
|
||||
dc_child = Resource(
|
||||
resource_id=self._derive_child_id(
|
||||
resource.resource_id, f"devcontainer-{config_name}"
|
||||
),
|
||||
name=f"devcontainer-{config_name}",
|
||||
resource_type_name="devcontainer-instance",
|
||||
classification=resource.classification,
|
||||
description=f"Devcontainer at {dc_result.config_path}",
|
||||
location=location,
|
||||
parents=[resource.resource_id],
|
||||
properties={
|
||||
"devcontainer_json_path": str(dc_result.config_path),
|
||||
"config_name": config_name,
|
||||
"provisioning_state": "discovered",
|
||||
},
|
||||
)
|
||||
children.append(dc_child)
|
||||
|
||||
return children
|
||||
|
||||
# -- Checkpoint and rollback (issue #836) ------------------------------
|
||||
|
||||
Reference in New Issue
Block a user