Compare commits

..

2 Commits

Author SHA1 Message Date
HAL9000 5b73be9f43 docs: fix quickstart plan/apply command signatures
CI / benchmark-publish (pull_request) Has been skipped
CI / helm (pull_request) Successful in 37s
CI / build (pull_request) Successful in 44s
CI / lint (pull_request) Successful in 1m0s
CI / quality (pull_request) Successful in 1m8s
CI / push-validation (pull_request) Successful in 29s
CI / typecheck (pull_request) Successful in 1m19s
CI / benchmark-regression (pull_request) Failing after 51s
CI / security (pull_request) Successful in 1m34s
CI / integration_tests (pull_request) Successful in 4m4s
CI / e2e_tests (pull_request) Successful in 4m12s
CI / unit_tests (pull_request) Successful in 4m40s
CI / docker (pull_request) Successful in 1m28s
CI / coverage (pull_request) Successful in 10m39s
CI / status-check (pull_request) Successful in 3s
Update docs/quickstart.md to use correct CLI command signatures:
- Replace 'cleveragents plan --project' with 'cleveragents plan use <action> --project'
- Replace 'cleveragents apply --project' with 'cleveragents plan list' + 'cleveragents plan apply <plan-id>'

Addresses reviewer non-blocking suggestion to verify CLI commands match
actual signatures before merging.
2026-05-05 14:30:56 +00:00
HAL9000 f179b57e18 docs: add quick start guide (PR #9245)
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 45s
CI / typecheck (pull_request) Successful in 1m6s
CI / quality (pull_request) Successful in 46s
CI / security (pull_request) Successful in 1m15s
CI / build (pull_request) Successful in 31s
CI / helm (pull_request) Successful in 24s
CI / push-validation (pull_request) Successful in 20s
CI / unit_tests (pull_request) Successful in 4m5s
CI / integration_tests (pull_request) Successful in 3m58s
CI / e2e_tests (pull_request) Successful in 4m23s
CI / benchmark-regression (pull_request) Failing after 1m29s
CI / docker (pull_request) Successful in 1m41s
CI / coverage (pull_request) Successful in 11m5s
CI / status-check (pull_request) Successful in 4s
Add docs/quickstart.md with end-to-end quick start guide covering
prerequisites, installation, project creation, resource registration,
plan/apply workflow, and troubleshooting.

Update mkdocs.yml navigation to include Quick Start page and rename
ADR-028 title to Skill/Tool Abstraction Definition.

Update CHANGELOG.md with quick start guide entry.

Closes #9245
2026-05-05 10:14:20 +00:00
24 changed files with 112 additions and 889 deletions
+1 -34
View File
@@ -5,15 +5,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Changed
- **`agents session list` now displays full 26-character session ULIDs** (#10970): The Rich table
and Summary panel ("Most Recent" / "Oldest") previously showed only the first 8 characters of
each session ULID. This made the output unusable for copy-paste into `session tell`,
`session show`, `session delete`, and `session export`, all of which require the full
26-character identifier. The full ULID is now displayed in all output formats (Rich, plain,
JSON, YAML, table).
### Security
- **aiohttp upgraded to >=3.13.4 to remediate CVE-2026-34513 and CVE-2026-34515** (#1549, #1544):
@@ -59,6 +50,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `agents actor context clear` command to reset actor message history and
state while preserving the underlying context directory via `ContextManager`
(#6370).
- **Quick Start Guide** (PR #9245): Added `docs/quickstart.md` with an end-to-end quick start guide covering prerequisites, installation, project creation, resource registration, plan/apply workflow, and troubleshooting. Updated `mkdocs.yml` navigation to include the Quick Start page.
- **Plan checkpoint management CLI commands** (#8683): Added `agents plan checkpoint-list <plan-id>` and `agents plan checkpoint-delete <checkpoint-id>` commands. Listing output now highlights checkpoint ID, type, created timestamp, reason, phase, and decision linkage with a concise field summary footer across rich/table/json/yaml formats. Deletion supports batch IDs, interactive confirmation (skip with `--yes`), and structured JSON/YAML responses for automation-friendly scripting.
- **Invariant Remove CLI Command** (#8530): Implemented `agents invariant remove <id>` command that soft-deletes an invariant by ID. The command displays a confirmation prompt before removal (bypassable with `--yes`/`-y`), outputs the removed invariant ID on success, and shows a clear error message when the invariant ID does not exist. Supports `--format` flag for JSON and YAML output. Full BDD test coverage and Robot Framework integration tests included.
@@ -87,31 +79,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed
- **`InvariantService.get_effective_invariants()` adds action_name param for 4-tier merge** (#3128):
Added an `action_name: str | None = None` parameter to
`InvariantService.get_effective_invariants()`, promoting the invariant precedence chain to
four tiers (**plan > action > project > global**) instead of three. The method now filters
action-scoped invariants by `action_name` (when provided) and passes them as the second tier
to `merge_invariants()`. Updated docstrings, `InvariantScope` documentation, and
`InvariantSet.merge()` + `merge_invariants()` signatures across the domain model, service layer,
CLI help text, benchmarks, Robot Framework tests, and new BDD scenarios (12 scenarios across
`features/invariant_action_scope_effective.feature`) covering all four-precedence paths,
deduplication semantics, action-scope filtering, and list_invariants effective-mode
passthrough. (#3329)
- **`LLMTraceRepository.save()` premature commit breaks UnitOfWork transactions** (#7505):
Replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a
dual-path implementation that respects the UnitOfWork (UoW) pattern. When an external
session is provided (UoW mode), the method now calls only `session.flush()`, leaving
transaction control to the caller. When no session is provided (standalone mode), the
method creates its own session, flushes, commits, and closes it to ensure durable
persistence. This eliminates three data-integrity violations: premature commit of outer
UoW transactions, loss of rollback capability for subsequent failures, and a mismatch
between the class docstring ("Callers are responsible for commit") and the implementation.
Input validation for the `trace` argument was also added. Two new BDD scenarios verify
the session contract: `Repository save() calls flush not commit` and `LLM trace rolled
back when UnitOfWork transaction rolls back`.
- **git_tools._get_base_env() TOCTOU Race Condition** (#7619): Fixed a
Time-Of-Check-To-Time-Of-Use race condition in `git_tools._get_base_env()`
where two concurrent threads could both observe `_BASE_ENV is None`, both
-3
View File
@@ -29,6 +29,3 @@ Below are some of the specific details of various contributions.
* HAL 9000 has contributed the git worktree TOCTOU race condition fix (PR #8178 / issue #7507): replaced the unsafe mkdtemp() + rmdir() pattern with a parent-directory approach to eliminate the race window in concurrent git worktree operations.
* HAL 9000 has contributed the git_tools TOCTOU race condition fix (PR #8255 / issue #7619): eliminated the Time-Of-Check-To-Time-Of-Use race in `_get_base_env()` by adding double-checked locking with a module-level `threading.Lock`, preventing concurrent threads from writing conflicting environment snapshots.
* 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 the invariant action-scope 4-tier merge fix (PR #3329 / issue #3128): added `action_name` parameter to `InvariantService.get_effective_invariants()`, promoted the invariant precedence chain from 3-tier (plan > project > global) to 4-tier (plan > action > project > global), updated `merge_invariants()` and `InvariantSet.merge()` signatures, refreshed CLI documentation, benchmarks, Robot Framework tests, and added 13 BDD scenarios covering 4-tier precedence, deduplication semantics, and action-scope filtering.
+5 -5
View File
@@ -74,7 +74,7 @@ class MergeSmallSuite:
def time_merge_small(self) -> None:
"""Benchmark merge with ~13 invariants."""
merge_invariants(self.plan, [], self.project, self.global_invs)
merge_invariants(self.plan, self.project, self.global_invs)
class MergeMediumSuite:
@@ -88,7 +88,7 @@ class MergeMediumSuite:
def time_merge_medium(self) -> None:
"""Benchmark merge with ~50 invariants."""
merge_invariants(self.plan, [], self.project, self.global_invs)
merge_invariants(self.plan, self.project, self.global_invs)
class MergeLargeSuite:
@@ -102,7 +102,7 @@ class MergeLargeSuite:
def time_merge_large(self) -> None:
"""Benchmark merge with ~250 invariants."""
merge_invariants(self.plan, [], self.project, self.global_invs)
merge_invariants(self.plan, self.project, self.global_invs)
class MergeDeduplicationSuite:
@@ -137,7 +137,7 @@ class MergeDeduplicationSuite:
def time_merge_dedup(self) -> None:
"""Benchmark merge with 60 invariants, all duplicates."""
merge_invariants(self.plan, [], self.project, self.global_invs)
merge_invariants(self.plan, self.project, self.global_invs)
class InvariantSetMergeSuite:
@@ -151,7 +151,7 @@ class InvariantSetMergeSuite:
def time_invariant_set_merge(self) -> None:
"""Benchmark InvariantSet.merge()."""
InvariantSet.merge(self.plan, [], self.project, self.global_invs)
InvariantSet.merge(self.plan, self.project, self.global_invs)
class ServiceEffectiveSuite:
+45
View File
@@ -0,0 +1,45 @@
# Quick Start Guide
This quick start guide will walk you through creating a new project, registering a resource, running a plan, and applying changes with CleverAgents.
## Prerequisites
- Python 3.11+ and virtualenv
- Git
- A working CleverAgents installation (see development/testing.md for details)
## Install (local development)
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e .[dev]
```
## Create a new project
```bash
# Create a new directory for your project
mkdir my-project && cd my-project
# Initialize a CleverAgents project (example command)
cleveragents init --name my-project
```
## Register a resource
Create a resource file under `resources/` (example YAML) and register it with the CLI or API.
## Plan and apply
```bash
# Create a plan using an action on your project
cleveragents plan use <action-name> --project my-project
# List plans to find the plan ID
cleveragents plan list
# Review the plan, then apply it by plan ID
cleveragents plan apply <plan-id>
```
## Troubleshooting
If you encounter issues running the examples above, consult `docs/development/testing.md` and the project README for local development tips.
+3 -3
View File
@@ -72,8 +72,8 @@ The `rich` format renders a sessions table with columns: **ID**, **Name**, **Act
| Field | Description |
|-------|-------------|
| Total | Number of sessions |
| Most Recent | Name or full ULID of the most recently updated session |
| Oldest | Name or full ULID of the oldest session |
| Most Recent | Name or truncated ID of the most recently updated session |
| Oldest | Name or truncated ID of the oldest session |
| Total Messages | Sum of messages across all sessions |
| Storage | Estimated storage used |
@@ -85,7 +85,7 @@ Followed by a `✓ OK N sessions listed` success message.
{
"sessions": [
{
"id": "01HXYZ4M1Q3F0R0E5HR8K5T8A",
"id": "01HXYZ...",
"name": "my-session",
"actor": "openai/gpt-4",
"messages": 5,
@@ -296,13 +296,13 @@ $ python -m cleveragents session list
┏━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
┃ ID ┃ Name ┃ Actor ┃ Messages ┃ Updated ┃
┡━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
│ 01KNKK4Q9GZ0TRR5B0NEJYGMWH │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │
│ 01KNKK4Q │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │
└──────────┴───────────┴────────┴──────────┴──────────────────┘
╭────────────────────────────────── Summary ───────────────────────────────────╮
│ Total: 1 │
│ Most Recent: 01KNKK4Q9GZ0TRR5B0NEJYGMWH
│ Oldest: 01KNKK4Q9GZ0TRR5B0NEJYGMWH
│ Most Recent: 01KNKK4Q │
│ Oldest: 01KNKK4Q │
│ Total Messages: 0 │
│ Storage: 0 KB │
╰──────────────────────────────────────────────────────────────────────────────╯
@@ -311,7 +311,7 @@ $ python -m cleveragents session list
```
**What's Happening:**
The session list shows all sessions with their full ULID, optional name, bound actor, message count, and last update time. The summary panel provides aggregate statistics across all sessions.
The session list shows all sessions with their truncated ID, optional name, bound actor, message count, and last update time. The summary panel provides aggregate statistics across all sessions.
---
@@ -339,8 +339,8 @@ $ python -m cleveragents session list --format json
],
"summary": {
"total": 1,
"most_recent": "01KNKK4Q9GZ0TRR5B0NEJYGMWH",
"oldest": "01KNKK4Q9GZ0TRR5B0NEJYGMWH",
"most_recent": "01KNKK4Q",
"oldest": "01KNKK4Q",
"total_messages": 0,
"storage": "0 KB"
}
@@ -469,7 +469,7 @@ $ python -m cleveragents session list
┏━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
┃ ID ┃ Name ┃ Actor ┃ Messages ┃ Updated ┃
┡━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
│ 01KNKK4Q9GZ0TRR5B0NEJYGMWH │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │
│ 01KNKK4Q │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │
└──────────┴───────────┴────────┴──────────┴──────────────────┘
✓ OK 1 sessions listed
```
@@ -180,14 +180,14 @@ $ python -m cleveragents session list
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ID ┃ Name ┃ Actor ┃ Messages ┃ Updated ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 01HXYZ4M1Q3F0R0E5HR8K5T8A │ (unnamed) │ openai/gpt-4o │ 3 │ 2026-04-07 09:22 │
│ 01HXYZ3K9P2E9Q9D4GQ7J4S7Z │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:15 │
│ 01HXYZ4M │ (unnamed) │ openai/gpt-4o │ 3 │ 2026-04-07 09:22 │
│ 01HXYZ3K │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:15 │
└──────────┴────────────┴────────────────┴──────────┴──────────────────┘
╭──────────────────────────────────────────── Summary ─────────────────────────────────────────────╮
│ Total: 2 │
│ Most Recent: 01HXYZ4M1Q3F0R0E5HR8K5T8A
│ Oldest: 01HXYZ3K9P2E9Q9D4GQ7J4S7Z
│ Most Recent: 01HXYZ4M │
│ Oldest: 01HXYZ3K │
│ Total Messages: 3 │
│ Storage: 0 KB │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -196,8 +196,8 @@ $ python -m cleveragents session list
```
**What's Happening:**
The list command renders a Rich table with five columns: the full session
ULID (26 characters), optional name, bound actor, message count, and
The list command renders a Rich table with five columns: truncated ID (first
8 characters for readability), optional name, bound actor, message count, and
last update time. The **Summary** panel below shows aggregate statistics
including total sessions, most recent, oldest, total message count, and
storage used.
@@ -229,8 +229,8 @@ $ python -m cleveragents session list --format json
],
"summary": {
"total": 2,
"most_recent": "01HXYZ4M1Q3F0R0E5HR8K5T8A",
"oldest": "01HXYZ3K9P2E9Q9D4GQ7J4S7Z",
"most_recent": "01HXYZ4M",
"oldest": "01HXYZ3K",
"total_messages": 3,
"storage": "0 KB"
}
@@ -773,7 +773,7 @@ $ python -m cleveragents session list
✓ OK 2 sessions listed
$ python -m cleveragents session list --format json
{"sessions": [...], "summary": {"total": 2, "most_recent": "01HXYZ4M1Q3F0R0E5HR8K5T8A", ...}}
{"sessions": [...], "summary": {"total": 2, "most_recent": "01HXYZ4M", ...}}
$ python -m cleveragents session tell --session 01HXYZ4M1Q3F0R0E5HR8K5T8A "What is the capital of France?"
user: What is the capital of France?
+8 -8
View File
@@ -1723,8 +1723,8 @@ None.
╭─ Sessions ───────────────────────────────────────────────────────────────────╮
│ <span style="color: cyan; font-weight: 600;">ID</span> <span style="color: cyan; font-weight: 600;">Name</span> <span style="color: cyan; font-weight: 600;">Actor</span> <span style="color: cyan; font-weight: 600;">Messages</span> <span style="color: cyan; font-weight: 600;">Updated</span> │
│ <span style="opacity: 0.7;">──────── ─────────────── ────────────────── ──────── ────────────────</span> │
│ 01HXM2A61MQHZ4MRBAY3MPNJTN weekly-planning local/orchestrator 6 2026-02-08 12:44 │
│ 01HXM1F21MQHZ4MRBAY3MPNJTN refactor-sprint local/orchestrator 14 2026-02-07 18:11 │
│ 01HXM2A6 weekly-planning local/orchestrator 6 2026-02-08 12:44 │
│ 01HXM1F2 refactor-sprint local/orchestrator 14 2026-02-07 18:11 │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Summary ────────────────────╮
@@ -1746,8 +1746,8 @@ None.
Sessions
ID Name Actor Messages Updated
-------- --------------- ------------------ -------- ----------------
01HXM2A61MQHZ4MRBAY3MPNJTN weekly-planning local/orchestrator 6 2026-02-08 12:44
01HXM1F21MQHZ4MRBAY3MPNJTN refactor-sprint local/orchestrator 14 2026-02-07 18:11
01HXM2A6 weekly-planning local/orchestrator 6 2026-02-08 12:44
01HXM1F2 refactor-sprint local/orchestrator 14 2026-02-07 18:11
Summary
Total: 2
@@ -1769,14 +1769,14 @@ None.
"data": {
"sessions": [
{
"id": "01HXM2A61MQHZ4MRBAY3MPNJTN",
"id": "01HXM2A6",
"name": "weekly-planning",
"actor": "local/orchestrator",
"messages": 6,
"updated": "2026-02-08T12:44:00Z"
},
{
"id": "01HXM1F21MQHZ4MRBAY3MPNJTN",
"id": "01HXM1F2",
"name": "refactor-sprint",
"actor": "local/orchestrator",
"messages": 14,
@@ -1804,12 +1804,12 @@ None.
exit_code: 0
data:
sessions:
- id: 01HXM2A61MQHZ4MRBAY3MPNJTN
- id: 01HXM2A6
name: weekly-planning
actor: local/orchestrator
messages: 6
updated: "2026-02-08T12:44:00Z"
- id: 01HXM1F21MQHZ4MRBAY3MPNJTN
- id: 01HXM1F2
name: refactor-sprint
actor: local/orchestrator
messages: 14
-6
View File
@@ -1714,12 +1714,6 @@ Feature: Consolidated Misc
And the temporary connection should be closed afterward
Scenario: get_current_revision uses check_same_thread=False for SQLite engines
Given a migration runner configured for "sqlite:///:memory:"
When I request the current revision from the database
Then the SQLite engine for get_current_revision should use check_same_thread=False
Scenario: File-based SQLite database directory is created if missing
Given a migration runner configured for "sqlite:///tmp/test-db/mydb.db"
When I initialize or upgrade a file-based SQLite database
@@ -1,109 +0,0 @@
# BDD scenarios for issue #3128 — action-scoped invariants in get_effective_invariants()
#
# Verifies that InvariantService.get_effective_invariants() correctly includes
# action-scoped invariants in the 4-tier precedence merge:
# plan > action > project > global
#
# Also verifies that merge_invariants() and InvariantSet.merge() accept and
# correctly process the action_invariants parameter.
#
# See: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/3128
Feature: Action-scoped invariants included in effective invariant computation
As a developer using InvariantService
I want get_effective_invariants() to include action-scoped invariants
So that the 4-tier precedence chain (plan > action > project > global) is fully enforced
# ================================================================
# get_effective_invariants() — action_name parameter
# ================================================================
Scenario: get_effective_invariants returns action-scoped invariants when action_name provided
Given an invariant service with action and global invariants
When I get effective invariants with action_name "deploy-service"
Then the effective set should contain the action invariant "Do not modify public API"
And the effective set should contain the global invariant "Log all changes"
And the effective set should have 2 invariants
Scenario: get_effective_invariants includes all action invariants when action_name is None
Given an invariant service with action and global invariants
When I get effective invariants with no action_name
Then the effective set should contain the action invariant "Do not modify public API"
And the effective set should contain the global invariant "Log all changes"
And the effective set should have 2 invariants
Scenario: get_effective_invariants with all four scopes returns all invariants
Given an invariant service with invariants at all four scopes
When I get effective invariants for plan "plan-001" action "deploy-service" and project "myapp"
Then the effective set should have 4 invariants
And the effective set should contain the plan invariant "Plan-level rule"
And the effective set should contain the action invariant "Action-level rule"
And the effective set should contain the project invariant "Project-level rule"
And the effective set should contain the global invariant "Global-level rule"
Scenario: Action invariants have higher precedence than project invariants
Given an invariant service with action and project invariants having the same text
When I get effective invariants for plan "plan-001" action "deploy-service" and project "myapp"
Then the effective set should have 1 invariant
And the first effective invariant should have scope "action"
Scenario: Plan invariants have higher precedence than action invariants
Given an invariant service with plan and action invariants having the same text
When I get effective invariants for plan "plan-001" action "deploy-service" and project "myapp"
Then the effective set should have 1 invariant
And the first effective invariant should have scope "plan"
Scenario: get_effective_invariants filters action invariants by action_name
Given an invariant service with two different action invariants
When I get effective invariants with action_name "deploy-service"
Then the effective set should contain the action invariant "Deploy constraint"
And the effective set should not contain the action invariant "Build constraint"
And the effective set should have 1 invariant
# ================================================================
# list_invariants(effective=True) — action scope passthrough
# ================================================================
Scenario: list_invariants with effective=True and action scope passes action_name
Given an invariant service with action and global invariants
When I list invariants with effective True and action scope "deploy-service"
Then the listed effective set should contain the action invariant "Do not modify public API"
And the listed effective set should contain the global invariant "Log all changes"
# ================================================================
# merge_invariants() — 4-tier signature
# ================================================================
Scenario: merge_invariants accepts action_invariants as second parameter
Given I have plan action project and global invariant lists
When I merge all four invariant tiers
Then the merged result should have 4 invariants
And the merged result should contain "Plan rule"
And the merged result should contain "Action rule"
And the merged result should contain "Project rule"
And the merged result should contain "Global rule"
Scenario: merge_invariants deduplicates action invariants by text
Given I have overlapping action and project invariant lists
When I merge all four invariant tiers
Then the merged result should have 1 invariant
And the first merged invariant should have scope "action"
Scenario: merge_invariants with empty action list behaves like 3-tier merge
Given I have plan project and global invariant lists with no action invariants
When I merge all four invariant tiers with empty action list
Then the merged result should have 3 invariants
# ================================================================
# InvariantSet.merge() — 4-tier signature
# ================================================================
Scenario: InvariantSet.merge accepts action_invariants as second parameter
Given I have plan action project and global invariant lists
When I merge all four tiers using InvariantSet
Then the invariant set should have 4 invariants
Scenario: InvariantSet.merge with empty action list produces correct result
Given I have plan project and global invariant lists with no action invariants
When I merge all four tiers using InvariantSet with empty action list
Then the invariant set should have 3 invariants
-22
View File
@@ -44,28 +44,6 @@ Feature: Session CLI commands
When I run session CLI list with --format json
Then the session CLI JSON list entries should match the documented contract
Scenario: List sessions displays full 26-character ULIDs in Rich table
Given there are mocked existing sessions
When I run session CLI list
Then the session CLI rich table should display full session ULIDs
Scenario: List sessions summary panel shows full ULIDs for unnamed sessions
Given there are mocked existing sessions
When I run session CLI list
Then the session CLI summary panel should contain full session ULIDs
Scenario: List sessions summary panel shows session names for named sessions
Given there are mocked existing named sessions
When I run session CLI list
Then the session CLI summary panel should show session names
Scenario: Full session ID from list output works with session tell
Given there are mocked existing sessions
When I run session CLI list
And I capture the first session full ULID from the output
And I run session CLI tell with the full session ID and prompt "Hello from list"
Then the session CLI tell should succeed
# Show command tests
Scenario: Show session with valid ID
Given there is a mocked session with messages
@@ -1,425 +0,0 @@
"""Step definitions for invariant_action_scope_effective.feature.
Tests that InvariantService.get_effective_invariants() correctly includes
action-scoped invariants in the 4-tier precedence merge:
plan > action > project > global
Also tests that merge_invariants() and InvariantSet.merge() accept and
correctly process the action_invariants parameter.
See: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/3128
"""
from __future__ import annotations
from behave import given, then, when # type: ignore[import-untyped]
from cleveragents.application.services.invariant_service import InvariantService
from cleveragents.domain.models.core.invariant import (
Invariant,
InvariantScope,
InvariantSet,
merge_invariants,
)
# ================================================================
# Givens — service setup
# ================================================================
@given("an invariant service with action and global invariants")
def step_service_action_global(context: object) -> None:
"""Set up a service with one action-scoped and one global invariant."""
context.service = InvariantService()
context.service.add_invariant(
text="Do not modify public API",
scope=InvariantScope.ACTION,
source_name="deploy-service",
)
context.service.add_invariant(
text="Log all changes",
scope=InvariantScope.GLOBAL,
source_name="system",
)
@given("an invariant service with invariants at all four scopes")
def step_service_all_four_scopes(context: object) -> None:
"""Set up a service with one invariant at each of the four scopes."""
context.service = InvariantService()
context.service.add_invariant(
text="Plan-level rule",
scope=InvariantScope.PLAN,
source_name="plan-001",
)
context.service.add_invariant(
text="Action-level rule",
scope=InvariantScope.ACTION,
source_name="deploy-service",
)
context.service.add_invariant(
text="Project-level rule",
scope=InvariantScope.PROJECT,
source_name="myapp",
)
context.service.add_invariant(
text="Global-level rule",
scope=InvariantScope.GLOBAL,
source_name="system",
)
@given("an invariant service with action and project invariants having the same text")
def step_service_action_project_same_text(context: object) -> None:
"""Set up a service where action and project invariants share the same text."""
context.service = InvariantService()
context.service.add_invariant(
text="Shared constraint text",
scope=InvariantScope.ACTION,
source_name="deploy-service",
)
context.service.add_invariant(
text="Shared constraint text",
scope=InvariantScope.PROJECT,
source_name="myapp",
)
@given("an invariant service with plan and action invariants having the same text")
def step_service_plan_action_same_text(context: object) -> None:
"""Set up a service where plan and action invariants share the same text."""
context.service = InvariantService()
context.service.add_invariant(
text="Shared constraint text",
scope=InvariantScope.PLAN,
source_name="plan-001",
)
context.service.add_invariant(
text="Shared constraint text",
scope=InvariantScope.ACTION,
source_name="deploy-service",
)
@given("an invariant service with two different action invariants")
def step_service_two_action_invariants(context: object) -> None:
"""Set up a service with two action invariants from different actions."""
context.service = InvariantService()
context.service.add_invariant(
text="Deploy constraint",
scope=InvariantScope.ACTION,
source_name="deploy-service",
)
context.service.add_invariant(
text="Build constraint",
scope=InvariantScope.ACTION,
source_name="build-service",
)
# ================================================================
# Whens — get_effective_invariants calls
# ================================================================
@when('I get effective invariants with action_name "{action_name}"')
def step_get_effective_with_action(context: object, action_name: str) -> None:
"""Call get_effective_invariants with the given action_name."""
context.effective = context.service.get_effective_invariants(
action_name=action_name,
)
@when("I get effective invariants with no action_name")
def step_get_effective_no_action(context: object) -> None:
"""Call get_effective_invariants without an action_name."""
context.effective = context.service.get_effective_invariants()
@when(
'I get effective invariants for plan "{plan_id}" action "{action_name}" and project "{project_name}"'
)
def step_get_effective_all_four(
context: object, plan_id: str, action_name: str, project_name: str
) -> None:
"""Call get_effective_invariants with all four scope parameters."""
context.effective = context.service.get_effective_invariants(
plan_id=plan_id,
action_name=action_name,
project_name=project_name,
)
@when('I get effective invariants with only action_name "{action_name}"')
def step_get_effective_only_action(context: object, action_name: str) -> None:
"""Call get_effective_invariants with only action_name (no plan or project)."""
context.effective = context.service.get_effective_invariants(
action_name=action_name,
)
@when('I get effective invariants with action_name "{action_name}" for filtering')
def step_get_effective_action_filter(context: object, action_name: str) -> None:
"""Call get_effective_invariants to filter by specific action_name."""
context.effective = context.service.get_effective_invariants(
action_name=action_name,
)
# ================================================================
# Whens — list_invariants with effective=True
# ================================================================
@when('I list invariants with effective True and action scope "{action_name}"')
def step_list_effective_action(context: object, action_name: str) -> None:
"""Call list_invariants with effective=True and action scope."""
context.listed_effective = context.service.list_invariants(
scope=InvariantScope.ACTION,
source_name=action_name,
effective=True,
)
# ================================================================
# Whens — merge_invariants() direct calls
# ================================================================
@given("I have plan action project and global invariant lists")
def step_four_tier_lists(context: object) -> None:
"""Create four separate invariant lists for merge testing."""
context.plan_invs = [
Invariant(text="Plan rule", scope=InvariantScope.PLAN, source_name="plan-001")
]
context.action_invs = [
Invariant(
text="Action rule",
scope=InvariantScope.ACTION,
source_name="deploy-service",
)
]
context.project_invs = [
Invariant(
text="Project rule", scope=InvariantScope.PROJECT, source_name="myapp"
)
]
context.global_invs = [
Invariant(text="Global rule", scope=InvariantScope.GLOBAL, source_name="system")
]
@given("I have overlapping action and project invariant lists")
def step_overlapping_action_project(context: object) -> None:
"""Create action and project invariant lists with the same text."""
context.plan_invs: list[Invariant] = []
context.action_invs = [
Invariant(
text="Shared rule",
scope=InvariantScope.ACTION,
source_name="deploy-service",
)
]
context.project_invs = [
Invariant(text="Shared rule", scope=InvariantScope.PROJECT, source_name="myapp")
]
context.global_invs: list[Invariant] = []
@given("I have plan project and global invariant lists with no action invariants")
def step_three_tier_lists(context: object) -> None:
"""Create three invariant lists (no action tier) for merge testing."""
context.plan_invs = [
Invariant(text="Plan rule", scope=InvariantScope.PLAN, source_name="plan-001")
]
context.action_invs: list[Invariant] = []
context.project_invs = [
Invariant(
text="Project rule", scope=InvariantScope.PROJECT, source_name="myapp"
)
]
context.global_invs = [
Invariant(text="Global rule", scope=InvariantScope.GLOBAL, source_name="system")
]
@when("I merge all four invariant tiers")
def step_merge_four_tiers(context: object) -> None:
"""Call merge_invariants with all four tiers."""
context.merged = merge_invariants(
context.plan_invs,
context.action_invs,
context.project_invs,
context.global_invs,
)
@when("I merge all four invariant tiers with empty action list")
def step_merge_four_tiers_empty_action(context: object) -> None:
"""Call merge_invariants with an empty action list."""
context.merged = merge_invariants(
context.plan_invs,
[],
context.project_invs,
context.global_invs,
)
@when("I merge all four tiers using InvariantSet")
def step_merge_invariant_set_four(context: object) -> None:
"""Call InvariantSet.merge with all four tiers."""
context.invariant_set = InvariantSet.merge(
context.plan_invs,
context.action_invs,
context.project_invs,
context.global_invs,
)
@when("I merge all four tiers using InvariantSet with empty action list")
def step_merge_invariant_set_empty_action(context: object) -> None:
"""Call InvariantSet.merge with an empty action list."""
context.invariant_set = InvariantSet.merge(
context.plan_invs,
[],
context.project_invs,
context.global_invs,
)
# ================================================================
# Thens — effective set assertions
# ================================================================
@then('the effective set should contain the action invariant "{text}"')
def step_effective_contains_action(context: object, text: str) -> None:
"""Assert the effective set contains an action invariant with the given text."""
texts = [inv.text for inv in context.effective]
assert text in texts, (
f"Expected action invariant '{text}' in effective set, got: {texts}"
)
@then('the effective set should not contain the action invariant "{text}"')
def step_effective_not_contains_action(context: object, text: str) -> None:
"""Assert the effective set does NOT contain an invariant with the given text."""
texts = [inv.text for inv in context.effective]
assert text not in texts, (
f"Expected action invariant '{text}' to be absent from effective set, got: {texts}"
)
@then('the effective set should contain the global invariant "{text}"')
def step_effective_contains_global(context: object, text: str) -> None:
"""Assert the effective set contains a global invariant with the given text."""
texts = [inv.text for inv in context.effective]
assert text in texts, (
f"Expected global invariant '{text}' in effective set, got: {texts}"
)
@then('the effective set should contain the plan invariant "{text}"')
def step_effective_contains_plan(context: object, text: str) -> None:
"""Assert the effective set contains a plan invariant with the given text."""
texts = [inv.text for inv in context.effective]
assert text in texts, (
f"Expected plan invariant '{text}' in effective set, got: {texts}"
)
@then('the effective set should contain the project invariant "{text}"')
def step_effective_contains_project(context: object, text: str) -> None:
"""Assert the effective set contains a project invariant with the given text."""
texts = [inv.text for inv in context.effective]
assert text in texts, (
f"Expected project invariant '{text}' in effective set, got: {texts}"
)
@then("the effective set should have {count:d} invariants")
def step_effective_count(context: object, count: int) -> None:
"""Assert the effective set has exactly the given number of invariants."""
actual = len(context.effective)
assert actual == count, f"Expected {count} effective invariants, got {actual}"
@then("the effective set should have {count:d} invariant")
def step_effective_count_singular(context: object, count: int) -> None:
"""Assert the effective set has exactly the given number of invariants (singular)."""
actual = len(context.effective)
assert actual == count, f"Expected {count} effective invariant, got {actual}"
@then('the first effective invariant should have scope "{scope}"')
def step_first_effective_scope(context: object, scope: str) -> None:
"""Assert the first invariant in the effective set has the given scope."""
assert context.effective, "Effective set is empty"
actual = context.effective[0].scope.value
assert actual == scope, (
f"Expected first effective invariant scope '{scope}', got '{actual}'"
)
# ================================================================
# Thens — list_invariants effective assertions
# ================================================================
@then('the listed effective set should contain the action invariant "{text}"')
def step_listed_effective_contains_action(context: object, text: str) -> None:
"""Assert the listed effective set contains an action invariant with the given text."""
texts = [inv.text for inv in context.listed_effective]
assert text in texts, (
f"Expected action invariant '{text}' in listed effective set, got: {texts}"
)
@then('the listed effective set should contain the global invariant "{text}"')
def step_listed_effective_contains_global(context: object, text: str) -> None:
"""Assert the listed effective set contains a global invariant with the given text."""
texts = [inv.text for inv in context.listed_effective]
assert text in texts, (
f"Expected global invariant '{text}' in listed effective set, got: {texts}"
)
# ================================================================
# Thens — merge_invariants() assertions
# ================================================================
@then("the merged result should have {count:d} invariants")
def step_merged_count_four(context: object, count: int) -> None:
"""Assert the merged result has exactly the given number of invariants."""
actual = len(context.merged)
assert actual == count, f"Expected {count} merged invariants, got {actual}"
@then('the merged result should contain "{text}"')
def step_merged_contains_text(context: object, text: str) -> None:
"""Assert the merged result contains an invariant with the given text."""
texts = [inv.text for inv in context.merged]
assert text in texts, f"Expected '{text}' in merged result, got: {texts}"
@then('the first merged invariant should have scope "{scope}"')
def step_first_merged_scope(context: object, scope: str) -> None:
"""Assert the first invariant in the merged result has the given scope."""
assert context.merged, "Merged result is empty"
actual = context.merged[0].scope.value
assert actual == scope, (
f"Expected first merged invariant scope '{scope}', got '{actual}'"
)
# ================================================================
# Thens — InvariantSet.merge() assertions
# ================================================================
@then("the invariant set should have {count:d} invariant")
def step_invariant_set_count_singular(context: object, count: int) -> None:
"""Assert the InvariantSet has exactly the given number of invariants (singular)."""
actual = len(context.invariant_set.invariants)
assert actual == count, f"Expected {count} invariant in set, got {actual}"
-2
View File
@@ -160,7 +160,6 @@ def step_plan_invariants_inactive(context):
def step_merge(context):
context.merged = merge_invariants(
getattr(context, "plan_invariants", []),
getattr(context, "action_invariants", []),
getattr(context, "project_invariants", []),
getattr(context, "global_invariants", []),
)
@@ -190,7 +189,6 @@ def step_merged_scope(context, idx, scope):
def step_merge_invariant_set(context):
inv_set = InvariantSet.merge(
getattr(context, "plan_invariants", []),
getattr(context, "action_invariants", []),
getattr(context, "project_invariants", []),
getattr(context, "global_invariants", []),
)
+2 -9
View File
@@ -809,9 +809,6 @@ class _BrokenSession:
def rollback(self) -> None:
pass
def close(self) -> None:
pass
def query(self, *_args: Any, **_kwargs: Any) -> Any:
raise SQLAlchemyDatabaseError("mock", {}, Exception("broken"))
@@ -988,10 +985,8 @@ def step_save_with_spy(context: Context) -> None:
object.__setattr__(real_session, "flush", spy_flush)
object.__setattr__(real_session, "commit", spy_commit)
# Pass the session explicitly to test the UoW path: save() must flush
# but must NOT commit (the caller owns the transaction boundary).
repo = LLMTraceRepository(session_factory=lambda: real_session)
repo.save(context.trace, session=real_session)
repo.save(context.trace)
# Commit so the data is visible for subsequent queries
object.__setattr__(real_session, "commit", original_commit)
real_session.commit()
@@ -1035,9 +1030,7 @@ def step_save_in_uow_rollback(context: Context) -> None:
session = context.uow_session_factory()
repo = LLMTraceRepository(session_factory=lambda: session)
try:
# Pass the session explicitly to use UoW mode: save() flushes but
# does NOT commit, so the caller's rollback can undo the change.
repo.save(context.trace, session=session)
repo.save(context.trace)
# Simulate a subsequent failure that triggers rollback
raise RuntimeError("Simulated failure after save")
except RuntimeError:
-11
View File
@@ -316,17 +316,6 @@ def step_then_temp_connection_closed(context) -> None:
assert context.current_rev_fake_engine.connections[0].exit_called is True
@then("the SQLite engine for get_current_revision should use check_same_thread=False")
def step_then_get_current_revision_check_same_thread(context) -> None:
_url, kwargs = context.current_rev_create_call
assert "connect_args" in kwargs, (
"Expected connect_args to be passed to create_engine for SQLite"
)
assert kwargs["connect_args"].get("check_same_thread") is False, (
"Expected check_same_thread=False in connect_args for SQLite engine"
)
@when("I initialize or upgrade a file-based SQLite database")
def step_when_init_file_based_sqlite(context) -> None:
import shutil
+2 -142
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import json
import os
import re
import tempfile
from datetime import datetime
from typing import Any
@@ -161,34 +160,14 @@ def step_existing_sessions(context: Context) -> None:
context.mock_service.list.return_value = sessions
@given("there are mocked existing named sessions")
def step_existing_named_sessions(context: Context) -> None:
"""Set up mocked sessions with names for Summary panel name-display test."""
sessions = [
_make_session(
session_id=_SESSION_ID,
actor_name="openai/gpt-4",
messages=[_make_message(sequence=0)],
),
_make_session(session_id=_SESSION_ID_2),
]
sessions[0].name = "weekly-planning"
sessions[1].name = "refactor-sprint"
context.mock_service.list.return_value = sessions
@when("I run session CLI list")
def step_list(context: Context) -> None:
context.result = context.runner.invoke(
session_app, ["list"], env={"COLUMNS": "200"}
)
context.result = context.runner.invoke(session_app, ["list"])
@when("I run session CLI list with --format json")
def step_list_json(context: Context) -> None:
context.result = context.runner.invoke(
session_app, ["list", "--format", "json"], env={"COLUMNS": "200"}
)
context.result = context.runner.invoke(session_app, ["list", "--format", "json"])
@then("the session CLI should show all sessions in a table")
@@ -197,125 +176,6 @@ def step_list_shows_table(context: Context) -> None:
assert "Sessions" in context.result.output
@then("the session CLI rich table should display full session ULIDs")
def step_list_rich_table_full_ulids(context: Context) -> None:
"""Verify the Rich table displays the full 26-character session ULIDs."""
assert context.result.exit_code == 0
output = context.result.output
# Restrict assertion to the table region (before the Summary panel) so
# that a regression back to 8-char truncation is not masked by the full
# ULID appearing elsewhere (e.g. in the Summary panel).
summary_idx = output.find("Summary")
table_output = output[:summary_idx] if summary_idx != -1 else output
assert _SESSION_ID in table_output, (
f"Full ULID {_SESSION_ID} not found in Rich table output:\n"
f"{context.result.output}"
)
assert _SESSION_ID_2 in table_output, (
f"Full ULID {_SESSION_ID_2} not found in Rich table output:\n"
f"{context.result.output}"
)
# Negative guard: ensure the table contains full 26-character ULIDs,
# not the old 8-character truncated form. A standalone 8-char prefix
# (not as part of the full ULID) would indicate the [:8] slice was not
# removed.
table_without_full_ids = table_output.replace(_SESSION_ID, "").replace(
_SESSION_ID_2, ""
)
assert _SESSION_ID[:8] not in table_without_full_ids, (
f"Truncated 8-char ID {_SESSION_ID[:8]} found in Rich table output:\n"
f"{context.result.output}"
)
assert _SESSION_ID_2[:8] not in table_without_full_ids, (
f"Truncated 8-char ID {_SESSION_ID_2[:8]} found in Rich table output:\n"
f"{context.result.output}"
)
@then("the session CLI summary panel should contain full session ULIDs")
def step_list_summary_full_ulids(context: Context) -> None:
"""Verify the Summary panel shows full ULIDs for unnamed sessions."""
assert context.result.exit_code == 0
output = context.result.output
assert "Summary" in output, f"Summary panel not found in output:\n{output}"
# Extract the Summary panel region to avoid a false pass from the Rich
# table also containing the same full ULIDs.
summary_idx = output.find("Summary")
summary_output = output[summary_idx:]
# Both Most Recent and Oldest entries should display full ULIDs when
# sessions are unnamed. Asserting only one ID would allow a regression
# that re-introduced [:8] on one fallback path while keeping the other
# intact to pass the test undetected.
assert _SESSION_ID in summary_output, (
f"Full ULID {_SESSION_ID} not found in Summary panel:\n{output}"
)
assert _SESSION_ID_2 in summary_output, (
f"Full ULID {_SESSION_ID_2} not found in Summary panel:\n{output}"
)
@then("the session CLI summary panel should show session names")
def step_list_summary_shows_names(context: Context) -> None:
"""Verify the Summary panel shows session names (not ULIDs) for named sessions."""
assert context.result.exit_code == 0
output = context.result.output
summary_idx = output.find("Summary")
assert summary_idx != -1, f"Summary panel not found in output:\n{output}"
summary_output = output[summary_idx:]
assert "weekly-planning" in summary_output, (
f"'weekly-planning' not found in Summary panel:\n{output}"
)
assert "refactor-sprint" in summary_output, (
f"'refactor-sprint' not found in Summary panel:\n{output}"
)
# The Summary should NOT show the raw ULID when session names are present
assert _SESSION_ID not in summary_output, (
f"Full ULID {_SESSION_ID} unexpectedly found in Summary panel:\n{output}"
)
assert _SESSION_ID_2 not in summary_output, (
f"Full ULID {_SESSION_ID_2} unexpectedly found in Summary panel:\n{output}"
)
@when("I capture the first session full ULID from the output")
def step_capture_first_ulid(context: Context) -> None:
"""Parse the first session's full ULID from the output and store it
for subsequent steps (round-trip tell test)."""
assert context.result.exit_code == 0
output = context.result.output
# Restrict the search to the table region (before the Summary panel)
# so that a ULID appearing in the Summary panel is not accidentally
# captured as the "first" session ID.
summary_idx = output.find("Summary")
search_region = output[:summary_idx] if summary_idx != -1 else output
match = re.search(r"[0-9A-HJKMNP-TV-Z]{26}", search_region)
assert match is not None, (
f"No 26-character ULID found in table output:\n{search_region}"
)
context.full_session_id = match.group()
assert len(context.full_session_id) == 26, (
f"Parsed ULID '{context.full_session_id}' is not 26 characters"
)
# Sanity check: the captured ID should match one of the known fixture IDs
assert context.full_session_id in (_SESSION_ID, _SESSION_ID_2), (
f"Captured ULID '{context.full_session_id}' does not match any fixture ID"
)
@when('I run session CLI tell with the full session ID and prompt "{prompt}"')
def step_tell_with_stored_ulid(context: Context, prompt: str) -> None:
"""Run session tell using the full ULID stored from session list."""
session_id = context.full_session_id
context.mock_service.append_message.side_effect = [
_make_message(MessageRole.USER, prompt, 0),
_make_message(MessageRole.ASSISTANT, f"Acknowledged: {prompt}", 1),
]
context.result = context.runner.invoke(
session_app,
["tell", "--session", session_id, prompt],
)
# ---------------------------------------------------------------------------
# Show
# ---------------------------------------------------------------------------
+2 -1
View File
@@ -61,6 +61,7 @@ nav:
- Reference/Command Input & Sessions: tui/input-and-sessions.md
- Configuration, Key Bindings & Integration: tui/configuration-and-integration.md
- FAQ: faq.md
- Quick Start: quickstart.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- Reference: reference/
@@ -93,7 +94,7 @@ nav:
- ADR-025 Observability & Logging: adr/ADR-025-observability-and-logging.md
- ADR-026 Agent-to-Agent Protocol (A2A): adr/ADR-026-agent-client-protocol.md
- ADR-027 Language Server Protocol (LSP) Integration: adr/ADR-027-language-server-protocol.md
- ADR-028 Agent Skills Standard (AgentSkills.io): adr/ADR-028-agent-skills-standard.md
- ADR-028 Skill/Tool Abstraction Definition: adr/ADR-028-agent-skills-standard.md
- ADR-029 Model Context Protocol (MCP) Adoption: adr/ADR-029-model-context-protocol.md
- ADR-030 Skill Abstraction Definition: adr/ADR-030-skill-abstraction-definition.md
- ADR-031 Actor Abstraction Definition: adr/ADR-031-actor-abstraction-definition.md
-2
View File
@@ -867,7 +867,6 @@ def invariants_enforced_during_strategize() -> None:
merged = merge_invariants(
plan_invariants=[plan_inv],
action_invariants=[],
project_invariants=[project_inv],
global_invariants=[global_inv],
)
@@ -891,7 +890,6 @@ def invariants_enforced_during_strategize() -> None:
invariant_set = InvariantSet.merge(
plan_invariants=[plan_inv],
action_invariants=[],
project_invariants=[project_inv],
global_invariants=[global_inv],
)
@@ -11,7 +11,7 @@ a dict keyed by invariant ID.
## Merge Precedence
Effective invariants are computed using plan > action > project > global order.
Effective invariants are computed using plan > project > global order.
See ``merge_invariants`` for de-duplication semantics.
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
@@ -124,7 +124,6 @@ class InvariantService:
if effective:
return self.get_effective_invariants(
plan_id=source_name if scope == InvariantScope.PLAN else None,
action_name=source_name if scope == InvariantScope.ACTION else None,
project_name=source_name if scope == InvariantScope.PROJECT else None,
)
@@ -168,19 +167,16 @@ class InvariantService:
def get_effective_invariants(
self,
plan_id: str | None = None,
action_name: str | None = None,
project_name: str | None = None,
) -> list[Invariant]:
"""Return the merged precedence chain for a plan/action/project context.
"""Return the merged precedence chain for a plan/project context.
Collects active invariants from each scope tier and merges them
using plan > action > project > global precedence.
using plan > project > global precedence.
Args:
plan_id: Optional plan identifier to collect plan-scoped
invariants.
action_name: Optional action name to collect action-scoped
invariants.
project_name: Optional project name to collect project-scoped
invariants.
@@ -195,12 +191,6 @@ class InvariantService:
if inv.scope == InvariantScope.PLAN
and (plan_id is None or inv.source_name == plan_id)
]
action_invs = [
inv
for inv in active
if inv.scope == InvariantScope.ACTION
and (action_name is None or inv.source_name == action_name)
]
project_invs = [
inv
for inv in active
@@ -209,7 +199,7 @@ class InvariantService:
]
global_invs = [inv for inv in active if inv.scope == InvariantScope.GLOBAL]
return merge_invariants(plan_invs, action_invs, project_invs, global_invs)
return merge_invariants(plan_invs, project_invs, global_invs)
def enforce_invariants(
self,
@@ -176,7 +176,6 @@ def list_invariants(
agents invariant list
agents invariant list --global
agents invariant list --effective --project myapp
agents invariant list --effective --action deploy-service
agents invariant list "data.*safe"
"""
try:
+3 -3
View File
@@ -151,8 +151,8 @@ def _session_list_dict(sessions: list[Session]) -> dict[str, Any]:
# Find most recent and oldest sessions
if sessions:
sorted_sessions = sorted(sessions, key=lambda x: x.updated_at, reverse=True)
most_recent = sorted_sessions[0].name or sorted_sessions[0].session_id
oldest = sorted_sessions[-1].name or sorted_sessions[-1].session_id
most_recent = sorted_sessions[0].name or sorted_sessions[0].session_id[:8]
oldest = sorted_sessions[-1].name or sorted_sessions[-1].session_id[:8]
else:
most_recent = None
oldest = None
@@ -347,7 +347,7 @@ def list_sessions(
for s in sessions:
table.add_row(
s.session_id, # Full ULID for copy-paste compatibility with session tell
s.session_id[:8], # Truncate ID for readability
s.name or "(unnamed)",
s.actor_name or "(none)",
str(s.message_count),
@@ -1,11 +1,10 @@
"""Invariant domain models for CleverAgents.
Invariants are natural-language constraints on plan execution, scoped at
global, project, action, or plan level. ACTION invariants participate
directly in the 4-tier merge chain rather than being promoted to plan-level.
The runtime precedence chain is:
global, project, action, or plan level. When an action is used, its
invariants are promoted to plan-level. The runtime precedence chain is:
plan > action > project > global
plan > project > global
They are reconciled by the Invariant Reconciliation Actor at the start
of Strategize and recorded as ``invariant_enforced`` decisions.
@@ -16,17 +15,14 @@ of Strategize and recorded as ``invariant_enforced`` decisions.
|------------|----------------------------------------------------|
| ``GLOBAL`` | Applies to every plan in the system |
| ``PROJECT``| Applies to plans targeting a specific project |
| ``ACTION`` | Defined in an action template; between PLAN and PROJECT in precedence |
| ``ACTION`` | Defined in an action template; promoted on ``use`` |
| ``PLAN`` | Attached directly to a specific plan |
## Merge Precedence
When computing the effective set of invariants for a plan, the merge
order is **plan > action > project > global**. Action-scoped invariants
sit between plan and project in the precedence chain they override
project and global constraints but are themselves overridden by
plan-level constraints. Duplicate texts (case-insensitive) are
de-duplicated, keeping the highest-precedence copy.
order is **plan > project > global**. Duplicate texts (case-insensitive)
are de-duplicated, keeping the highest-precedence copy.
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
"""
@@ -43,9 +39,8 @@ from ulid import ULID
class InvariantScope(StrEnum):
"""Scope at which an invariant applies.
Precedence (highest to lowest): PLAN > ACTION > PROJECT > GLOBAL.
ACTION invariants participate directly in the 4-tier merge chain,
sitting between PLAN and PROJECT in precedence order.
Precedence (highest to lowest): PLAN > PROJECT > GLOBAL.
ACTION invariants are promoted to PLAN scope at ``plan use`` time.
"""
GLOBAL = "global"
@@ -142,11 +137,10 @@ class InvariantSet(BaseModel):
def merge(
cls,
plan_invariants: list[Invariant],
action_invariants: list[Invariant],
project_invariants: list[Invariant],
global_invariants: list[Invariant],
) -> InvariantSet:
"""Merge invariants respecting plan > action > project > global precedence.
"""Merge invariants respecting plan > project > global precedence.
De-duplicates by text (case-insensitive), keeping the copy from
the highest-precedence tier. Within each tier, source ordering
@@ -154,8 +148,7 @@ class InvariantSet(BaseModel):
Args:
plan_invariants: Plan-level invariants (highest precedence).
action_invariants: Action-level invariants (second precedence).
project_invariants: Project-level invariants (third precedence).
project_invariants: Project-level invariants.
global_invariants: Global-level invariants (lowest precedence).
Returns:
@@ -163,12 +156,7 @@ class InvariantSet(BaseModel):
"""
return cls(
invariants=tuple(
merge_invariants(
plan_invariants,
action_invariants,
project_invariants,
global_invariants,
)
merge_invariants(plan_invariants, project_invariants, global_invariants)
)
)
@@ -177,11 +165,10 @@ class InvariantSet(BaseModel):
def merge_invariants(
plan_invariants: list[Invariant],
action_invariants: list[Invariant],
project_invariants: list[Invariant],
global_invariants: list[Invariant],
) -> list[Invariant]:
"""Merge invariants implementing plan > action > project > global precedence.
"""Merge invariants implementing plan > project > global precedence.
De-duplicates by text (case-insensitive). The first occurrence
(from the highest-precedence tier) wins. Within each tier, the
@@ -189,8 +176,7 @@ def merge_invariants(
Args:
plan_invariants: Plan-level invariants (highest precedence).
action_invariants: Action-level invariants (second precedence).
project_invariants: Project-level invariants (third precedence).
project_invariants: Project-level invariants.
global_invariants: Global-level invariants (lowest precedence).
Returns:
@@ -199,13 +185,7 @@ def merge_invariants(
seen: set[str] = set()
result: list[Invariant] = []
tiers = (
plan_invariants,
action_invariants,
project_invariants,
global_invariants,
)
for inv_list in tiers:
for inv_list in (plan_invariants, project_invariants, global_invariants):
for inv in inv_list:
if not inv.active:
continue
@@ -31,17 +31,6 @@ class LLMTraceRepository:
Uses the session-factory pattern: each public method obtains a
session from the factory. Callers are responsible for commit.
When ``save()`` is called with an explicit ``session`` argument the
repository operates in *UnitOfWork mode*: it flushes the change into
the caller's transaction but does **not** commit or close the session.
The caller (or the enclosing ``UnitOfWork``) is responsible for the
final commit.
When ``save()`` is called without an explicit ``session`` argument the
repository operates in *standalone mode*: it creates its own session
from the factory, flushes, commits, and closes the session so that the
trace is durably persisted even outside a ``UnitOfWork``.
"""
def __init__(
@@ -57,26 +46,16 @@ class LLMTraceRepository:
return self._sf()
@database_retry
def save(self, trace: LLMTrace, session: Session | None = None) -> None:
def save(self, trace: LLMTrace) -> None:
"""Persist a single ``LLMTrace`` row.
Args:
trace: The trace to persist. Must not be ``None``.
session: Optional external SQLAlchemy session. When provided
the repository flushes into the caller's transaction and
does **not** commit or close the session (UnitOfWork mode).
When omitted the repository creates its own session, commits,
and closes it (standalone mode).
trace: The trace to persist.
Raises:
ValueError: If ``trace`` is ``None``.
DatabaseError: On unrecoverable persistence failure.
"""
if trace is None:
raise ValueError("trace must not be None")
own_session = session is None
s: Session = self._session() if own_session else session
session = self._session()
try:
model = LLMTraceModel(
trace_id=trace.trace_id,
@@ -98,16 +77,11 @@ class LLMTraceRepository:
error=trace.error,
timestamp=trace.timestamp.isoformat(),
)
s.add(model)
s.flush()
if own_session:
s.commit()
session.add(model)
session.flush()
except (SQLAlchemyDatabaseError, OperationalError) as exc:
s.rollback()
session.rollback()
raise DatabaseError(f"Failed to save LLM trace: {exc}") from exc
finally:
if own_session:
s.close()
@database_retry
def get(self, trace_id: str) -> LLMTrace | None:
@@ -154,13 +154,7 @@ class MigrationRunner:
Returns:
Current revision ID or None if no migrations have been applied
"""
if self.database_url.startswith("sqlite"):
engine = create_engine(
self.database_url,
connect_args={"check_same_thread": False},
)
else:
engine = create_engine(self.database_url)
engine = create_engine(self.database_url)
with engine.connect() as connection:
context = MigrationContext.configure(connection)
return context.get_current_revision()