feat(project): add context policy model

This commit is contained in:
2026-02-17 09:00:52 +00:00
parent 5a8bedbc4e
commit 487cfb7ccd
9 changed files with 1132 additions and 15 deletions
+142
View File
@@ -0,0 +1,142 @@
"""ASV benchmarks for ProjectContextPolicy validation overhead.
Measures the performance of:
- ContextView construction (Pydantic validation)
- ProjectContextPolicy construction
- resolve_view() for each phase
- JSON serialization round-trip
"""
from __future__ import annotations
import sys
from pathlib import Path
try:
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
def _make_view() -> ContextView:
"""Create a fully-populated ContextView."""
return ContextView(
include_resources=["db-*", "cache-*"],
exclude_resources=["db-test"],
include_paths=["src/**/*.py", "lib/**"],
exclude_paths=["*.pyc", "__pycache__/**"],
max_file_size=1048576,
max_total_size=10485760,
)
def _make_policy() -> ProjectContextPolicy:
"""Create a fully-populated ProjectContextPolicy."""
return ProjectContextPolicy(
default_view=ContextView(
include_resources=["db-*"],
exclude_resources=["db-test"],
include_paths=["src/**"],
exclude_paths=["*.pyc"],
max_file_size=1048576,
max_total_size=10485760,
),
strategize_view=ContextView(
include_resources=["db-*", "cache-*"],
),
execute_view=ContextView(
include_paths=["src/**", "lib/**"],
),
apply_view=ContextView(
exclude_paths=["tests/**"],
),
)
class ContextViewValidationSuite:
"""Benchmark ContextView model construction."""
def time_view_construction(self) -> None:
"""Benchmark creating a fully-populated ContextView."""
_make_view()
def time_view_minimal_construction(self) -> None:
"""Benchmark creating a minimal ContextView."""
ContextView()
def time_view_with_size_limits(self) -> None:
"""Benchmark ContextView with size limit validation."""
ContextView(
max_file_size=1048576,
max_total_size=10485760,
)
class PolicyValidationSuite:
"""Benchmark ProjectContextPolicy model construction."""
def time_policy_construction(self) -> None:
"""Benchmark creating a fully-populated policy."""
_make_policy()
def time_policy_empty_construction(self) -> None:
"""Benchmark creating an empty policy."""
ProjectContextPolicy()
def time_policy_default_only(self) -> None:
"""Benchmark policy with only default view."""
ProjectContextPolicy(
default_view=_make_view(),
)
class PolicyResolveSuite:
"""Benchmark resolve_view() for each phase."""
def setup(self) -> None:
"""Create policy for resolve benchmarks."""
self.policy = _make_policy()
def time_resolve_default(self) -> None:
"""Benchmark resolve_view('default')."""
self.policy.resolve_view("default")
def time_resolve_strategize(self) -> None:
"""Benchmark resolve_view('strategize')."""
self.policy.resolve_view("strategize")
def time_resolve_execute(self) -> None:
"""Benchmark resolve_view('execute')."""
self.policy.resolve_view("execute")
def time_resolve_apply(self) -> None:
"""Benchmark resolve_view('apply')."""
self.policy.resolve_view("apply")
class PolicySerializationSuite:
"""Benchmark policy serialization."""
def setup(self) -> None:
"""Create policy for serialization benchmarks."""
self.policy = _make_policy()
def time_model_dump(self) -> None:
"""Benchmark model_dump() dict serialization."""
self.policy.model_dump()
def time_model_dump_json(self) -> None:
"""Benchmark model_dump_json() JSON serialization."""
self.policy.model_dump_json()
def time_json_roundtrip(self) -> None:
"""Benchmark JSON serialize + deserialize."""
json_str = self.policy.model_dump_json()
ProjectContextPolicy.model_validate_json(json_str)
+164
View File
@@ -0,0 +1,164 @@
# Project Context Policy
A `ProjectContextPolicy` controls what context (resources and files) is available
during each ACMS phase. It uses **view inheritance** so each phase can selectively
override or inherit from its parent.
## Inheritance Chain
```
default → strategize → execute → apply
```
| Phase | Inherits from |
|--------------|---------------|
| `default` | *(none)* |
| `strategize` | `default` |
| `execute` | `strategize` |
| `apply` | `execute` |
When resolving a view for a phase, the system walks up the inheritance chain and
returns the first explicitly-set `ContextView`. If no overrides are found, the
`default_view` is returned.
## ContextView
Each view controls:
| Field | Type | Default | Description |
|---------------------|----------------|---------|------------------------------------------|
| `include_resources` | `list[str]` | `[]` | Resource names/patterns to include |
| `exclude_resources` | `list[str]` | `[]` | Resource names/patterns to exclude |
| `include_paths` | `list[str]` | `[]` | File path globs to include |
| `exclude_paths` | `list[str]` | `[]` | File path globs to exclude |
| `max_file_size` | `int \| None` | `None` | Max file size in bytes (None = no limit) |
| `max_total_size` | `int \| None` | `None` | Max total context size (None = no limit) |
**Empty lists mean "include everything"** — no filtering is applied.
Exclusions always take precedence over inclusions.
## Examples
### Empty policy (include everything)
```python
from cleveragents.domain.models.core.context_policy import (
ProjectContextPolicy,
)
policy = ProjectContextPolicy()
view = policy.resolve_view("execute")
# view.include_resources == [] (all resources)
# view.include_paths == [] (all paths)
# view.max_file_size is None (no limit)
```
### Default view with overrides at strategize
```python
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
policy = ProjectContextPolicy(
default_view=ContextView(
include_resources=["db-*"],
exclude_paths=["*.pyc"],
max_file_size=1_048_576, # 1 MB
),
strategize_view=ContextView(
include_resources=["db-*", "cache-*"],
max_file_size=2_097_152, # 2 MB
),
)
# Strategize uses its own view
view = policy.resolve_view("strategize")
assert view.include_resources == ["db-*", "cache-*"]
assert view.max_file_size == 2_097_152
# Execute inherits from strategize (no execute_view set)
view = policy.resolve_view("execute")
assert view.include_resources == ["db-*", "cache-*"]
# Apply also inherits from strategize
view = policy.resolve_view("apply")
assert view.include_resources == ["db-*", "cache-*"]
```
### Override at execute level only
```python
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
policy = ProjectContextPolicy(
default_view=ContextView(
include_resources=["db-*"],
),
execute_view=ContextView(
include_resources=["db-*", "api-*"],
max_total_size=10_485_760, # 10 MB
),
)
# Strategize inherits from default (no strategize_view set)
view = policy.resolve_view("strategize")
assert view.include_resources == ["db-*"]
# Execute uses its own view
view = policy.resolve_view("execute")
assert view.include_resources == ["db-*", "api-*"]
# Apply inherits from execute
view = policy.resolve_view("apply")
assert view.include_resources == ["db-*", "api-*"]
```
### Size limits
```python
from cleveragents.domain.models.core.context_policy import (
ContextView,
)
# Valid size limits
view = ContextView(
max_file_size=1_048_576, # 1 MB per file
max_total_size=10_485_760, # 10 MB total
)
# None means no limit (default)
view = ContextView()
assert view.max_file_size is None
assert view.max_total_size is None
# Zero or negative values are rejected
# ContextView(max_file_size=0) -> ValidationError
# ContextView(max_file_size=-1) -> ValidationError
```
## JSON Serialization
```python
import json
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
policy = ProjectContextPolicy(
default_view=ContextView(include_resources=["db-*"]),
strategize_view=ContextView(include_resources=["cache-*"]),
)
# Serialize
data = json.loads(policy.model_dump_json())
# Deserialize
restored = ProjectContextPolicy.model_validate(data)
assert restored == policy
```
+148
View File
@@ -0,0 +1,148 @@
Feature: Project Context Policy Domain Model
As a developer
I want a project context policy model with view inheritance
So that context filtering can be configured per ACMS phase
# ---- Empty policy defaults ----
Scenario: Empty policy defaults to including everything
When I create an empty project context policy
Then the default view should include all resources
And the default view should include all paths
And the default view should have no file size limit
And the default view should have no total size limit
# ---- View inheritance ----
Scenario: Strategize inherits from default when not overridden
Given a policy with only a default view
When I resolve the view for phase "strategize"
Then the resolved view should be the default view
Scenario: Execute inherits from strategize when not overridden
Given a policy with default and strategize views
When I resolve the view for phase "execute"
Then the resolved view should be the strategize view
Scenario: Apply inherits from execute when not overridden
Given a policy with default strategize and execute views
When I resolve the view for phase "apply"
Then the resolved view should be the execute view
Scenario: Execute inherits from default when strategize is None
Given a policy with only a default view
When I resolve the view for phase "execute"
Then the resolved view should be the default view
Scenario: Apply falls through to default when all overrides are None
Given a policy with only a default view
When I resolve the view for phase "apply"
Then the resolved view should be the default view
Scenario: Resolve default phase returns default view
Given a policy with only a default view
When I resolve the view for phase "default"
Then the resolved view should be the default view
# ---- Override isolation ----
Scenario: Override at execute level does not affect strategize
Given a policy with a custom execute view
When I resolve the view for phase "strategize"
Then the resolved view should be the default view
Scenario: Override at execute returns execute view
Given a policy with a custom execute view
When I resolve the view for phase "execute"
Then the resolved view should be the execute view
# ---- Invalid phase name ----
Scenario: Invalid phase name raises error
Given a policy with only a default view
When I try to resolve the view for phase "invalid_phase"
Then a context policy error should be raised
And the context policy error should mention "Invalid phase"
Scenario: Unknown phase name raises error
Given a policy with only a default view
When I try to resolve the view for phase "plan"
Then a context policy error should be raised
And the context policy error should mention "Invalid phase"
# ---- Include/exclude resource patterns ----
Scenario: Include resources filters correctly
When I create a context view with include resources "db-*,cache-*"
Then the context view should have 2 include resources
Scenario: Exclude resources filters correctly
When I create a context view with exclude resources "temp-*"
Then the context view should have 1 exclude resource
Scenario: Combined include and exclude resources
When I create a context view with include "db-*" and exclude "db-test"
Then the context view should have 1 include resource
And the context view should have 1 exclude resource
# ---- Include/exclude path globs ----
Scenario: Include paths accepts globs
When I create a context view with include paths "src/**/*.py,tests/**"
Then the context view should have 2 include paths
Scenario: Exclude paths accepts globs
When I create a context view with exclude paths "*.pyc,__pycache__/**"
Then the context view should have 2 exclude paths
# ---- Size limit validation ----
Scenario: Valid max file size is accepted
When I create a context view with max file size 1048576
Then the context view max file size should be 1048576
Scenario: None max file size means no limit
When I create a context view with no file size limit
Then the context view max file size should be None
Scenario: Zero max file size raises error
When I try to create a context view with max file size 0
Then a context policy error should be raised
And the context policy error should mention "positive integer"
Scenario: Negative max file size raises error
When I try to create a context view with max file size -100
Then a context policy error should be raised
And the context policy error should mention "positive integer"
Scenario: Valid max total size is accepted
When I create a context view with max total size 10485760
Then the context view max total size should be 10485760
Scenario: Zero max total size raises error
When I try to create a context view with max total size 0
Then a context policy error should be raised
And the context policy error should mention "positive integer"
Scenario: Negative max total size raises error
When I try to create a context view with max total size -50
Then a context policy error should be raised
And the context policy error should mention "positive integer"
# ---- Serialization round-trip ----
Scenario: Policy survives JSON round-trip
Given a fully populated context policy
When I serialize and deserialize the policy
Then the deserialized policy should match the original
# ---- ContextView model_dump ----
Scenario: ContextView model_dump has expected keys
When I create a context view with defaults
Then the context view dump should have key "include_resources"
And the context view dump should have key "exclude_resources"
And the context view dump should have key "include_paths"
And the context view dump should have key "exclude_paths"
And the context view dump should have key "max_file_size"
And the context view dump should have key "max_total_size"
@@ -0,0 +1,347 @@
"""Step definitions for ProjectContextPolicy domain model tests."""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
# -------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------
def _default_view(**overrides: Any) -> ContextView:
"""Create a ContextView with sensible defaults."""
defaults: dict[str, Any] = {}
defaults.update(overrides)
return ContextView(**defaults)
# -------------------------------------------------------------------
# Empty policy defaults
# -------------------------------------------------------------------
@when("I create an empty project context policy")
def step_create_empty_policy(context: Context) -> None:
context.policy = ProjectContextPolicy()
context.resolved_view = context.policy.default_view
@then("the default view should include all resources")
def step_default_includes_all_resources(context: Context) -> None:
assert context.resolved_view.include_resources == []
@then("the default view should include all paths")
def step_default_includes_all_paths(context: Context) -> None:
assert context.resolved_view.include_paths == []
@then("the default view should have no file size limit")
def step_default_no_file_size_limit(context: Context) -> None:
assert context.resolved_view.max_file_size is None
@then("the default view should have no total size limit")
def step_default_no_total_size_limit(context: Context) -> None:
assert context.resolved_view.max_total_size is None
# -------------------------------------------------------------------
# View inheritance — Given steps
# -------------------------------------------------------------------
@given("a policy with only a default view")
def step_policy_default_only(context: Context) -> None:
context.default_view = ContextView(
include_resources=["db-*"],
max_file_size=1024,
)
context.policy = ProjectContextPolicy(
default_view=context.default_view,
)
@given("a policy with default and strategize views")
def step_policy_default_and_strategize(context: Context) -> None:
context.default_view = ContextView(
include_resources=["db-*"],
)
context.strategize_view = ContextView(
include_resources=["db-*", "cache-*"],
)
context.policy = ProjectContextPolicy(
default_view=context.default_view,
strategize_view=context.strategize_view,
)
@given("a policy with default strategize and execute views")
def step_policy_default_strat_exec(context: Context) -> None:
context.default_view = ContextView(
include_resources=["db-*"],
)
context.strategize_view = ContextView(
include_resources=["db-*", "cache-*"],
)
context.execute_view = ContextView(
include_resources=["db-*", "cache-*", "api-*"],
)
context.policy = ProjectContextPolicy(
default_view=context.default_view,
strategize_view=context.strategize_view,
execute_view=context.execute_view,
)
@given("a policy with a custom execute view")
def step_policy_custom_execute(context: Context) -> None:
context.default_view = ContextView(
include_resources=["db-*"],
)
context.execute_view = ContextView(
include_resources=["all-*"],
max_file_size=2048,
)
context.policy = ProjectContextPolicy(
default_view=context.default_view,
execute_view=context.execute_view,
)
# -------------------------------------------------------------------
# View inheritance — When/Then steps
# -------------------------------------------------------------------
@when('I resolve the view for phase "{phase}"')
def step_resolve_view(context: Context, phase: str) -> None:
context.resolved_view = context.policy.resolve_view(phase)
@then("the resolved view should be the default view")
def step_resolved_is_default(context: Context) -> None:
assert context.resolved_view is context.default_view
@then("the resolved view should be the strategize view")
def step_resolved_is_strategize(context: Context) -> None:
assert context.resolved_view is context.strategize_view
@then("the resolved view should be the execute view")
def step_resolved_is_execute(context: Context) -> None:
assert context.resolved_view is context.execute_view
# -------------------------------------------------------------------
# Invalid phase
# -------------------------------------------------------------------
@when('I try to resolve the view for phase "{phase}"')
def step_try_resolve_invalid_phase(context: Context, phase: str) -> None:
context.policy_error = None
try:
context.policy.resolve_view(phase)
except ValueError as exc:
context.policy_error = str(exc)
@then("a context policy error should be raised")
def step_policy_error_raised(context: Context) -> None:
assert context.policy_error is not None, "Expected an error but none was raised"
@then('the context policy error should mention "{text}"')
def step_error_mentions(context: Context, text: str) -> None:
assert text in context.policy_error, (
f"Expected '{text}' in error: {context.policy_error}"
)
# -------------------------------------------------------------------
# Include/exclude resources
# -------------------------------------------------------------------
@when('I create a context view with include resources "{patterns}"')
def step_create_view_include_resources(context: Context, patterns: str) -> None:
names = [p.strip() for p in patterns.split(",")]
context.ctx_view = ContextView(include_resources=names)
@then("the context view should have {count:d} include resources")
def step_view_include_resource_count(context: Context, count: int) -> None:
assert len(context.ctx_view.include_resources) == count
@when('I create a context view with exclude resources "{patterns}"')
def step_create_view_exclude_resources(context: Context, patterns: str) -> None:
names = [p.strip() for p in patterns.split(",")]
context.ctx_view = ContextView(exclude_resources=names)
@then("the context view should have {count:d} exclude resource")
def step_view_exclude_resource_count_singular(context: Context, count: int) -> None:
assert len(context.ctx_view.exclude_resources) == count
@when('I create a context view with include "{inc}" and exclude "{exc}"')
def step_create_view_include_exclude(context: Context, inc: str, exc: str) -> None:
context.ctx_view = ContextView(
include_resources=[inc],
exclude_resources=[exc],
)
@then("the context view should have {count:d} include resource")
def step_view_include_resource_count_singular(context: Context, count: int) -> None:
assert len(context.ctx_view.include_resources) == count
# -------------------------------------------------------------------
# Include/exclude paths
# -------------------------------------------------------------------
@when('I create a context view with include paths "{globs}"')
def step_create_view_include_paths(context: Context, globs: str) -> None:
paths = [g.strip() for g in globs.split(",")]
context.ctx_view = ContextView(include_paths=paths)
@then("the context view should have {count:d} include paths")
def step_view_include_path_count(context: Context, count: int) -> None:
assert len(context.ctx_view.include_paths) == count
@when('I create a context view with exclude paths "{globs}"')
def step_create_view_exclude_paths(context: Context, globs: str) -> None:
paths = [g.strip() for g in globs.split(",")]
context.ctx_view = ContextView(exclude_paths=paths)
@then("the context view should have {count:d} exclude paths")
def step_view_exclude_path_count(context: Context, count: int) -> None:
assert len(context.ctx_view.exclude_paths) == count
# -------------------------------------------------------------------
# Size limit validation
# -------------------------------------------------------------------
@when("I create a context view with max file size {size:d}")
def step_create_view_max_file_size(context: Context, size: int) -> None:
context.ctx_view = ContextView(max_file_size=size)
@then("the context view max file size should be {size:d}")
def step_view_max_file_size(context: Context, size: int) -> None:
assert context.ctx_view.max_file_size == size
@when("I create a context view with no file size limit")
def step_create_view_no_file_limit(context: Context) -> None:
context.ctx_view = ContextView()
@then("the context view max file size should be None")
def step_view_max_file_size_none(context: Context) -> None:
assert context.ctx_view.max_file_size is None
@when("I try to create a context view with max file size {size:d}")
def step_try_create_view_bad_file_size(context: Context, size: int) -> None:
context.policy_error = None
try:
ContextView(max_file_size=size)
except ValidationError as exc:
context.policy_error = str(exc)
@when("I create a context view with max total size {size:d}")
def step_create_view_max_total_size(context: Context, size: int) -> None:
context.ctx_view = ContextView(max_total_size=size)
@then("the context view max total size should be {size:d}")
def step_view_max_total_size(context: Context, size: int) -> None:
assert context.ctx_view.max_total_size == size
@when("I try to create a context view with max total size {size:d}")
def step_try_create_view_bad_total_size(context: Context, size: int) -> None:
context.policy_error = None
try:
ContextView(max_total_size=size)
except ValidationError as exc:
context.policy_error = str(exc)
# -------------------------------------------------------------------
# Serialization round-trip
# -------------------------------------------------------------------
@given("a fully populated context policy")
def step_full_policy(context: Context) -> None:
context.policy = ProjectContextPolicy(
default_view=ContextView(
include_resources=["db-*"],
exclude_resources=["db-test"],
include_paths=["src/**"],
exclude_paths=["*.pyc"],
max_file_size=1048576,
max_total_size=10485760,
),
strategize_view=ContextView(
include_resources=["db-*", "cache-*"],
),
execute_view=ContextView(
include_paths=["src/**", "lib/**"],
),
apply_view=ContextView(
exclude_paths=["tests/**"],
),
)
@when("I serialize and deserialize the policy")
def step_roundtrip(context: Context) -> None:
json_str = context.policy.model_dump_json()
context.roundtrip_policy = ProjectContextPolicy.model_validate_json(json_str)
@then("the deserialized policy should match the original")
def step_roundtrip_match(context: Context) -> None:
assert context.roundtrip_policy == context.policy
# -------------------------------------------------------------------
# ContextView model_dump keys
# -------------------------------------------------------------------
@when("I create a context view with defaults")
def step_create_default_view(context: Context) -> None:
context.ctx_view = ContextView()
context.ctx_view_dump = context.ctx_view.model_dump()
@then('the context view dump should have key "{key}"')
def step_view_dump_has_key(context: Context, key: str) -> None:
assert key in context.ctx_view_dump, (
f"Key '{key}' not in dump: {list(context.ctx_view_dump.keys())}"
)
+15 -15
View File
@@ -2451,24 +2451,24 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
**PARALLEL SUBTRACK B2.cli [Jeff]**: `project context` CLI scaffolding
**SEQUENTIAL MERGE NOTE**: B2.model lands before B2.cli; ACMS execution wiring is in Section 8.
- [ ] **COMMIT (Owner: Jeff | Group: B2.model | Branch: feature/m3-project-context-model | Planned: Day 14 | Expected: Day 18) - Commit message: "feat(project): add context policy model"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m3-project-context-model`
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Jeff]: Add `ProjectContextPolicy` model with `default`/`strategize`/`execute`/`apply` view inheritance rules.
- [ ] Code [Jeff]: Add validation for include/exclude resources, include/exclude path globs, and size limits.
- [ ] Docs [Jeff]: Add `docs/reference/project_context_policy.md` with view inheritance examples.
- [ ] Tests (Behave) [Jeff]: Add scenarios for view inheritance, empty policy defaulting, and invalid values.
- [ ] Tests (Robot) [Jeff]: Add Robot test that serializes a policy and validates structure.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/project_context_policy_bench.py` for validation overhead.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [ ] Git [Jeff]: `git add .`
- [ ] Git [Jeff]: `git commit -m "feat(project): add context policy model"`
- [X] **COMMIT (Owner: Jeff | Group: B2.model | Branch: feature/m3-project-context-model | Planned: Day 14 | Expected: Day 18) - Commit message: "feat(project): add context policy model"**
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
- [X] Git [Jeff]: `git checkout -b feature/m3-project-context-model`
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [X] Code [Jeff]: Add `ProjectContextPolicy` model with `default`/`strategize`/`execute`/`apply` view inheritance rules.
- [X] Code [Jeff]: Add validation for include/exclude resources, include/exclude path globs, and size limits.
- [X] Docs [Jeff]: Add `docs/reference/project_context_policy.md` with view inheritance examples.
- [X] Tests (Behave) [Jeff]: Add scenarios for view inheritance, empty policy defaulting, and invalid values.
- [X] Tests (Robot) [Jeff]: Add Robot test that serializes a policy and validates structure.
- [X] Tests (ASV) [Jeff]: Add `benchmarks/project_context_policy_bench.py` for validation overhead.
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [X] Git [Jeff]: `git add .`
- [X] Git [Jeff]: `git commit -m "feat(project): add context policy model"`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m3-project-context-model` to `master` with description "Add project context policy model + validation with tests/docs.".
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git branch -d feature/m3-project-context-model`
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] **COMMIT (Owner: Jeff | Group: B2.cli | Branch: feature/m3-project-context-cli | Planned: Day 15 | Expected: Day 19) - Commit message: "feat(cli): add project context commands"**
- [ ] Git [Jeff]: `git checkout master`
+108
View File
@@ -0,0 +1,108 @@
"""Helper utilities for ProjectContextPolicy Robot tests."""
from __future__ import annotations
import json
import sys
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
def _serialize_test() -> None:
"""Serialize a policy and validate structure."""
policy = ProjectContextPolicy(
default_view=ContextView(
include_resources=["db-*"],
exclude_resources=["db-test"],
include_paths=["src/**/*.py"],
exclude_paths=["*.pyc"],
max_file_size=1048576,
max_total_size=10485760,
),
strategize_view=ContextView(
include_resources=["db-*", "cache-*"],
),
)
data = json.loads(policy.model_dump_json())
# Validate top-level keys
assert "default_view" in data, "missing default_view"
assert "strategize_view" in data, "missing strategize_view"
assert "execute_view" in data, "missing execute_view"
assert "apply_view" in data, "missing apply_view"
# Validate default_view structure
dv = data["default_view"]
assert dv["include_resources"] == ["db-*"]
assert dv["exclude_resources"] == ["db-test"]
assert dv["include_paths"] == ["src/**/*.py"]
assert dv["exclude_paths"] == ["*.pyc"]
assert dv["max_file_size"] == 1048576
assert dv["max_total_size"] == 10485760
# Validate strategize_view
sv = data["strategize_view"]
assert sv["include_resources"] == ["db-*", "cache-*"]
# Validate None views are null
assert data["execute_view"] is None
assert data["apply_view"] is None
print("context-policy-serialize-ok")
def _resolve_test() -> None:
"""Test resolve_view inheritance."""
policy = ProjectContextPolicy(
default_view=ContextView(
include_resources=["default-*"],
),
strategize_view=ContextView(
include_resources=["strategize-*"],
),
)
# execute should inherit from strategize
view = policy.resolve_view("execute")
assert view.include_resources == ["strategize-*"]
# default should return default
view = policy.resolve_view("default")
assert view.include_resources == ["default-*"]
print("context-policy-resolve-ok")
def _empty_test() -> None:
"""Test empty policy defaults."""
policy = ProjectContextPolicy()
view = policy.resolve_view("apply")
assert view.include_resources == []
assert view.exclude_resources == []
assert view.include_paths == []
assert view.exclude_paths == []
assert view.max_file_size is None
assert view.max_total_size is None
print("context-policy-empty-ok")
def main() -> None:
if len(sys.argv) < 2:
raise SystemExit("Expected command argument")
command = sys.argv[1]
if command == "serialize":
_serialize_test()
elif command == "resolve":
_resolve_test()
elif command == "empty":
_empty_test()
else:
raise SystemExit(f"Unknown helper command: {command}")
if __name__ == "__main__":
main()
+27
View File
@@ -0,0 +1,27 @@
*** Settings ***
Documentation Smoke tests for ProjectContextPolicy model
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_project_context_policy.py
*** Test Cases ***
Context Policy Serializes Correctly
[Documentation] Ensure ProjectContextPolicy serializes with expected structure
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} serialize cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} context-policy-serialize-ok
Context Policy View Inheritance Resolves
[Documentation] Ensure resolve_view follows inheritance chain
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} resolve cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} context-policy-resolve-ok
Empty Context Policy Defaults To All
[Documentation] Ensure empty policy defaults to including everything
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} empty cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} context-policy-empty-ok
@@ -20,6 +20,12 @@ from cleveragents.domain.models.core.context import (
MaxContextCount,
SummaryForUpdateContextParams,
)
# Project context policy model
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
from cleveragents.domain.models.core.debug_attempt import DebugAttempt
from cleveragents.domain.models.core.org import (
CloudBillingFields,
@@ -140,6 +146,7 @@ __all__ = [
"ContextFile",
"ContextType",
"ContextUpdateResult",
"ContextView",
"CreditType",
"CreditsTransaction",
"CreditsTransactionType",
@@ -169,6 +176,7 @@ __all__ = [
"PlanTimestamps",
"ProcessingState",
"Project",
"ProjectContextPolicy",
"ProjectLink",
"ProjectSettings",
"ProjectStats",
@@ -0,0 +1,173 @@
"""Project context policy domain model for CleverAgents v3.
A **ProjectContextPolicy** controls what context (resources and files) is
available during each ACMS phase. It uses *view inheritance*:
default → strategize → execute → apply
Each phase can override or inherit from its parent. An empty policy
(``ProjectContextPolicy()``) defaults to including everything.
## View Inheritance
``resolve_view(phase)`` walks the inheritance chain and returns the
first explicitly-set ``ContextView`` for that phase (or defaults).
| Phase | Inherits from |
|------------|---------------|
| default | (none) |
| strategize | default |
| execute | strategize |
| apply | execute |
Based on ``docs/specification.md`` Context section and ADR-004.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
VALID_PHASES: frozenset[str] = frozenset({"default", "strategize", "execute", "apply"})
_INHERITANCE_CHAIN: dict[str, list[str]] = {
"default": ["default"],
"strategize": ["strategize", "default"],
"execute": ["execute", "strategize", "default"],
"apply": ["apply", "execute", "strategize", "default"],
}
# ---------------------------------------------------------------------------
# ContextView
# ---------------------------------------------------------------------------
class ContextView(BaseModel):
"""Defines what resources and files are visible in a phase.
Empty lists for ``include_resources`` and ``include_paths`` mean
"include everything" (no filtering). Exclusions always take
precedence over inclusions.
"""
include_resources: list[str] = Field(
default_factory=list,
description=("Resource names/patterns to include (empty = all)"),
)
exclude_resources: list[str] = Field(
default_factory=list,
description="Resource names/patterns to exclude",
)
include_paths: list[str] = Field(
default_factory=list,
description=("File path globs to include (empty = all)"),
)
exclude_paths: list[str] = Field(
default_factory=list,
description="File path globs to exclude",
)
max_file_size: int | None = Field(
default=None,
description=("Max file size in bytes to include in context (None = no limit)"),
)
max_total_size: int | None = Field(
default=None,
description=("Max total context size in bytes (None = no limit)"),
)
@field_validator("max_file_size", "max_total_size")
@classmethod
def _validate_positive_size(
cls: type[ContextView],
v: int | None,
) -> int | None:
"""Size limits must be positive when set."""
if v is not None and v <= 0:
raise ValueError("Size limit must be a positive integer or None")
return v
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
# ---------------------------------------------------------------------------
# ProjectContextPolicy
# ---------------------------------------------------------------------------
class ProjectContextPolicy(BaseModel):
"""Controls what context is available during each ACMS phase.
Uses view inheritance: ``default`` → ``strategize`` →
``execute`` → ``apply``. Each phase can override or inherit
from its parent.
An empty ``ProjectContextPolicy()`` defaults to including
everything (the ``default_view`` has empty include lists which
means "all").
"""
default_view: ContextView = Field(
default_factory=ContextView,
description="Base defaults for all phases",
)
strategize_view: ContextView | None = Field(
default=None,
description=("Overrides for Strategize (inherits from default if None)"),
)
execute_view: ContextView | None = Field(
default=None,
description=("Overrides for Execute (inherits from strategize if None)"),
)
apply_view: ContextView | None = Field(
default=None,
description=("Overrides for Apply (inherits from execute if None)"),
)
def resolve_view(self, phase: str) -> ContextView:
"""Resolve the effective view for a given phase.
Walks the inheritance chain and returns the first
explicitly-set ``ContextView``. If no overrides are found,
returns ``default_view``.
Args:
phase: One of ``"default"``, ``"strategize"``,
``"execute"``, or ``"apply"``.
Returns:
The resolved ``ContextView`` for the phase.
Raises:
ValueError: If *phase* is not a valid ACMS phase.
"""
if phase not in VALID_PHASES:
raise ValueError(
f"Invalid phase '{phase}': must be one of {sorted(VALID_PHASES)}"
)
view_map: dict[str, ContextView | None] = {
"default": self.default_view,
"strategize": self.strategize_view,
"execute": self.execute_view,
"apply": self.apply_view,
}
for ancestor in _INHERITANCE_CHAIN[phase]:
view = view_map[ancestor]
if view is not None:
return view
# Should never reach here; default_view is always set.
return self.default_view # pragma: no cover
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)