feat(domain): add decision model and context snapshots [H-16, D3.domain] #128
@@ -164,3 +164,6 @@ hive-mind-prompt-*.txt
|
||||
.cleveragents/
|
||||
.pabotsuitenames
|
||||
PIPE
|
||||
|
||||
# Git worktrees for parallel task branches
|
||||
worktrees/
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""ASV benchmarks for decision domain model operations.
|
||||
|
||||
Measures the performance of:
|
||||
- Decision model construction (Pydantic validation)
|
||||
- DecisionType enum access
|
||||
- ContextSnapshot construction
|
||||
- Decision serialization (model_dump / model_validate)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cleveragents.domain.models.core.decision import (
|
||||
ArtifactRef,
|
||||
ContextSnapshot,
|
||||
Decision,
|
||||
DecisionType,
|
||||
ResourceRef,
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
from cleveragents.domain.models.core.decision import (
|
||||
ArtifactRef,
|
||||
ContextSnapshot,
|
||||
Decision,
|
||||
DecisionType,
|
||||
ResourceRef,
|
||||
)
|
||||
|
||||
from ulid import ULID
|
||||
|
||||
_PLAN_ID = str(ULID())
|
||||
_PARENT_ID = str(ULID())
|
||||
_RESOURCE_ID = str(ULID())
|
||||
|
||||
|
||||
class DecisionConstructionSuite:
|
||||
"""Benchmark Decision model construction."""
|
||||
|
||||
def time_minimal_root_decision(self) -> None:
|
||||
"""Benchmark minimal prompt_definition creation."""
|
||||
Decision(
|
||||
plan_id=_PLAN_ID,
|
||||
sequence_number=0,
|
||||
decision_type=DecisionType.PROMPT_DEFINITION,
|
||||
question="What should we build?",
|
||||
chosen_option="A REST API",
|
||||
)
|
||||
|
||||
def time_child_decision_with_confidence(self) -> None:
|
||||
"""Benchmark child decision with confidence score."""
|
||||
Decision(
|
||||
plan_id=_PLAN_ID,
|
||||
parent_decision_id=_PARENT_ID,
|
||||
sequence_number=1,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
question="Which framework?",
|
||||
chosen_option="FastAPI",
|
||||
alternatives_considered=["Flask", "Django", "Starlette"],
|
||||
confidence_score=0.85,
|
||||
)
|
||||
|
||||
def time_decision_with_snapshot(self) -> None:
|
||||
"""Benchmark decision with full context snapshot."""
|
||||
Decision(
|
||||
plan_id=_PLAN_ID,
|
||||
parent_decision_id=_PARENT_ID,
|
||||
sequence_number=2,
|
||||
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
|
||||
question="How to implement auth?",
|
||||
chosen_option="JWT tokens",
|
||||
context_snapshot=ContextSnapshot(
|
||||
hot_context_hash="sha256:bench123",
|
||||
hot_context_ref="store://snapshots/bench123",
|
||||
relevant_resources=[
|
||||
ResourceRef(resource_id=_RESOURCE_ID, path="src/main.py"),
|
||||
ResourceRef(resource_id=str(ULID())),
|
||||
],
|
||||
actor_state_ref="checkpoint://actor/bench",
|
||||
),
|
||||
)
|
||||
|
||||
def time_correction_decision(self) -> None:
|
||||
"""Benchmark correction decision creation."""
|
||||
Decision(
|
||||
plan_id=_PLAN_ID,
|
||||
parent_decision_id=_PARENT_ID,
|
||||
sequence_number=3,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
question="Which framework?",
|
||||
chosen_option="Django instead",
|
||||
is_correction=True,
|
||||
corrects_decision_id=str(ULID()),
|
||||
correction_reason="Changed requirements",
|
||||
)
|
||||
|
||||
def time_decision_with_artifacts(self) -> None:
|
||||
"""Benchmark decision with artifacts list."""
|
||||
Decision(
|
||||
plan_id=_PLAN_ID,
|
||||
parent_decision_id=_PARENT_ID,
|
||||
sequence_number=4,
|
||||
decision_type=DecisionType.TOOL_INVOCATION,
|
||||
question="Which tool?",
|
||||
chosen_option="code_editor",
|
||||
artifacts_produced=[
|
||||
ArtifactRef(artifact_path=f"src/file_{i}.py") for i in range(10)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class DecisionSerializationSuite:
|
||||
"""Benchmark Decision serialization round-trips."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.decision = Decision(
|
||||
plan_id=_PLAN_ID,
|
||||
parent_decision_id=_PARENT_ID,
|
||||
sequence_number=5,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
question="Architecture choice?",
|
||||
chosen_option="Microservices",
|
||||
alternatives_considered=["Monolith", "Serverless"],
|
||||
confidence_score=0.78,
|
||||
context_snapshot=ContextSnapshot(
|
||||
hot_context_hash="sha256:serial",
|
||||
hot_context_ref="store://serial",
|
||||
relevant_resources=[
|
||||
ResourceRef(resource_id=_RESOURCE_ID, path="src/app.py"),
|
||||
],
|
||||
actor_state_ref="checkpoint://serial",
|
||||
),
|
||||
artifacts_produced=[
|
||||
ArtifactRef(artifact_path="src/service.py"),
|
||||
],
|
||||
)
|
||||
self.dump = self.decision.model_dump()
|
||||
|
||||
def time_model_dump(self) -> None:
|
||||
"""Benchmark model_dump serialization."""
|
||||
self.decision.model_dump()
|
||||
|
||||
def time_model_dump_json(self) -> None:
|
||||
"""Benchmark model_dump_json serialization."""
|
||||
self.decision.model_dump_json()
|
||||
|
||||
def time_model_validate(self) -> None:
|
||||
"""Benchmark model_validate deserialization."""
|
||||
Decision.model_validate(self.dump)
|
||||
|
||||
def time_as_cli_dict(self) -> None:
|
||||
"""Benchmark as_cli_dict rendering."""
|
||||
self.decision.as_cli_dict()
|
||||
|
||||
|
||||
class ContextSnapshotSuite:
|
||||
"""Benchmark ContextSnapshot construction."""
|
||||
|
||||
def time_empty_snapshot(self) -> None:
|
||||
"""Benchmark empty snapshot creation."""
|
||||
ContextSnapshot()
|
||||
|
||||
def time_snapshot_with_resources(self) -> None:
|
||||
"""Benchmark snapshot with multiple resource refs."""
|
||||
ContextSnapshot(
|
||||
hot_context_hash="sha256:bench",
|
||||
hot_context_ref="store://bench",
|
||||
relevant_resources=[
|
||||
ResourceRef(resource_id=str(ULID()), path=f"src/f{i}.py")
|
||||
for i in range(20)
|
||||
],
|
||||
actor_state_ref="checkpoint://bench",
|
||||
)
|
||||
|
||||
|
||||
class DecisionTypeEnumSuite:
|
||||
"""Benchmark DecisionType enum operations."""
|
||||
|
||||
def time_enum_access(self) -> None:
|
||||
"""Benchmark accessing an enum member."""
|
||||
_ = DecisionType.PROMPT_DEFINITION
|
||||
|
||||
def time_enum_from_value(self) -> None:
|
||||
"""Benchmark creating enum from string value."""
|
||||
DecisionType("strategy_choice")
|
||||
|
||||
def time_enum_iteration(self) -> None:
|
||||
"""Benchmark iterating all enum members."""
|
||||
list(DecisionType)
|
||||
@@ -0,0 +1,122 @@
|
||||
# Decision Domain Model
|
||||
|
||||
The decision subsystem records every choice point in a plan's lifecycle
|
||||
as a **Decision** node in a persistent tree. Decisions are created
|
||||
during the Strategize and Execute phases and form the basis for
|
||||
targeted correction and replay.
|
||||
|
||||
## Decision Types
|
||||
|
||||
| Type | Phase | Description |
|
||||
|--------------------------|------------|------------------------------------------|
|
||||
| `prompt_definition` | Strategize | Root decision — the plan prompt |
|
||||
| `invariant_enforced` | Strategize | An invariant constraint was applied |
|
||||
| `strategy_choice` | Strategize | High-level approach chosen |
|
||||
| `implementation_choice` | Execute | How to implement a specific task |
|
||||
| `resource_selection` | Execute | Which resources to read / modify |
|
||||
| `subplan_spawn` | Strategize | Decision to create a child plan |
|
||||
| `subplan_parallel_spawn` | Strategize | Spawn a group of child plans in parallel |
|
||||
| `tool_invocation` | Execute | Which skill / tool to use |
|
||||
| `error_recovery` | Execute | How to handle a failure |
|
||||
| `validation_response` | Execute | Response to a validation failure |
|
||||
| `user_intervention` | Any | User-provided guidance / correction |
|
||||
|
||||
## Decision Fields
|
||||
|
||||
### Identity
|
||||
|
||||
- **decision_id** — ULID, auto-generated
|
||||
- **plan_id** — ULID of the parent plan (required)
|
||||
- **parent_decision_id** — ULID of the parent decision, or `None` for the root
|
||||
- **sequence_number** — monotonic order within the plan (0-indexed, never reused)
|
||||
|
||||
### Classification
|
||||
|
||||
- **decision_type** — one of the 11 `DecisionType` enum values
|
||||
|
||||
### Content
|
||||
|
||||
- **question** — what question was being answered (required, non-empty)
|
||||
- **chosen_option** — the option that was selected (required, non-empty)
|
||||
- **alternatives_considered** — list of other options evaluated
|
||||
- **confidence_score** — float 0.0–1.0, or `None` if not applicable
|
||||
|
||||
### Context Snapshot
|
||||
|
||||
Every decision captures a `ContextSnapshot` for replay:
|
||||
|
||||
- **hot_context_hash** — cryptographic hash of the context window
|
||||
- **hot_context_ref** — storage pointer to the full serialised context
|
||||
- **relevant_resources** — list of `ResourceRef` entries (resource_id + optional path)
|
||||
- **actor_state_ref** — LangGraph actor checkpoint reference
|
||||
|
||||
### Rationale
|
||||
|
||||
- **rationale** — human-readable explanation
|
||||
- **actor_reasoning** — raw LLM reasoning trace, if available
|
||||
|
||||
### Downstream Impact
|
||||
|
||||
- **downstream_decision_ids** — ULIDs of decisions that depend on this one
|
||||
- **downstream_plan_ids** — ULIDs of child plans spawned from this decision
|
||||
- **artifacts_produced** — list of `ArtifactRef` (artifact_path + artifact_type)
|
||||
|
||||
### Timestamps
|
||||
|
||||
- **created_at** — UTC datetime, auto-set on creation
|
||||
|
||||
### Correction Metadata
|
||||
|
||||
- **is_correction** — boolean, True if this decision corrects another
|
||||
- **corrects_decision_id** — ULID of the original decision
|
||||
- **correction_reason** — why the correction was made
|
||||
- **superseded_by** — ULID of the decision that replaced this one
|
||||
|
||||
## Tree Structure
|
||||
|
||||
Decisions form a tree via `parent_decision_id`:
|
||||
|
||||
```
|
||||
prompt_definition (root, parent=None)
|
||||
├── invariant_enforced
|
||||
├── strategy_choice
|
||||
│ ├── implementation_choice
|
||||
│ │ ├── resource_selection
|
||||
│ │ └── tool_invocation
|
||||
│ └── subplan_spawn
|
||||
└── strategy_choice
|
||||
```
|
||||
|
||||
The `prompt_definition` type is always the root and must have
|
||||
`parent_decision_id = None`. This is enforced by a model validator.
|
||||
|
||||
## Correction Lifecycle
|
||||
|
||||
Corrections never mutate existing decisions. Instead:
|
||||
|
||||
1. A new `Decision` is created with `is_correction=True` and
|
||||
`corrects_decision_id` pointing to the original.
|
||||
2. The original decision has its `superseded_by` field set to the
|
||||
new decision's ID.
|
||||
3. All downstream decisions of the original are also superseded.
|
||||
|
||||
The **current tree** consists of all decisions where
|
||||
`superseded_by IS NULL`.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
- `plan_id` must be a valid ULID
|
||||
- `parent_decision_id`, `corrects_decision_id`, `superseded_by` must
|
||||
be valid ULIDs or None
|
||||
- `confidence_score` must be in [0.0, 1.0] or None
|
||||
- `prompt_definition` decisions must have `parent_decision_id = None`
|
||||
- If `is_correction` is True, `corrects_decision_id` must be set
|
||||
- If `corrects_decision_id` is set, `is_correction` must be True
|
||||
|
||||
## Source
|
||||
|
||||
- Domain model: `src/cleveragents/domain/models/core/decision.py`
|
||||
- Specification: `docs/specification.md` L18390–L18521
|
||||
- ADR-007: Decision tree and correction
|
||||
- ADR-033: Decision recording protocol
|
||||
- ADR-034: Decision tree versioning and history
|
||||
@@ -0,0 +1,246 @@
|
||||
Feature: Decision domain model
|
||||
Validates the Decision, DecisionType, ContextSnapshot, ResourceRef,
|
||||
and ArtifactRef domain models including ULID validation, enum coverage,
|
||||
tree structure helpers, correction constraints, and serialization.
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DecisionType enum
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: All 11 decision types are defined
|
||||
Then the DecisionType enum should have exactly 11 members
|
||||
|
||||
Scenario: Strategize-phase types are correctly classified
|
||||
Then STRATEGIZE_TYPES should contain "prompt_definition"
|
||||
And STRATEGIZE_TYPES should contain "invariant_enforced"
|
||||
And STRATEGIZE_TYPES should contain "strategy_choice"
|
||||
And STRATEGIZE_TYPES should contain "subplan_spawn"
|
||||
And STRATEGIZE_TYPES should contain "subplan_parallel_spawn"
|
||||
And STRATEGIZE_TYPES should have exactly 5 members
|
||||
|
||||
Scenario: Execute-phase types are correctly classified
|
||||
Then EXECUTE_TYPES should contain "implementation_choice"
|
||||
And EXECUTE_TYPES should contain "resource_selection"
|
||||
And EXECUTE_TYPES should contain "tool_invocation"
|
||||
And EXECUTE_TYPES should contain "error_recovery"
|
||||
And EXECUTE_TYPES should contain "validation_response"
|
||||
And EXECUTE_TYPES should have exactly 5 members
|
||||
|
||||
Scenario: user_intervention is not in either phase set
|
||||
Then STRATEGIZE_TYPES should not contain "user_intervention"
|
||||
And EXECUTE_TYPES should not contain "user_intervention"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Decision creation and ULID validation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: Create a minimal root decision
|
||||
Given a valid plan ULID
|
||||
When I create a prompt_definition decision with sequence 0
|
||||
Then the decision should be created successfully
|
||||
And the decision should be a root decision
|
||||
And the decision should have a valid ULID as decision_id
|
||||
And the decision type should be "prompt_definition"
|
||||
And the decision is_strategize_type should be true
|
||||
|
||||
Scenario: Create a non-root decision with parent
|
||||
Given a valid plan ULID
|
||||
And a valid parent decision ULID
|
||||
When I create a strategy_choice decision with sequence 1 and a parent
|
||||
Then the decision should be created successfully
|
||||
And the decision should not be a root decision
|
||||
And the decision is_strategize_type should be true
|
||||
|
||||
Scenario: Create an execute-phase decision
|
||||
Given a valid plan ULID
|
||||
And a valid parent decision ULID
|
||||
When I create an implementation_choice decision with sequence 2 and a parent
|
||||
Then the decision should be created successfully
|
||||
And the decision is_execute_type should be true
|
||||
And the decision is_strategize_type should be false
|
||||
|
||||
Scenario: user_intervention is valid in any phase
|
||||
Given a valid plan ULID
|
||||
When I create a user_intervention decision with sequence 3
|
||||
Then the decision should be created successfully
|
||||
And the decision is_any_phase_type should be true
|
||||
|
||||
Scenario: Invalid plan_id is rejected
|
||||
When I try to create a decision with plan_id "not-a-ulid"
|
||||
Then a decision validation error should be raised
|
||||
And the decision error should mention "plan_id"
|
||||
|
||||
Scenario: Invalid parent_decision_id is rejected
|
||||
Given a valid plan ULID
|
||||
When I try to create a decision with parent_decision_id "bad-id"
|
||||
Then a decision validation error should be raised
|
||||
And the decision error should mention "parent_decision_id"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# prompt_definition root constraint
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: prompt_definition with a parent is rejected
|
||||
Given a valid plan ULID
|
||||
And a valid parent decision ULID
|
||||
When I try to create a prompt_definition with a parent
|
||||
Then a decision validation error should be raised
|
||||
And the decision error should mention "root"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Confidence score validation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: Confidence score within valid range
|
||||
Given a valid plan ULID
|
||||
When I create a decision with confidence score 0.85
|
||||
Then the decision should be created successfully
|
||||
And the decision confidence score should be 0.85
|
||||
|
||||
Scenario: Confidence score of 0.0 is valid
|
||||
Given a valid plan ULID
|
||||
When I create a decision with confidence score 0.0
|
||||
Then the decision should be created successfully
|
||||
|
||||
Scenario: Confidence score of 1.0 is valid
|
||||
Given a valid plan ULID
|
||||
When I create a decision with confidence score 1.0
|
||||
Then the decision should be created successfully
|
||||
|
||||
Scenario: Confidence score above 1.0 is rejected
|
||||
Given a valid plan ULID
|
||||
When I try to create a decision with confidence score 1.5
|
||||
Then a decision validation error should be raised
|
||||
|
||||
Scenario: Confidence score below 0.0 is rejected
|
||||
Given a valid plan ULID
|
||||
When I try to create a decision with confidence score -0.1
|
||||
Then a decision validation error should be raised
|
||||
|
||||
Scenario: Confidence score of None is valid
|
||||
Given a valid plan ULID
|
||||
When I create a decision with confidence score None
|
||||
Then the decision should be created successfully
|
||||
And the decision confidence score should be None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Correction metadata
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: Create a correction decision
|
||||
Given a valid plan ULID
|
||||
And a valid corrected decision ULID
|
||||
When I create a correction decision
|
||||
Then the decision should be created successfully
|
||||
And the decision is_correction should be true
|
||||
|
||||
Scenario: is_correction without corrects_decision_id is rejected
|
||||
Given a valid plan ULID
|
||||
When I try to create a decision with is_correction true but no corrects_decision_id
|
||||
Then a decision validation error should be raised
|
||||
And the decision error should mention "corrects_decision_id"
|
||||
|
||||
Scenario: corrects_decision_id without is_correction is rejected
|
||||
Given a valid plan ULID
|
||||
And a valid corrected decision ULID
|
||||
When I try to create a decision with corrects_decision_id but is_correction false
|
||||
Then a decision validation error should be raised
|
||||
And the decision error should mention "is_correction"
|
||||
|
||||
Scenario: superseded_by marks decision as superseded
|
||||
Given a valid plan ULID
|
||||
When I create a decision that is superseded
|
||||
Then the decision is_superseded should be true
|
||||
|
||||
Scenario: Invalid superseded_by ULID is rejected
|
||||
Given a valid plan ULID
|
||||
When I try to create a decision with superseded_by "invalid"
|
||||
Then a decision validation error should be raised
|
||||
And the decision error should mention "superseded_by"
|
||||
|
||||
Scenario: Invalid corrects_decision_id ULID is rejected
|
||||
Given a valid plan ULID
|
||||
When I try to create a decision with corrects_decision_id ULID "not-valid"
|
||||
Then a decision validation error should be raised
|
||||
And the decision error should mention "corrects_decision_id"
|
||||
|
||||
Scenario: with_superseded_by returns a new superseded copy
|
||||
Given a valid plan ULID
|
||||
When I create a prompt_definition decision with sequence 0
|
||||
And I call with_superseded_by on the decision
|
||||
Then the original decision should not be superseded
|
||||
And the superseded copy should be superseded
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ContextSnapshot
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: Create a decision with a populated context snapshot
|
||||
Given a valid plan ULID
|
||||
And a context snapshot with hash and resources
|
||||
When I create a decision with the context snapshot
|
||||
Then the decision should be created successfully
|
||||
And the decision context snapshot hash should not be empty
|
||||
And the decision context snapshot should have resources
|
||||
|
||||
Scenario: Default context snapshot is empty
|
||||
Given a valid plan ULID
|
||||
When I create a prompt_definition decision with sequence 0
|
||||
Then the decision context snapshot hash should be empty
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ResourceRef and ArtifactRef
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: ResourceRef requires non-empty resource_id
|
||||
When I try to create a ResourceRef with empty resource_id
|
||||
Then a decision validation error should be raised
|
||||
|
||||
Scenario: ArtifactRef requires non-empty artifact_path
|
||||
When I try to create an ArtifactRef with empty artifact_path
|
||||
Then a decision validation error should be raised
|
||||
|
||||
Scenario: Decision with artifacts_produced
|
||||
Given a valid plan ULID
|
||||
When I create a decision with artifacts
|
||||
Then the decision should have 2 artifacts produced
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serialization
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: Decision round-trips through dict serialization
|
||||
Given a valid plan ULID
|
||||
When I create a fully populated decision
|
||||
Then the decision should round-trip through model_dump and model_validate
|
||||
|
||||
Scenario: as_cli_dict returns expected keys
|
||||
Given a valid plan ULID
|
||||
When I create a prompt_definition decision with sequence 0
|
||||
Then as_cli_dict should contain key "decision_id"
|
||||
And as_cli_dict should contain key "type"
|
||||
And as_cli_dict should contain key "confidence"
|
||||
And as_cli_dict should contain key "parent"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# All 11 decision types can be instantiated
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario Outline: Each decision type can be instantiated
|
||||
Given a valid plan ULID
|
||||
When I create a decision of type "<dtype>"
|
||||
Then the decision should be created successfully
|
||||
|
||||
Examples:
|
||||
| dtype |
|
||||
| prompt_definition |
|
||||
| invariant_enforced |
|
||||
| strategy_choice |
|
||||
| implementation_choice |
|
||||
| resource_selection |
|
||||
| subplan_spawn |
|
||||
| subplan_parallel_spawn |
|
||||
| tool_invocation |
|
||||
| error_recovery |
|
||||
| validation_response |
|
||||
| user_intervention |
|
||||
@@ -0,0 +1,556 @@
|
||||
"""Step definitions for decision_model.feature.
|
||||
|
||||
Tests the Decision, DecisionType, ContextSnapshot, ResourceRef,
|
||||
and ArtifactRef domain models.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
from behave.runner import Context
|
||||
from pydantic import ValidationError
|
||||
from ulid import ULID
|
||||
|
||||
from cleveragents.domain.models.core.decision import (
|
||||
EXECUTE_TYPES,
|
||||
STRATEGIZE_TYPES,
|
||||
ArtifactRef,
|
||||
ContextSnapshot,
|
||||
Decision,
|
||||
DecisionType,
|
||||
ResourceRef,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_PLAN_ULID = str(ULID())
|
||||
_VALID_PARENT_ULID = str(ULID())
|
||||
_VALID_CORRECTED_ULID = str(ULID())
|
||||
_VALID_SUPERSEDED_ULID = str(ULID())
|
||||
|
||||
|
||||
def _make_decision(
|
||||
plan_id: str = _VALID_PLAN_ULID,
|
||||
parent_decision_id: str | None = None,
|
||||
sequence_number: int = 0,
|
||||
decision_type: DecisionType = DecisionType.PROMPT_DEFINITION,
|
||||
question: str = "What approach should we take?",
|
||||
chosen_option: str = "Build a REST API",
|
||||
confidence_score: float | None = None,
|
||||
context_snapshot: ContextSnapshot | None = None,
|
||||
is_correction: bool = False,
|
||||
corrects_decision_id: str | None = None,
|
||||
correction_reason: str | None = None,
|
||||
superseded_by: str | None = None,
|
||||
artifacts_produced: list[ArtifactRef] | None = None,
|
||||
) -> Decision:
|
||||
kwargs: dict[str, Any] = {
|
||||
"plan_id": plan_id,
|
||||
"sequence_number": sequence_number,
|
||||
"decision_type": decision_type,
|
||||
"question": question,
|
||||
"chosen_option": chosen_option,
|
||||
"is_correction": is_correction,
|
||||
}
|
||||
if parent_decision_id is not None:
|
||||
kwargs["parent_decision_id"] = parent_decision_id
|
||||
if confidence_score is not None:
|
||||
kwargs["confidence_score"] = confidence_score
|
||||
if context_snapshot is not None:
|
||||
kwargs["context_snapshot"] = context_snapshot
|
||||
if corrects_decision_id is not None:
|
||||
kwargs["corrects_decision_id"] = corrects_decision_id
|
||||
if correction_reason is not None:
|
||||
kwargs["correction_reason"] = correction_reason
|
||||
if superseded_by is not None:
|
||||
kwargs["superseded_by"] = superseded_by
|
||||
if artifacts_produced is not None:
|
||||
kwargs["artifacts_produced"] = artifacts_produced
|
||||
return Decision(**kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DecisionType enum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the DecisionType enum should have exactly {count:d} members")
|
||||
def step_decision_type_count(context: Context, count: int) -> None:
|
||||
assert len(DecisionType) == count, (
|
||||
f"Expected {count} members, got {len(DecisionType)}"
|
||||
)
|
||||
|
||||
|
||||
@then('STRATEGIZE_TYPES should contain "{dtype}"')
|
||||
def step_strategize_contains(context: Context, dtype: str) -> None:
|
||||
assert DecisionType(dtype) in STRATEGIZE_TYPES, f"{dtype} not in STRATEGIZE_TYPES"
|
||||
|
||||
|
||||
@then('STRATEGIZE_TYPES should not contain "{dtype}"')
|
||||
def step_strategize_not_contains(context: Context, dtype: str) -> None:
|
||||
assert DecisionType(dtype) not in STRATEGIZE_TYPES
|
||||
|
||||
|
||||
@then("STRATEGIZE_TYPES should have exactly {count:d} members")
|
||||
def step_strategize_count(context: Context, count: int) -> None:
|
||||
assert len(STRATEGIZE_TYPES) == count
|
||||
|
||||
|
||||
@then('EXECUTE_TYPES should contain "{dtype}"')
|
||||
def step_execute_contains(context: Context, dtype: str) -> None:
|
||||
assert DecisionType(dtype) in EXECUTE_TYPES, f"{dtype} not in EXECUTE_TYPES"
|
||||
|
||||
|
||||
@then('EXECUTE_TYPES should not contain "{dtype}"')
|
||||
def step_execute_not_contains(context: Context, dtype: str) -> None:
|
||||
assert DecisionType(dtype) not in EXECUTE_TYPES
|
||||
|
||||
|
||||
@then("EXECUTE_TYPES should have exactly {count:d} members")
|
||||
def step_execute_count(context: Context, count: int) -> None:
|
||||
assert len(EXECUTE_TYPES) == count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a valid plan ULID")
|
||||
def step_valid_plan_ulid(context: Context) -> None:
|
||||
context.decision_plan_id = str(ULID())
|
||||
|
||||
|
||||
@given("a valid parent decision ULID")
|
||||
def step_valid_parent_ulid(context: Context) -> None:
|
||||
context.decision_parent_id = str(ULID())
|
||||
|
||||
|
||||
@given("a valid corrected decision ULID")
|
||||
def step_valid_corrected_ulid(context: Context) -> None:
|
||||
context.decision_corrected_id = str(ULID())
|
||||
|
||||
|
||||
@given("a context snapshot with hash and resources")
|
||||
def step_context_snapshot(context: Context) -> None:
|
||||
context.decision_snapshot = ContextSnapshot(
|
||||
hot_context_hash="sha256:abc123",
|
||||
hot_context_ref="store://snapshots/abc123",
|
||||
relevant_resources=[
|
||||
ResourceRef(resource_id=str(ULID()), path="src/main.py"),
|
||||
ResourceRef(resource_id=str(ULID())),
|
||||
],
|
||||
actor_state_ref="checkpoint://actor/001",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps — successful creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I create a prompt_definition decision with sequence {seq:d}")
|
||||
def step_create_root(context: Context, seq: int) -> None:
|
||||
context.decision_result = _make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
sequence_number=seq,
|
||||
decision_type=DecisionType.PROMPT_DEFINITION,
|
||||
)
|
||||
|
||||
|
||||
@when("I create a strategy_choice decision with sequence {seq:d} and a parent")
|
||||
def step_create_strategy(context: Context, seq: int) -> None:
|
||||
context.decision_result = _make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
parent_decision_id=context.decision_parent_id,
|
||||
sequence_number=seq,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
)
|
||||
|
||||
|
||||
@when("I create an implementation_choice decision with sequence {seq:d} and a parent")
|
||||
def step_create_impl(context: Context, seq: int) -> None:
|
||||
context.decision_result = _make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
parent_decision_id=context.decision_parent_id,
|
||||
sequence_number=seq,
|
||||
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
|
||||
)
|
||||
|
||||
|
||||
@when("I create a user_intervention decision with sequence {seq:d}")
|
||||
def step_create_user_intervention(context: Context, seq: int) -> None:
|
||||
context.decision_result = _make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
sequence_number=seq,
|
||||
decision_type=DecisionType.USER_INTERVENTION,
|
||||
)
|
||||
|
||||
|
||||
@when("I create a decision with confidence score {score}")
|
||||
def step_create_with_confidence(context: Context, score: str) -> None:
|
||||
conf = None if score == "None" else float(score)
|
||||
context.decision_result = _make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
confidence_score=conf,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
)
|
||||
|
||||
|
||||
@when("I create a correction decision")
|
||||
def step_create_correction(context: Context) -> None:
|
||||
context.decision_result = _make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
is_correction=True,
|
||||
corrects_decision_id=context.decision_corrected_id,
|
||||
correction_reason="Original approach was suboptimal",
|
||||
)
|
||||
|
||||
|
||||
@when("I create a decision that is superseded")
|
||||
def step_create_superseded(context: Context) -> None:
|
||||
context.decision_result = _make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
superseded_by=str(ULID()),
|
||||
)
|
||||
|
||||
|
||||
@when("I create a decision with the context snapshot")
|
||||
def step_create_with_snapshot(context: Context) -> None:
|
||||
context.decision_result = _make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
context_snapshot=context.decision_snapshot,
|
||||
)
|
||||
|
||||
|
||||
@when("I create a decision with artifacts")
|
||||
def step_create_with_artifacts(context: Context) -> None:
|
||||
context.decision_result = _make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
artifacts_produced=[
|
||||
ArtifactRef(artifact_path="src/api.py", artifact_type="file"),
|
||||
ArtifactRef(artifact_path="patches/fix.patch", artifact_type="patch"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@when("I create a fully populated decision")
|
||||
def step_create_fully_populated(context: Context) -> None:
|
||||
context.decision_result = _make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
parent_decision_id=str(ULID()),
|
||||
sequence_number=5,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
question="Which framework?",
|
||||
chosen_option="FastAPI",
|
||||
confidence_score=0.92,
|
||||
context_snapshot=ContextSnapshot(
|
||||
hot_context_hash="sha256:def456",
|
||||
hot_context_ref="store://snapshots/def456",
|
||||
relevant_resources=[ResourceRef(resource_id=str(ULID()))],
|
||||
actor_state_ref="checkpoint://actor/005",
|
||||
),
|
||||
artifacts_produced=[ArtifactRef(artifact_path="src/app.py")],
|
||||
)
|
||||
|
||||
|
||||
@when('I create a decision of type "{dtype}"')
|
||||
def step_create_any_type(context: Context, dtype: str) -> None:
|
||||
dt = DecisionType(dtype)
|
||||
parent = None if dt == DecisionType.PROMPT_DEFINITION else str(ULID())
|
||||
context.decision_result = _make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
parent_decision_id=parent,
|
||||
decision_type=dt,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps — expected failures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I try to create a decision with plan_id "{plan_id}"')
|
||||
def step_try_invalid_plan_id(context: Context, plan_id: str) -> None:
|
||||
try:
|
||||
_make_decision(plan_id=plan_id)
|
||||
context.decision_error = None
|
||||
except ValidationError as exc:
|
||||
context.decision_error = exc
|
||||
|
||||
|
||||
@when('I try to create a decision with parent_decision_id "{parent_id}"')
|
||||
def step_try_invalid_parent(context: Context, parent_id: str) -> None:
|
||||
try:
|
||||
_make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
parent_decision_id=parent_id,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
)
|
||||
context.decision_error = None
|
||||
except ValidationError as exc:
|
||||
context.decision_error = exc
|
||||
|
||||
|
||||
@when("I try to create a prompt_definition with a parent")
|
||||
def step_try_root_with_parent(context: Context) -> None:
|
||||
try:
|
||||
_make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
parent_decision_id=context.decision_parent_id,
|
||||
decision_type=DecisionType.PROMPT_DEFINITION,
|
||||
)
|
||||
context.decision_error = None
|
||||
except ValidationError as exc:
|
||||
context.decision_error = exc
|
||||
|
||||
|
||||
@when("I try to create a decision with confidence score {score}")
|
||||
def step_try_bad_confidence(context: Context, score: str) -> None:
|
||||
try:
|
||||
_make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
confidence_score=float(score),
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
)
|
||||
context.decision_error = None
|
||||
except ValidationError as exc:
|
||||
context.decision_error = exc
|
||||
|
||||
|
||||
@when("I try to create a decision with is_correction true but no corrects_decision_id")
|
||||
def step_try_correction_no_target(context: Context) -> None:
|
||||
try:
|
||||
_make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
is_correction=True,
|
||||
)
|
||||
context.decision_error = None
|
||||
except ValidationError as exc:
|
||||
context.decision_error = exc
|
||||
|
||||
|
||||
@when("I try to create a decision with corrects_decision_id but is_correction false")
|
||||
def step_try_correction_flag_mismatch(context: Context) -> None:
|
||||
try:
|
||||
_make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
is_correction=False,
|
||||
corrects_decision_id=context.decision_corrected_id,
|
||||
)
|
||||
context.decision_error = None
|
||||
except ValidationError as exc:
|
||||
context.decision_error = exc
|
||||
|
||||
|
||||
@when('I try to create a decision with superseded_by "{value}"')
|
||||
def step_try_bad_superseded(context: Context, value: str) -> None:
|
||||
try:
|
||||
_make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
superseded_by=value,
|
||||
)
|
||||
context.decision_error = None
|
||||
except ValidationError as exc:
|
||||
context.decision_error = exc
|
||||
|
||||
|
||||
@when('I try to create a decision with corrects_decision_id ULID "{value}"')
|
||||
def step_try_bad_corrects_id(context: Context, value: str) -> None:
|
||||
try:
|
||||
_make_decision(
|
||||
plan_id=context.decision_plan_id,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
is_correction=True,
|
||||
corrects_decision_id=value,
|
||||
)
|
||||
context.decision_error = None
|
||||
except ValidationError as exc:
|
||||
context.decision_error = exc
|
||||
|
||||
|
||||
@when("I call with_superseded_by on the decision")
|
||||
def step_call_with_superseded_by(context: Context) -> None:
|
||||
context.decision_superseded_copy = context.decision_result.with_superseded_by(
|
||||
str(ULID())
|
||||
)
|
||||
|
||||
|
||||
@when("I try to create a ResourceRef with empty resource_id")
|
||||
def step_try_empty_resource_ref(context: Context) -> None:
|
||||
try:
|
||||
ResourceRef(resource_id="")
|
||||
context.decision_error = None
|
||||
except ValidationError as exc:
|
||||
context.decision_error = exc
|
||||
|
||||
|
||||
@when("I try to create an ArtifactRef with empty artifact_path")
|
||||
def step_try_empty_artifact_ref(context: Context) -> None:
|
||||
try:
|
||||
ArtifactRef(artifact_path="")
|
||||
context.decision_error = None
|
||||
except ValidationError as exc:
|
||||
context.decision_error = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the decision should be created successfully")
|
||||
def step_decision_created(context: Context) -> None:
|
||||
assert context.decision_result is not None
|
||||
assert isinstance(context.decision_result, Decision)
|
||||
|
||||
|
||||
@then("the decision should be a root decision")
|
||||
def step_is_root(context: Context) -> None:
|
||||
assert context.decision_result.is_root
|
||||
|
||||
|
||||
@then("the decision should not be a root decision")
|
||||
def step_not_root(context: Context) -> None:
|
||||
assert not context.decision_result.is_root
|
||||
|
||||
|
||||
@then("the decision should have a valid ULID as decision_id")
|
||||
def step_valid_decision_id(context: Context) -> None:
|
||||
import re
|
||||
|
||||
pattern = r"^[0-9A-HJKMNP-TV-Z]{26}$"
|
||||
assert re.match(pattern, context.decision_result.decision_id), (
|
||||
f"Invalid ULID: {context.decision_result.decision_id}"
|
||||
)
|
||||
|
||||
|
||||
@then('the decision type should be "{dtype}"')
|
||||
def step_decision_type_check(context: Context, dtype: str) -> None:
|
||||
assert context.decision_result.decision_type == DecisionType(dtype)
|
||||
|
||||
|
||||
@then("the decision is_strategize_type should be true")
|
||||
def step_is_strategize(context: Context) -> None:
|
||||
assert context.decision_result.is_strategize_type
|
||||
|
||||
|
||||
@then("the decision is_strategize_type should be false")
|
||||
def step_not_strategize(context: Context) -> None:
|
||||
assert not context.decision_result.is_strategize_type
|
||||
|
||||
|
||||
@then("the decision is_execute_type should be true")
|
||||
def step_is_execute(context: Context) -> None:
|
||||
assert context.decision_result.is_execute_type
|
||||
|
||||
|
||||
@then("the decision is_execute_type should be false")
|
||||
def step_not_execute(context: Context) -> None:
|
||||
assert not context.decision_result.is_execute_type
|
||||
|
||||
|
||||
@then("the decision is_any_phase_type should be true")
|
||||
def step_is_any_phase(context: Context) -> None:
|
||||
assert context.decision_result.is_any_phase_type
|
||||
|
||||
|
||||
@then("the decision confidence score should be {score}")
|
||||
def step_confidence_value(context: Context, score: str) -> None:
|
||||
if score == "None":
|
||||
assert context.decision_result.confidence_score is None
|
||||
else:
|
||||
assert context.decision_result.confidence_score == float(score)
|
||||
|
||||
|
||||
@then("the decision is_correction should be true")
|
||||
def step_is_correction(context: Context) -> None:
|
||||
assert context.decision_result.is_correction
|
||||
|
||||
|
||||
@then("the decision is_superseded should be true")
|
||||
def step_is_superseded(context: Context) -> None:
|
||||
assert context.decision_result.is_superseded
|
||||
|
||||
|
||||
@then("the original decision should not be superseded")
|
||||
def step_original_not_superseded(context: Context) -> None:
|
||||
assert not context.decision_result.is_superseded
|
||||
|
||||
|
||||
@then("the superseded copy should be superseded")
|
||||
def step_copy_is_superseded(context: Context) -> None:
|
||||
assert context.decision_superseded_copy.is_superseded
|
||||
assert context.decision_superseded_copy.decision_id == (
|
||||
context.decision_result.decision_id
|
||||
)
|
||||
|
||||
|
||||
@then("a decision validation error should be raised")
|
||||
def step_validation_error(context: Context) -> None:
|
||||
assert context.decision_error is not None, (
|
||||
"Expected ValidationError but none raised"
|
||||
)
|
||||
assert isinstance(context.decision_error, ValidationError)
|
||||
|
||||
|
||||
@then('the decision error should mention "{text}"')
|
||||
def step_error_mentions(context: Context, text: str) -> None:
|
||||
assert text.lower() in str(context.decision_error).lower(), (
|
||||
f"Expected '{text}' in error: {context.decision_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("the decision context snapshot hash should not be empty")
|
||||
def step_snapshot_hash_not_empty(context: Context) -> None:
|
||||
assert context.decision_result.context_snapshot.hot_context_hash
|
||||
|
||||
|
||||
@then("the decision context snapshot should have resources")
|
||||
def step_snapshot_has_resources(context: Context) -> None:
|
||||
assert len(context.decision_result.context_snapshot.relevant_resources) > 0
|
||||
|
||||
|
||||
@then("the decision context snapshot hash should be empty")
|
||||
def step_snapshot_hash_empty(context: Context) -> None:
|
||||
assert context.decision_result.context_snapshot.hot_context_hash == ""
|
||||
|
||||
|
||||
@then("the decision should have {count:d} artifacts produced")
|
||||
def step_artifact_count(context: Context, count: int) -> None:
|
||||
assert len(context.decision_result.artifacts_produced) == count
|
||||
|
||||
|
||||
@then("the decision should round-trip through model_dump and model_validate")
|
||||
def step_roundtrip(context: Context) -> None:
|
||||
data = context.decision_result.model_dump()
|
||||
restored = Decision.model_validate(data)
|
||||
assert restored.decision_id == context.decision_result.decision_id
|
||||
assert restored.decision_type == context.decision_result.decision_type
|
||||
assert restored.question == context.decision_result.question
|
||||
assert restored.chosen_option == context.decision_result.chosen_option
|
||||
assert restored.confidence_score == context.decision_result.confidence_score
|
||||
assert (
|
||||
restored.context_snapshot.hot_context_hash
|
||||
== context.decision_result.context_snapshot.hot_context_hash
|
||||
)
|
||||
assert len(restored.artifacts_produced) == len(
|
||||
context.decision_result.artifacts_produced
|
||||
)
|
||||
|
||||
|
||||
@then('as_cli_dict should contain key "{key}"')
|
||||
def step_cli_dict_key(context: Context, key: str) -> None:
|
||||
cli = context.decision_result.as_cli_dict()
|
||||
assert key in cli, f"Key '{key}' not in as_cli_dict: {list(cli.keys())}"
|
||||
+18
-18
@@ -2251,24 +2251,24 @@ This section replaces all unfinished work items with the Day 14 plan. Work is or
|
||||
- [X] Git [Jeff]: `git push -u origin feature/m1-plan-execute-runtime`
|
||||
- [X] Forgejo PR [Jeff]: Open PR from `feature/m1-plan-execute-runtime` to `master` with a suitable and thorough description (PR #149)
|
||||
|
||||
- [ ] **COMMIT (Owner: Hamza | Group: M1.resource-handlers | Branch: feature/m1-resource-handlers | Planned: Day 14 | Expected: Day 16) - Commit message: "feat(resource): add handler runtime for git-checkout and fs-directory"**
|
||||
- [ ] Git [Hamza]: `git checkout master`
|
||||
- [ ] Git [Hamza]: `git pull origin master`
|
||||
- [ ] Git [Hamza]: `git checkout -b feature/m1-resource-handlers`
|
||||
- [ ] Code [Hamza]: Define a resource handler protocol and implement `GitCheckoutHandler` + `FsDirectoryHandler` resolution of local paths.
|
||||
- [ ] Code [Hamza]: Integrate sandbox manager to provide per-plan sandbox roots for git worktrees and copy-on-write paths.
|
||||
- [ ] Code [Hamza]: Add resource binding helper to pass resource root into tool inputs consistently.
|
||||
- [ ] Docs [Hamza]: Update `docs/reference/resource_handlers.md` with handler behavior and sandbox outputs.
|
||||
- [ ] Git [Hamza]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [ ] Tests (Behave) [Hamza]: Add scenarios for handler resolution and sandbox root creation.
|
||||
- [ ] Tests (Robot) [Hamza]: Add Robot test covering resource handler + sandbox integration via CLI.
|
||||
- [ ] Tests (ASV) [Hamza]: Add `benchmarks/resource_handler_bench.py` for handler resolution overhead.
|
||||
- [ ] Quality [Hamza]: 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%.
|
||||
- [ ] Quality [Hamza]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
|
||||
- [ ] Git [Hamza]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [ ] Git [Hamza]: `git commit -m "feat(resource): add handler runtime for git-checkout and fs-directory"`
|
||||
- [ ] Git [Hamza]: `git push -u origin feature/m1-resource-handlers`
|
||||
- [ ] Forgejo PR [Hamza]: Open PR from `feature/m1-resource-handlers` to `master` with a suitable and thorough description
|
||||
- [x] **COMMIT (Owner: Hamza | Group: M1.resource-handlers | Branch: feature/m1-resource-handlers | Planned: Day 14 | Expected: Day 16) - Commit message: "feat(resource): add handler runtime for git-checkout and fs-directory"**
|
||||
- [x] Git [Hamza]: `git checkout master`
|
||||
- [x] Git [Hamza]: `git pull origin master`
|
||||
- [x] Git [Hamza]: `git checkout -b feature/m1-resource-handlers`
|
||||
- [x] Code [Hamza]: Define a resource handler protocol and implement `GitCheckoutHandler` + `FsDirectoryHandler` resolution of local paths.
|
||||
- [x] Code [Hamza]: Integrate sandbox manager to provide per-plan sandbox roots for git worktrees and copy-on-write paths.
|
||||
- [x] Code [Hamza]: Add resource binding helper to pass resource root into tool inputs consistently.
|
||||
- [x] Docs [Hamza]: Update `docs/reference/resource_handlers.md` with handler behavior and sandbox outputs.
|
||||
- [x] Git [Hamza]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [x] Tests (Behave) [Hamza]: Add scenarios for handler resolution and sandbox root creation.
|
||||
- [x] Tests (Robot) [Hamza]: Add Robot test covering resource handler + sandbox integration via CLI.
|
||||
- [x] Tests (ASV) [Hamza]: Add `benchmarks/resource_handler_bench.py` for handler resolution overhead.
|
||||
- [x] Quality [Hamza]: 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 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 [Hamza]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
|
||||
- [x] Git [Hamza]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [x] Git [Hamza]: `git commit -m "feat(resource): add handler runtime for git-checkout and fs-directory"`
|
||||
- [x] Git [Hamza]: `git push -u origin feature/m1-resource-handlers`
|
||||
- [x] Forgejo PR [Hamza]: Open PR from `feature/m1-resource-handlers` to `master` with a suitable and thorough description
|
||||
|
||||
- [ ] **COMMIT (Owner: Luis | Group: M1.apply-pipeline | Branch: feature/m1-apply-pipeline | Planned: Day 15 | Expected: Day 17) - Commit message: "feat(apply): merge sandbox changes into targets with conflict handling"**
|
||||
- [ ] Git [Luis]: `git checkout master`
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for decision domain model persistence contract
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER_SCRIPT} robot/helper_decision_model.py
|
||||
|
||||
*** Test Cases ***
|
||||
Create Root Decision
|
||||
[Documentation] Create a prompt_definition root decision
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} create_root cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} decision-create-root-ok
|
||||
|
||||
Create Child Decision
|
||||
[Documentation] Create a strategy_choice child decision
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} create_child cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} decision-create-child-ok
|
||||
|
||||
Decision Type Enum Values
|
||||
[Documentation] Verify all 11 decision type enum values
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} enum_values cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} decision-enums-ok
|
||||
|
||||
Context Snapshot
|
||||
[Documentation] Create a decision with a populated context snapshot
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} context_snapshot cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} decision-snapshot-ok
|
||||
|
||||
Correction Metadata
|
||||
[Documentation] Create a correction decision and verify metadata
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} correction cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} decision-correction-ok
|
||||
|
||||
Round Trip Serialization
|
||||
[Documentation] Verify model_dump / model_validate round-trip
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} roundtrip cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} decision-roundtrip-ok
|
||||
|
||||
CLI Dict Output
|
||||
[Documentation] Verify as_cli_dict produces expected keys
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} cli_dict cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} decision-cli-dict-ok
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Helper script for Robot Framework decision model smoke tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure src is importable when run from workspace root
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from ulid import ULID
|
||||
|
||||
from cleveragents.domain.models.core.decision import (
|
||||
ArtifactRef,
|
||||
ContextSnapshot,
|
||||
Decision,
|
||||
DecisionType,
|
||||
ResourceRef,
|
||||
)
|
||||
|
||||
|
||||
def _test_create_root() -> None:
|
||||
"""Create a root prompt_definition decision."""
|
||||
d = Decision(
|
||||
plan_id=str(ULID()),
|
||||
sequence_number=0,
|
||||
decision_type=DecisionType.PROMPT_DEFINITION,
|
||||
question="What should we build?",
|
||||
chosen_option="A REST API for user management",
|
||||
)
|
||||
assert d.is_root
|
||||
assert d.decision_type == DecisionType.PROMPT_DEFINITION
|
||||
assert d.parent_decision_id is None
|
||||
print("decision-create-root-ok")
|
||||
|
||||
|
||||
def _test_create_child() -> None:
|
||||
"""Create a strategy_choice child decision."""
|
||||
parent_id = str(ULID())
|
||||
d = Decision(
|
||||
plan_id=str(ULID()),
|
||||
parent_decision_id=parent_id,
|
||||
sequence_number=1,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
question="Which framework?",
|
||||
chosen_option="FastAPI",
|
||||
alternatives_considered=["Flask", "Django"],
|
||||
confidence_score=0.9,
|
||||
)
|
||||
assert not d.is_root
|
||||
assert d.parent_decision_id == parent_id
|
||||
assert d.confidence_score == 0.9
|
||||
assert len(d.alternatives_considered) == 2
|
||||
print("decision-create-child-ok")
|
||||
|
||||
|
||||
def _test_enum_values() -> None:
|
||||
"""Verify all 11 decision type enum values."""
|
||||
expected = {
|
||||
"prompt_definition",
|
||||
"invariant_enforced",
|
||||
"strategy_choice",
|
||||
"implementation_choice",
|
||||
"resource_selection",
|
||||
"subplan_spawn",
|
||||
"subplan_parallel_spawn",
|
||||
"tool_invocation",
|
||||
"error_recovery",
|
||||
"validation_response",
|
||||
"user_intervention",
|
||||
}
|
||||
actual = {dt.value for dt in DecisionType}
|
||||
assert actual == expected, f"Mismatch: {actual.symmetric_difference(expected)}"
|
||||
assert len(DecisionType) == 11
|
||||
print("decision-enums-ok")
|
||||
|
||||
|
||||
def _test_context_snapshot() -> None:
|
||||
"""Create a decision with a populated context snapshot."""
|
||||
snap = ContextSnapshot(
|
||||
hot_context_hash="sha256:abc123def",
|
||||
hot_context_ref="store://snapshots/abc123def",
|
||||
relevant_resources=[
|
||||
ResourceRef(resource_id=str(ULID()), path="src/main.py"),
|
||||
ResourceRef(resource_id=str(ULID())),
|
||||
],
|
||||
actor_state_ref="checkpoint://actor/001",
|
||||
)
|
||||
d = Decision(
|
||||
plan_id=str(ULID()),
|
||||
sequence_number=0,
|
||||
decision_type=DecisionType.PROMPT_DEFINITION,
|
||||
question="What approach?",
|
||||
chosen_option="Microservices",
|
||||
context_snapshot=snap,
|
||||
)
|
||||
assert d.context_snapshot.hot_context_hash == "sha256:abc123def"
|
||||
assert len(d.context_snapshot.relevant_resources) == 2
|
||||
assert d.context_snapshot.actor_state_ref == "checkpoint://actor/001"
|
||||
print("decision-snapshot-ok")
|
||||
|
||||
|
||||
def _test_correction() -> None:
|
||||
"""Create a correction decision and verify metadata."""
|
||||
original_id = str(ULID())
|
||||
d = Decision(
|
||||
plan_id=str(ULID()),
|
||||
parent_decision_id=str(ULID()),
|
||||
sequence_number=3,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
question="Which framework?",
|
||||
chosen_option="Django instead",
|
||||
is_correction=True,
|
||||
corrects_decision_id=original_id,
|
||||
correction_reason="FastAPI lacks admin panel",
|
||||
)
|
||||
assert d.is_correction
|
||||
assert d.corrects_decision_id == original_id
|
||||
assert d.correction_reason == "FastAPI lacks admin panel"
|
||||
assert not d.is_superseded
|
||||
print("decision-correction-ok")
|
||||
|
||||
|
||||
def _test_roundtrip() -> None:
|
||||
"""Verify model_dump / model_validate round-trip."""
|
||||
d = Decision(
|
||||
plan_id=str(ULID()),
|
||||
parent_decision_id=str(ULID()),
|
||||
sequence_number=5,
|
||||
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
|
||||
question="How to implement auth?",
|
||||
chosen_option="JWT tokens",
|
||||
confidence_score=0.85,
|
||||
context_snapshot=ContextSnapshot(
|
||||
hot_context_hash="sha256:roundtrip",
|
||||
relevant_resources=[ResourceRef(resource_id=str(ULID()))],
|
||||
),
|
||||
artifacts_produced=[
|
||||
ArtifactRef(artifact_path="src/auth.py", artifact_type="file"),
|
||||
],
|
||||
)
|
||||
data = d.model_dump()
|
||||
restored = Decision.model_validate(data)
|
||||
assert restored.decision_id == d.decision_id
|
||||
assert restored.decision_type == d.decision_type
|
||||
assert restored.confidence_score == d.confidence_score
|
||||
assert restored.context_snapshot.hot_context_hash == "sha256:roundtrip"
|
||||
assert len(restored.artifacts_produced) == 1
|
||||
print("decision-roundtrip-ok")
|
||||
|
||||
|
||||
def _test_cli_dict() -> None:
|
||||
"""Verify as_cli_dict produces expected keys."""
|
||||
d = Decision(
|
||||
plan_id=str(ULID()),
|
||||
sequence_number=0,
|
||||
decision_type=DecisionType.PROMPT_DEFINITION,
|
||||
question="Build what?",
|
||||
chosen_option="API",
|
||||
)
|
||||
cli = d.as_cli_dict()
|
||||
required_keys = {
|
||||
"decision_id",
|
||||
"plan_id",
|
||||
"type",
|
||||
"sequence",
|
||||
"question",
|
||||
"chosen",
|
||||
"confidence",
|
||||
"parent",
|
||||
"is_correction",
|
||||
"superseded",
|
||||
}
|
||||
assert required_keys.issubset(cli.keys()), (
|
||||
f"Missing keys: {required_keys - cli.keys()}"
|
||||
)
|
||||
print("decision-cli-dict-ok")
|
||||
|
||||
|
||||
_TESTS = {
|
||||
"create_root": _test_create_root,
|
||||
"create_child": _test_create_child,
|
||||
"enum_values": _test_enum_values,
|
||||
"context_snapshot": _test_context_snapshot,
|
||||
"correction": _test_correction,
|
||||
"roundtrip": _test_roundtrip,
|
||||
"cli_dict": _test_cli_dict,
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <test_name>")
|
||||
print(f"Available tests: {', '.join(sorted(_TESTS))}")
|
||||
sys.exit(1)
|
||||
|
||||
test_name = sys.argv[1]
|
||||
if test_name not in _TESTS:
|
||||
print(f"Unknown test: {test_name}")
|
||||
print(f"Available: {', '.join(sorted(_TESTS))}")
|
||||
sys.exit(1)
|
||||
|
||||
_TESTS[test_name]()
|
||||
@@ -0,0 +1,490 @@
|
||||
"""Decision domain models for the decision tree subsystem.
|
||||
|
||||
A :class:`Decision` represents a persisted choice point in a plan's
|
||||
decision tree, created during the Strategize or Execute phases. Each
|
||||
decision records the question being answered, the chosen option,
|
||||
alternatives considered, a confidence score, rationale, a
|
||||
:class:`ContextSnapshot` for replay, and downstream dependency links.
|
||||
|
||||
Decision types
|
||||
--------------
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - Type
|
||||
- Phase
|
||||
- Description
|
||||
* - ``prompt_definition``
|
||||
- Strategize
|
||||
- Root decision — the plan prompt
|
||||
* - ``invariant_enforced``
|
||||
- Strategize
|
||||
- An invariant constraint was applied
|
||||
* - ``strategy_choice``
|
||||
- Strategize
|
||||
- High-level approach chosen
|
||||
* - ``implementation_choice``
|
||||
- Execute
|
||||
- How to implement a specific task
|
||||
* - ``resource_selection``
|
||||
- Execute
|
||||
- Which resources to read / modify
|
||||
* - ``subplan_spawn``
|
||||
- Strategize
|
||||
- Decision to create a child plan
|
||||
* - ``subplan_parallel_spawn``
|
||||
- Strategize
|
||||
- Spawn a group of child plans in parallel
|
||||
* - ``tool_invocation``
|
||||
- Execute
|
||||
- Which skill / tool to use
|
||||
* - ``error_recovery``
|
||||
- Execute
|
||||
- How to handle a failure
|
||||
* - ``validation_response``
|
||||
- Execute
|
||||
- Response to a validation failure
|
||||
* - ``user_intervention``
|
||||
- Any
|
||||
- User-provided guidance / correction
|
||||
|
||||
Context snapshots
|
||||
-----------------
|
||||
|
||||
Every decision captures a :class:`ContextSnapshot` sufficient to
|
||||
replay the decision from the same informational starting point. The
|
||||
snapshot includes a hash of the hot context window, a storage
|
||||
reference for the full context, a list of :class:`ResourceRef`
|
||||
entries, and the LangGraph actor checkpoint reference.
|
||||
|
||||
Based on:
|
||||
- docs/specification.md L18390-L18521
|
||||
- docs/adr/ADR-007-decision-tree-and-correction.md
|
||||
- docs/adr/ADR-033-decision-recording-protocol.md
|
||||
- docs/adr/ADR-034-decision-tree-versioning-and-history.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from ulid import ULID
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"
|
||||
_ULID_RE = re.compile(ULID_PATTERN)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DecisionType(StrEnum):
|
||||
"""Classification of a decision node in the plan decision tree.
|
||||
|
||||
Each type is associated with one or more plan phases and constrains
|
||||
what downstream decisions are permitted.
|
||||
"""
|
||||
|
||||
PROMPT_DEFINITION = "prompt_definition"
|
||||
INVARIANT_ENFORCED = "invariant_enforced"
|
||||
STRATEGY_CHOICE = "strategy_choice"
|
||||
IMPLEMENTATION_CHOICE = "implementation_choice"
|
||||
RESOURCE_SELECTION = "resource_selection"
|
||||
SUBPLAN_SPAWN = "subplan_spawn"
|
||||
SUBPLAN_PARALLEL_SPAWN = "subplan_parallel_spawn"
|
||||
TOOL_INVOCATION = "tool_invocation"
|
||||
ERROR_RECOVERY = "error_recovery"
|
||||
VALIDATION_RESPONSE = "validation_response"
|
||||
USER_INTERVENTION = "user_intervention"
|
||||
|
||||
|
||||
#: Decision types that may only be created during the Strategize phase.
|
||||
STRATEGIZE_TYPES: frozenset[DecisionType] = frozenset(
|
||||
{
|
||||
DecisionType.PROMPT_DEFINITION,
|
||||
DecisionType.INVARIANT_ENFORCED,
|
||||
DecisionType.STRATEGY_CHOICE,
|
||||
DecisionType.SUBPLAN_SPAWN,
|
||||
DecisionType.SUBPLAN_PARALLEL_SPAWN,
|
||||
}
|
||||
)
|
||||
|
||||
#: Decision types that may only be created during the Execute phase.
|
||||
EXECUTE_TYPES: frozenset[DecisionType] = frozenset(
|
||||
{
|
||||
DecisionType.IMPLEMENTATION_CHOICE,
|
||||
DecisionType.RESOURCE_SELECTION,
|
||||
DecisionType.TOOL_INVOCATION,
|
||||
DecisionType.ERROR_RECOVERY,
|
||||
DecisionType.VALIDATION_RESPONSE,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Embedded value objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ResourceRef(BaseModel):
|
||||
"""Reference to a resource that influenced a decision.
|
||||
|
||||
Captures the resource identifier and an optional path / symbol
|
||||
within that resource for fine-grained provenance tracking.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True, str_strip_whitespace=True)
|
||||
|
||||
resource_id: str = Field(
|
||||
...,
|
||||
description="ULID of the referenced resource.",
|
||||
)
|
||||
path: str = Field(
|
||||
default="",
|
||||
description="Optional sub-path or symbol within the resource.",
|
||||
)
|
||||
|
||||
@field_validator("resource_id")
|
||||
@classmethod
|
||||
def _resource_id_not_empty(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError("resource_id must not be empty")
|
||||
return v
|
||||
|
||||
|
||||
class ArtifactRef(BaseModel):
|
||||
"""Reference to an artifact produced by a decision.
|
||||
|
||||
Artifacts are files, patches, or outputs created as a side-effect
|
||||
of executing a decision.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True, str_strip_whitespace=True)
|
||||
|
||||
artifact_path: str = Field(
|
||||
...,
|
||||
description="Path to the artifact (relative to workspace root).",
|
||||
)
|
||||
artifact_type: str = Field(
|
||||
default="file",
|
||||
description="Kind of artifact: file, patch, log, etc.",
|
||||
)
|
||||
|
||||
@field_validator("artifact_path")
|
||||
@classmethod
|
||||
def _artifact_path_not_empty(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError("artifact_path must not be empty")
|
||||
return v
|
||||
|
||||
|
||||
class ContextSnapshot(BaseModel):
|
||||
"""Snapshot of the informational context at decision time.
|
||||
|
||||
Captures enough state to replay the decision from the same
|
||||
starting point. The ``hot_context_hash`` is a cryptographic hash
|
||||
of the exact context window; ``hot_context_ref`` is a storage
|
||||
pointer to the full serialised context.
|
||||
|
||||
See ADR-033 §Context Snapshot Auto-Capture for the recording
|
||||
protocol.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True, str_strip_whitespace=True)
|
||||
|
||||
hot_context_hash: str = Field(
|
||||
default="",
|
||||
description="Cryptographic hash of the hot context window.",
|
||||
)
|
||||
hot_context_ref: str = Field(
|
||||
default="",
|
||||
description="Storage pointer to the full context snapshot.",
|
||||
)
|
||||
relevant_resources: list[ResourceRef] = Field(
|
||||
default_factory=list,
|
||||
description="Resources that influenced this decision.",
|
||||
)
|
||||
actor_state_ref: str = Field(
|
||||
default="",
|
||||
description="LangGraph actor checkpoint reference.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decision model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Decision(BaseModel):
|
||||
"""A persisted choice point in a plan's decision tree.
|
||||
|
||||
Decisions form a tree (via ``parent_decision_id``) and an influence
|
||||
DAG (via ``downstream_decision_ids``). The ``prompt_definition``
|
||||
type is always the root of the tree and must have no parent.
|
||||
|
||||
Immutable once created — corrections create *new* decisions and
|
||||
set ``superseded_by`` on the original.
|
||||
|
||||
.. note::
|
||||
|
||||
Because the model is frozen, ``superseded_by`` cannot be set
|
||||
via attribute assignment. Use :meth:`with_superseded_by` to
|
||||
obtain a copy with the field populated. The persistence layer
|
||||
is responsible for writing the updated copy back to storage.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True,
|
||||
str_strip_whitespace=True,
|
||||
use_enum_values=False,
|
||||
)
|
||||
|
||||
# --- Identity ---
|
||||
|
||||
decision_id: str = Field(
|
||||
default_factory=lambda: str(ULID()),
|
||||
description="Unique decision identifier (ULID).",
|
||||
pattern=ULID_PATTERN,
|
||||
)
|
||||
plan_id: str = Field(
|
||||
...,
|
||||
description="ULID of the parent plan.",
|
||||
pattern=ULID_PATTERN,
|
||||
)
|
||||
parent_decision_id: str | None = Field(
|
||||
default=None,
|
||||
description="ULID of the parent decision (None for root).",
|
||||
)
|
||||
sequence_number: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
description=(
|
||||
"Monotonic order within the plan's decisions. "
|
||||
"Uniqueness within a plan is enforced at the persistence "
|
||||
"layer, not the domain model."
|
||||
),
|
||||
)
|
||||
|
||||
# --- Classification ---
|
||||
|
||||
decision_type: DecisionType = Field(
|
||||
...,
|
||||
description="The kind of decision being recorded.",
|
||||
)
|
||||
|
||||
# --- The decision itself ---
|
||||
|
||||
question: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="What question was being answered.",
|
||||
)
|
||||
chosen_option: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="The option that was chosen.",
|
||||
)
|
||||
alternatives_considered: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Other options that were evaluated.",
|
||||
)
|
||||
confidence_score: float | None = Field(
|
||||
default=None,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Confidence in the decision (0.0-1.0).",
|
||||
)
|
||||
|
||||
# --- Context snapshot ---
|
||||
|
||||
context_snapshot: ContextSnapshot = Field(
|
||||
default_factory=ContextSnapshot,
|
||||
description="Informational context at decision time.",
|
||||
)
|
||||
|
||||
# --- Rationale ---
|
||||
|
||||
rationale: str = Field(
|
||||
default="",
|
||||
description="Human-readable rationale for the decision.",
|
||||
)
|
||||
actor_reasoning: str | None = Field(
|
||||
default=None,
|
||||
description="Raw LLM reasoning trace, if available.",
|
||||
)
|
||||
|
||||
# --- Downstream impact ---
|
||||
|
||||
downstream_decision_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="ULIDs of decisions that depend on this one.",
|
||||
)
|
||||
downstream_plan_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="ULIDs of child plans spawned from this decision.",
|
||||
)
|
||||
artifacts_produced: list[ArtifactRef] = Field(
|
||||
default_factory=list,
|
||||
description="Artifacts created as side-effects.",
|
||||
)
|
||||
|
||||
# --- Timestamps ---
|
||||
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
description="When the decision was recorded.",
|
||||
)
|
||||
|
||||
# --- Correction metadata ---
|
||||
|
||||
is_correction: bool = Field(
|
||||
default=False,
|
||||
description="Whether this decision is a correction of another.",
|
||||
)
|
||||
corrects_decision_id: str | None = Field(
|
||||
default=None,
|
||||
description="ULID of the decision being corrected.",
|
||||
)
|
||||
correction_reason: str | None = Field(
|
||||
default=None,
|
||||
description="Why the correction was made.",
|
||||
)
|
||||
superseded_by: str | None = Field(
|
||||
default=None,
|
||||
description="ULID of the decision that supersedes this one.",
|
||||
)
|
||||
|
||||
# --- Validators ---
|
||||
|
||||
@field_validator("plan_id")
|
||||
@classmethod
|
||||
def _plan_id_valid(cls, v: str) -> str:
|
||||
if not _ULID_RE.match(v):
|
||||
raise ValueError(f"plan_id must be a valid ULID, got '{v}'")
|
||||
return v
|
||||
|
||||
@field_validator("parent_decision_id")
|
||||
@classmethod
|
||||
def _parent_decision_id_valid(cls, v: str | None) -> str | None:
|
||||
if v is not None and not _ULID_RE.match(v):
|
||||
raise ValueError(
|
||||
f"parent_decision_id must be a valid ULID or None, got '{v}'"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("corrects_decision_id")
|
||||
@classmethod
|
||||
def _corrects_decision_id_valid(cls, v: str | None) -> str | None:
|
||||
if v is not None and not _ULID_RE.match(v):
|
||||
raise ValueError(
|
||||
f"corrects_decision_id must be a valid ULID or None, got '{v}'"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("superseded_by")
|
||||
@classmethod
|
||||
def _superseded_by_valid(cls, v: str | None) -> str | None:
|
||||
if v is not None and not _ULID_RE.match(v):
|
||||
raise ValueError(f"superseded_by must be a valid ULID or None, got '{v}'")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _root_decision_constraints(self) -> Decision:
|
||||
"""Ensure prompt_definition decisions have no parent."""
|
||||
if (
|
||||
self.decision_type == DecisionType.PROMPT_DEFINITION
|
||||
and self.parent_decision_id is not None
|
||||
):
|
||||
raise ValueError(
|
||||
"prompt_definition decisions must be the tree root "
|
||||
"(parent_decision_id must be None)"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _correction_fields_consistent(self) -> Decision:
|
||||
"""Ensure correction metadata is internally consistent."""
|
||||
if self.is_correction and not self.corrects_decision_id:
|
||||
raise ValueError(
|
||||
"is_correction is True but corrects_decision_id is not set"
|
||||
)
|
||||
if self.corrects_decision_id and not self.is_correction:
|
||||
raise ValueError("corrects_decision_id is set but is_correction is False")
|
||||
return self
|
||||
|
||||
# --- Domain helpers ---
|
||||
|
||||
@property
|
||||
def is_root(self) -> bool:
|
||||
"""Return True if this is the root of the decision tree."""
|
||||
return self.parent_decision_id is None
|
||||
|
||||
@property
|
||||
def is_superseded(self) -> bool:
|
||||
"""Return True if this decision has been superseded by a correction."""
|
||||
return self.superseded_by is not None
|
||||
|
||||
@property
|
||||
def is_strategize_type(self) -> bool:
|
||||
"""Return True if this is a Strategize-phase decision type."""
|
||||
return self.decision_type in STRATEGIZE_TYPES
|
||||
|
||||
@property
|
||||
def is_execute_type(self) -> bool:
|
||||
"""Return True if this is an Execute-phase decision type."""
|
||||
return self.decision_type in EXECUTE_TYPES
|
||||
|
||||
@property
|
||||
def is_any_phase_type(self) -> bool:
|
||||
"""Return True if this decision type is valid in any phase."""
|
||||
return self.decision_type == DecisionType.USER_INTERVENTION
|
||||
|
||||
def as_cli_dict(self) -> dict[str, object]:
|
||||
"""Return a stable dict for CLI table rendering."""
|
||||
return {
|
||||
"decision_id": self.decision_id,
|
||||
"plan_id": self.plan_id,
|
||||
"type": str(self.decision_type),
|
||||
"sequence": self.sequence_number,
|
||||
"question": self.question,
|
||||
"chosen": self.chosen_option,
|
||||
"confidence": self.confidence_score,
|
||||
"parent": self.parent_decision_id or "(root)",
|
||||
"is_correction": self.is_correction,
|
||||
"superseded": self.is_superseded,
|
||||
}
|
||||
|
||||
def with_superseded_by(self, new_decision_id: str) -> Decision:
|
||||
"""Return a copy with ``superseded_by`` set.
|
||||
|
||||
Because the model is frozen, this is the canonical way to mark
|
||||
a decision as superseded during the correction lifecycle.
|
||||
|
||||
Args:
|
||||
new_decision_id: ULID of the decision that supersedes this one.
|
||||
|
||||
Returns:
|
||||
A new :class:`Decision` instance identical to this one
|
||||
except with ``superseded_by`` populated.
|
||||
"""
|
||||
return self.model_copy(update={"superseded_by": new_decision_id})
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EXECUTE_TYPES",
|
||||
"STRATEGIZE_TYPES",
|
||||
"ArtifactRef",
|
||||
"ContextSnapshot",
|
||||
"Decision",
|
||||
"DecisionType",
|
||||
"ResourceRef",
|
||||
]
|
||||
Reference in New Issue
Block a user