feat(change): add ChangeSet models and invocation tracker

This commit is contained in:
CoreRasurae
2026-02-19 11:54:14 +00:00
parent 8892a810ff
commit 8a219286d6
9 changed files with 1593 additions and 31 deletions
+137
View File
@@ -0,0 +1,137 @@
"""ASV benchmarks for ChangeSet models and invocation tracking.
Measures the performance of:
- ChangeEntry creation and validation
- SpecChangeSet sorted_entries and grouped_by_resource
- InMemoryInvocationTracker operations
- Path normalization
"""
from __future__ import annotations
import sys
from datetime import UTC, datetime
from pathlib import Path
try:
from cleveragents.domain.models.core.change import (
ChangeEntry,
ChangeOperation,
InMemoryInvocationTracker,
SpecChangeSet,
ToolInvocation,
normalize_change_path,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.domain.models.core.change import (
ChangeEntry,
ChangeOperation,
InMemoryInvocationTracker,
SpecChangeSet,
ToolInvocation,
normalize_change_path,
)
def _make_entries(n: int) -> list[ChangeEntry]:
"""Create *n* ChangeEntry objects with varied resources."""
entries: list[ChangeEntry] = []
for i in range(n):
minutes, seconds = divmod(i, 60)
entries.append(
ChangeEntry(
plan_id="plan-bench",
resource_id=f"res-{i % 5}",
tool_name="builtin/file-write",
operation=ChangeOperation.CREATE,
path=f"src/file_{i}.py",
after_hash=f"hash-{i}",
timestamp=datetime(2026, 1, 1, 0, minutes, seconds, tzinfo=UTC),
)
)
return entries
class ChangeEntrySuite:
"""Benchmark ChangeEntry creation and validation."""
def time_create_entry(self) -> None:
ChangeEntry(
plan_id="plan-1",
resource_id="res-1",
tool_name="builtin/file-write",
operation=ChangeOperation.CREATE,
path="src/new.py",
after_hash="abc123",
)
def time_create_entry_with_integrity_check(self) -> None:
e = ChangeEntry(
plan_id="plan-1",
resource_id="res-1",
tool_name="builtin/file-edit",
operation=ChangeOperation.MODIFY,
path="src/edit.py",
before_hash="before",
after_hash="after",
)
_ = e.has_integrity_hashes
class SpecChangeSetSuite:
"""Benchmark SpecChangeSet operations."""
def setup(self) -> None:
self.entries = _make_entries(100)
self.changeset = SpecChangeSet(plan_id="plan-bench", entries=self.entries)
def time_sorted_entries(self) -> None:
self.changeset.sorted_entries()
def time_grouped_by_resource(self) -> None:
self.changeset.grouped_by_resource()
def time_summary(self) -> None:
self.changeset.summary()
def time_add_change(self) -> None:
cs = SpecChangeSet(plan_id="plan-bench")
for entry in self.entries[:10]:
cs.add_change(entry)
class InvocationTrackerSuite:
"""Benchmark InMemoryInvocationTracker."""
def setup(self) -> None:
self.tracker = InMemoryInvocationTracker()
for i in range(50):
self.tracker.track(
ToolInvocation(
plan_id=f"plan-{i % 3}",
tool_name=f"tool-{i}",
skill_name=f"skill-{i % 5}",
sequence_number=i,
change_ids=[f"chg-{i}"],
)
)
def time_get_invocations(self) -> None:
self.tracker.get_invocations("plan-0")
def time_get_invocations_for_skill(self) -> None:
self.tracker.get_invocations_for_skill("plan-0", "skill-0")
def time_get_changes(self) -> None:
self.tracker.get_changes("plan-0")
class PathNormalizationSuite:
"""Benchmark path normalization."""
def time_normalize_absolute(self) -> None:
normalize_change_path("/home/user/project/src/main.py", "/home/user/project")
def time_normalize_relative(self) -> None:
normalize_change_path("src/main.py", "")
+143
View File
@@ -0,0 +1,143 @@
# Change Tracking (C5.model)
This document describes the ChangeSet domain models, ToolInvocation
tracking, and the SkillInvocationTracker used for auditability of
tool executions during the Execute phase.
## Overview
CleverAgents uses a **tool-based change model**: LLMs call tools
directly, tools modify sandbox state, and a `ChangeSet` is built from
those tool invocations. Every resource modification is explicit and
tracked.
## Models
### ChangeOperation (enum)
| Value | Description |
|----------|---------------------------|
| `create` | New file created |
| `modify` | Existing file changed |
| `delete` | File removed |
| `rename` | File moved or renamed |
`ChangeType` is an alias for `ChangeOperation`.
### ChangeEntry
A single recorded change from a tool execution.
| Field | Type | Required | Description |
|---------------|--------------------|----------|-----------------------------------|
| `entry_id` | `str` (ULID) | auto | Unique identifier |
| `plan_id` | `str` (ULID) | yes | Owning plan |
| `resource_id` | `str` (ULID) | yes | Affected resource |
| `tool_name` | `str` | yes | Namespaced tool name |
| `operation` | `ChangeOperation` | yes | Type of change |
| `path` | `str` | yes | Repo-relative file path |
| `before_hash` | `str \| None` | no | SHA-256 before change |
| `after_hash` | `str \| None` | no | SHA-256 after change |
| `before_mode` | `int \| None` | no | File mode before change |
| `after_mode` | `int \| None` | no | File mode after change |
| `timestamp` | `datetime` | auto | UTC timestamp |
**Validation rules:**
- CREATE must not have `before_hash` (no prior content).
- DELETE must not have `after_hash` (no resulting content).
**Integrity check:** `has_integrity_hashes` property returns `True` when
the operation-appropriate hashes are all present.
### SpecChangeSet
Accumulated set of ChangeEntry records for a plan.
| Field | Type | Description |
|----------------|---------------------|-------------------------------|
| `changeset_id` | `str` (ULID) | Unique identifier |
| `plan_id` | `str` (ULID) | Owning plan |
| `entries` | `list[ChangeEntry]` | Ordered change entries |
| `created_at` | `datetime` | UTC creation timestamp |
**Methods:**
- `summary()` -- flat counts (total, creates, modifies, deletes, renames)
- `sorted_entries()` -- deterministic order: `(resource_id, path, timestamp)`
- `grouped_by_resource()` -- dict grouped by resource_id, sorted within
- `add_change(entry)` -- append a change entry
### ToolInvocation
Record of a single tool execution for auditability.
| Field | Type | Description |
|--------------------|-------------------------|------------------------------------|
| `invocation_id` | `str` (ULID) | Unique identifier |
| `plan_id` | `str` (ULID) | Plan that triggered the call |
| `tool_name` | `str` | Namespaced tool name |
| `skill_name` | `str \| None` | Skill that provided the tool |
| `arguments` | `dict` | Input parameters |
| `result` | `dict \| None` | Output payload |
| `error` | `str \| None` | Error message on failure |
| `success` | `bool` | Whether execution succeeded |
| `duration_ms` | `float` | Wall-clock time in milliseconds |
| `started_at` | `datetime` | UTC start timestamp |
| `completed_at` | `datetime \| None` | UTC end timestamp |
| `change_ids` | `list[str]` | ChangeEntry IDs produced |
| `sequence_number` | `int` | Ordering within a plan |
| `sandbox_path` | `str \| None` | Sandbox root used |
| `resource_refs` | `list[str]` | Resource IDs involved |
| `provider_metadata`| `dict \| None` | Provider info (model, latency) |
### InvocationTracker (Protocol)
| Method | Returns | Description |
|---------------------------------|------------------------|---------------------------------|
| `track(invocation)` | `None` | Record a tool invocation |
| `get_invocations(plan_id)` | `list[ToolInvocation]` | All invocations for a plan |
| `get_invocations_for_skill(...)`| `list[ToolInvocation]` | Filter by skill within a plan |
| `get_changes(plan_id)` | `list[str]` | All change IDs for a plan |
### InMemoryInvocationTracker
In-memory implementation of the `InvocationTracker` protocol.
Returns invocations sorted by `sequence_number`.
## Path Normalization
`normalize_change_path(path, repo_root)` converts absolute paths to
repo-relative POSIX paths by stripping the repo root prefix.
## Usage
```python
from cleveragents.domain.models.core.change import (
ChangeEntry, ChangeOperation, SpecChangeSet,
ToolInvocation, InMemoryInvocationTracker,
normalize_change_path,
)
# Create and populate a changeset
cs = SpecChangeSet(plan_id="plan-123")
cs.add_change(ChangeEntry(
plan_id="plan-123",
resource_id="res-1",
tool_name="builtin/file-write",
operation=ChangeOperation.CREATE,
path=normalize_change_path("/sandbox/src/new.py", "/sandbox"),
after_hash="abc123",
))
# Track a tool invocation
tracker = InMemoryInvocationTracker()
tracker.track(ToolInvocation(
plan_id="plan-123",
tool_name="builtin/file-write",
skill_name="local/file-ops",
change_ids=[cs.entries[0].entry_id],
))
# Get grouped view for plan diff
for resource_id, entries in cs.grouped_by_resource().items():
print(f"Resource {resource_id}: {len(entries)} changes")
```
+180
View File
@@ -0,0 +1,180 @@
Feature: ChangeSet Models and Invocation Tracking
As a developer
I want ChangeSet models with deterministic ordering and invocation tracking
So that all tool executions and their changes are auditable
# ---- ChangeType Alias ----
Scenario: ChangeType is an alias for ChangeOperation
Given I import ChangeType and ChangeOperation
Then ChangeType should be the same class as ChangeOperation
And ChangeType CREATE should equal "create"
# ---- ChangeEntry Validators ----
Scenario: CREATE entry rejects before_hash
When I create a ChangeEntry with CREATE and before_hash "abc"
Then a validation error should be raised mentioning "before_hash"
Scenario: DELETE entry rejects after_hash
When I create a ChangeEntry with DELETE and after_hash "abc"
Then a validation error should be raised mentioning "after_hash"
Scenario: CREATE entry accepts after_hash only
When I create a valid CREATE ChangeEntry with after_hash "abc123"
Then the change entry operation should be "create"
And the change entry after_hash should be "abc123"
Scenario: MODIFY entry accepts both hashes
When I create a valid MODIFY ChangeEntry with both hashes
Then the change entry operation should be "modify"
And the change entry has_integrity_hashes should be true
Scenario: RENAME entry without hashes is valid
When I create a RENAME ChangeEntry without hashes
Then the change entry operation should be "rename"
And the change entry has_integrity_hashes should be true
Scenario: CREATE entry without after_hash has no integrity
When I create a CREATE ChangeEntry without any hashes
Then the change entry has_integrity_hashes should be false
# ---- Path Normalization ----
Scenario: Normalize absolute path with repo root
Given a repo root "/home/user/project"
When I normalize the path "/home/user/project/src/main.py"
Then the normalized path should be "src/main.py"
Scenario: Normalize relative path stays unchanged
Given an empty repo root
When I normalize the path "src/main.py"
Then the normalized path should be "src/main.py"
Scenario: Normalize path outside repo root is unchanged
Given a repo root "/other/project"
When I normalize the path "/home/user/project/src/main.py"
Then the normalized path should be "home/user/project/src/main.py"
# ---- SpecChangeSet Ordering ----
Scenario: sorted_entries returns deterministic order
Given a SpecChangeSet with entries in random order
When I call sorted_entries
Then entries should be sorted by resource_id then path then timestamp
Scenario: grouped_by_resource groups correctly
Given a SpecChangeSet with entries for multiple resources
When I call grouped_by_resource
Then entries should be grouped by resource_id
And each group should be sorted by path and timestamp
Scenario: add_change appends to entries
Given an empty SpecChangeSet
When I add a change entry to it
Then the spec changeset should contain 1 entry
# ---- ToolInvocation Model ----
Scenario: Create a ToolInvocation with minimal fields
When I create a ToolInvocation for plan "plan-1" and tool "builtin/file-write"
Then the invocation should have a ULID invocation_id
And the invocation plan_id should be "plan-1"
And the invocation tool_name should be "builtin/file-write"
And the invocation success should be true
Scenario: Create a failed ToolInvocation
When I create a failed ToolInvocation with error "file not found"
Then the invocation success should be false
And the invocation error should be "file not found"
Scenario: ToolInvocation with change_ids
When I create a ToolInvocation with change_ids
Then the invocation should have 2 change_ids
Scenario: ToolInvocation with provider metadata
When I create a ToolInvocation with provider metadata
Then the invocation provider_metadata should contain model name
# ---- InMemoryInvocationTracker ----
Scenario: Track invocations and retrieve by plan
Given an InMemoryInvocationTracker
When I track 3 invocations for plan "plan-A"
And I track 2 invocations for plan "plan-B"
Then get_invocations for "plan-A" should return 3
And get_invocations for "plan-B" should return 2
Scenario: Retrieve invocations for a specific skill
Given an InMemoryInvocationTracker
When I track invocations for different skills
Then get_invocations_for_skill should filter correctly
Scenario: Get change IDs across invocations
Given an InMemoryInvocationTracker
When I track invocations with change_ids
Then get_changes should return all change_ids for the plan
Scenario: Invocations are ordered by sequence number
Given an InMemoryInvocationTracker
When I track invocations out of order
Then get_invocations should return them in sequence order
# ---- SpecChangeSet summary ----
Scenario: summary returns correct counts for mixed operations
Given a SpecChangeSet with mixed operation entries
When I call summary on the changeset
Then the summary total should be 4
And the summary creates should be 1
And the summary modifies should be 1
And the summary deletes should be 1
And the summary renames should be 1
And the summary paths_changed should be 4
And the summary resources_involved should be 2
# ---- add_change plan_id validation ----
Scenario: add_change rejects entry with mismatched plan_id
Given an empty SpecChangeSet
When I add a change entry with a different plan_id
Then a validation error should be raised mentioning "plan_id"
# ---- Empty tracker queries ----
Scenario: get_invocations returns empty for unknown plan
Given an InMemoryInvocationTracker
Then get_invocations for "nonexistent-plan" should return 0
Scenario: get_changes returns empty for unknown plan
Given an InMemoryInvocationTracker
Then get_changes for "nonexistent-plan" should return 0 ids
Scenario: get_invocations_for_skill returns empty for unknown skill
Given an InMemoryInvocationTracker
When I track 1 invocations for plan "plan-X"
Then get_invocations_for_skill "plan-X" "no-such-skill" should return 0
# ---- get_changes ordering proof ----
Scenario: get_changes respects sequence order not insertion order
Given an InMemoryInvocationTracker
When I track change invocations in reverse sequence order
Then get_changes should return change_ids in sequence order
# ---- Path normalization edge cases ----
Scenario: Normalize path that equals repo root returns dot
Given a repo root "/home/user/project"
When I normalize the path "/home/user/project"
Then the normalized path should be "."
Scenario: Normalize path with dot-dot components preserves them
Given a repo root "/repo"
When I normalize the path "/repo/foo/../bar/file.py"
Then the normalized path should be "foo/../bar/file.py"
Scenario: Normalize empty path returns dot
Given an empty repo root
When I normalize the path "."
Then the normalized path should be "."
+628
View File
@@ -0,0 +1,628 @@
"""Step definitions for ChangeSet models and invocation tracking."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from behave import given, then, when
from cleveragents.domain.models.core.change import (
ChangeEntry,
ChangeOperation,
ChangeType,
InMemoryInvocationTracker,
SpecChangeSet,
ToolInvocation,
normalize_change_path,
)
__all__: list[str] = []
# ---------------------------------------------------------------------------
# ChangeType alias
# ---------------------------------------------------------------------------
@given("I import ChangeType and ChangeOperation")
def step_import_change_type(context: Any) -> None:
context.change_type = ChangeType
context.change_operation = ChangeOperation
@then("ChangeType should be the same class as ChangeOperation")
def step_changetype_same(context: Any) -> None:
assert context.change_type is context.change_operation
@then('ChangeType CREATE should equal "{value}"')
def step_changetype_create_value(context: Any, value: str) -> None:
assert value == ChangeType.CREATE
# ---------------------------------------------------------------------------
# ChangeEntry validators
# ---------------------------------------------------------------------------
def _base_entry_kwargs() -> dict[str, Any]:
return {
"plan_id": "plan-test",
"resource_id": "res-test",
"tool_name": "builtin/file-write",
"path": "src/test.py",
}
@when('I create a ChangeEntry with CREATE and before_hash "{h}"')
def step_create_entry_create_before(context: Any, h: str) -> None:
try:
ChangeEntry(
**_base_entry_kwargs(),
operation=ChangeOperation.CREATE,
before_hash=h,
after_hash="xyz",
)
context.validation_error = None
except Exception as exc:
context.validation_error = str(exc)
@when('I create a ChangeEntry with DELETE and after_hash "{h}"')
def step_create_entry_delete_after(context: Any, h: str) -> None:
try:
ChangeEntry(
**_base_entry_kwargs(),
operation=ChangeOperation.DELETE,
before_hash="xyz",
after_hash=h,
)
context.validation_error = None
except Exception as exc:
context.validation_error = str(exc)
@then('a validation error should be raised mentioning "{text}"')
def step_validation_error(context: Any, text: str) -> None:
assert context.validation_error is not None, "No error was raised"
assert text in context.validation_error, (
f"Expected '{text}' in: {context.validation_error}"
)
@when('I create a valid CREATE ChangeEntry with after_hash "{h}"')
def step_valid_create_entry(context: Any, h: str) -> None:
context.entry = ChangeEntry(
**_base_entry_kwargs(),
operation=ChangeOperation.CREATE,
after_hash=h,
)
@when("I create a valid MODIFY ChangeEntry with both hashes")
def step_valid_modify_entry(context: Any) -> None:
context.entry = ChangeEntry(
**_base_entry_kwargs(),
operation=ChangeOperation.MODIFY,
before_hash="hash-a",
after_hash="hash-b",
)
@when("I create a RENAME ChangeEntry without hashes")
def step_rename_no_hashes(context: Any) -> None:
context.entry = ChangeEntry(
**_base_entry_kwargs(),
operation=ChangeOperation.RENAME,
)
@when("I create a CREATE ChangeEntry without any hashes")
def step_create_no_hashes(context: Any) -> None:
context.entry = ChangeEntry(
**_base_entry_kwargs(),
operation=ChangeOperation.CREATE,
)
@then('the change entry operation should be "{op}"')
def step_change_entry_operation(context: Any, op: str) -> None:
assert context.entry.operation == op
@then('the change entry after_hash should be "{h}"')
def step_change_entry_after_hash(context: Any, h: str) -> None:
assert context.entry.after_hash == h
@then("the change entry has_integrity_hashes should be true")
def step_change_entry_integrity_true(context: Any) -> None:
assert context.entry.has_integrity_hashes is True
@then("the change entry has_integrity_hashes should be false")
def step_change_entry_integrity_false(context: Any) -> None:
assert context.entry.has_integrity_hashes is False
# ---------------------------------------------------------------------------
# Path normalization
# ---------------------------------------------------------------------------
@given('a repo root "{root}"')
def step_repo_root(context: Any, root: str) -> None:
context.repo_root = root
@given("an empty repo root")
def step_empty_repo_root(context: Any) -> None:
context.repo_root = ""
@when('I normalize the path "{path}"')
def step_normalize_path(context: Any, path: str) -> None:
context.normalized = normalize_change_path(path, context.repo_root)
@then('the normalized path should be "{expected}"')
def step_normalized_result(context: Any, expected: str) -> None:
assert context.normalized == expected, (
f"Expected '{expected}', got '{context.normalized}'"
)
# ---------------------------------------------------------------------------
# SpecChangeSet ordering
# ---------------------------------------------------------------------------
def _make_entry(resource_id: str, path: str, ts_offset: int = 0) -> ChangeEntry:
"""Create a ChangeEntry with specific ordering fields."""
return ChangeEntry(
plan_id="plan-order",
resource_id=resource_id,
tool_name="builtin/file-write",
operation=ChangeOperation.CREATE,
path=path,
after_hash="hash-new",
timestamp=datetime(2026, 1, 1, 0, 0, ts_offset, tzinfo=UTC),
)
@given("a SpecChangeSet with entries in random order")
def step_changeset_random_order(context: Any) -> None:
entries = [
_make_entry("res-B", "z.py", 3),
_make_entry("res-A", "b.py", 1),
_make_entry("res-B", "a.py", 2),
_make_entry("res-A", "a.py", 0),
]
context.changeset = SpecChangeSet(plan_id="plan-order", entries=entries)
@when("I call sorted_entries")
def step_call_sorted(context: Any) -> None:
context.sorted = context.changeset.sorted_entries()
@then("entries should be sorted by resource_id then path then timestamp")
def step_verify_sorted(context: Any) -> None:
keys = [(e.resource_id, e.path, e.timestamp) for e in context.sorted]
assert keys == sorted(keys), f"Not sorted: {keys}"
@given("a SpecChangeSet with entries for multiple resources")
def step_changeset_multi_resource(context: Any) -> None:
entries = [
_make_entry("res-B", "y.py", 1),
_make_entry("res-A", "x.py", 0),
_make_entry("res-B", "w.py", 2),
_make_entry("res-A", "z.py", 3),
]
context.changeset = SpecChangeSet(plan_id="plan-grp", entries=entries)
@when("I call grouped_by_resource")
def step_call_grouped(context: Any) -> None:
context.groups = context.changeset.grouped_by_resource()
@then("entries should be grouped by resource_id")
def step_verify_grouped(context: Any) -> None:
assert set(context.groups.keys()) == {"res-A", "res-B"}
@then("each group should be sorted by path and timestamp")
def step_verify_group_sort(context: Any) -> None:
for entries in context.groups.values():
keys = [(e.path, e.timestamp) for e in entries]
assert keys == sorted(keys), f"Not sorted: {keys}"
@given("an empty SpecChangeSet")
def step_empty_changeset(context: Any) -> None:
context.changeset = SpecChangeSet(plan_id="plan-empty")
@when("I add a change entry to it")
def step_add_change(context: Any) -> None:
entry = ChangeEntry(
plan_id="plan-empty",
resource_id="res-1",
tool_name="builtin/file-write",
operation=ChangeOperation.CREATE,
path="new.py",
after_hash="hash-new",
)
context.changeset.add_change(entry)
@then("the spec changeset should contain {count:d} entry")
def step_spec_changeset_count(context: Any, count: int) -> None:
assert len(context.changeset.entries) == count
# ---------------------------------------------------------------------------
# ToolInvocation
# ---------------------------------------------------------------------------
@when('I create a ToolInvocation for plan "{pid}" and tool "{tool}"')
def step_create_invocation(context: Any, pid: str, tool: str) -> None:
context.invocation = ToolInvocation(plan_id=pid, tool_name=tool)
@then("the invocation should have a ULID invocation_id")
def step_invocation_ulid(context: Any) -> None:
assert len(context.invocation.invocation_id) == 26
@then('the invocation plan_id should be "{pid}"')
def step_invocation_plan(context: Any, pid: str) -> None:
assert context.invocation.plan_id == pid
@then('the invocation tool_name should be "{tool}"')
def step_invocation_tool(context: Any, tool: str) -> None:
assert context.invocation.tool_name == tool
@then("the invocation success should be true")
def step_invocation_success_true(context: Any) -> None:
assert context.invocation.success is True
@when('I create a failed ToolInvocation with error "{msg}"')
def step_failed_invocation(context: Any, msg: str) -> None:
context.invocation = ToolInvocation(
plan_id="plan-fail",
tool_name="builtin/file-read",
success=False,
error=msg,
)
@then("the invocation success should be false")
def step_invocation_success_false(context: Any) -> None:
assert context.invocation.success is False
@then('the invocation error should be "{msg}"')
def step_invocation_error(context: Any, msg: str) -> None:
assert context.invocation.error == msg
@when("I create a ToolInvocation with change_ids")
def step_invocation_change_ids(context: Any) -> None:
context.invocation = ToolInvocation(
plan_id="plan-chg",
tool_name="builtin/file-write",
change_ids=["chg-1", "chg-2"],
)
@then("the invocation should have {count:d} change_ids")
def step_invocation_change_count(context: Any, count: int) -> None:
assert len(context.invocation.change_ids) == count
@when("I create a ToolInvocation with provider metadata")
def step_invocation_provider(context: Any) -> None:
context.invocation = ToolInvocation(
plan_id="plan-prov",
tool_name="builtin/file-write",
provider_metadata={
"model_name": "gpt-4",
"provider_id": "openai",
"latency_ms": 150.0,
},
)
@then("the invocation provider_metadata should contain model name")
def step_invocation_provider_meta(context: Any) -> None:
meta = context.invocation.provider_metadata
assert meta is not None
assert "model_name" in meta
# ---------------------------------------------------------------------------
# InMemoryInvocationTracker
# ---------------------------------------------------------------------------
@given("an InMemoryInvocationTracker")
def step_tracker(context: Any) -> None:
context.tracker = InMemoryInvocationTracker()
@when('I track {count:d} invocations for plan "{pid}"')
def step_track_n(context: Any, count: int, pid: str) -> None:
for i in range(count):
context.tracker.track(
ToolInvocation(
plan_id=pid,
tool_name=f"tool-{i}",
sequence_number=i,
)
)
@then('get_invocations for "{pid}" should return {count:d}')
def step_get_invocations_count(context: Any, pid: str, count: int) -> None:
result = context.tracker.get_invocations(pid)
assert len(result) == count, f"Expected {count}, got {len(result)}"
@when("I track invocations for different skills")
def step_track_skills(context: Any) -> None:
context.tracker.track(
ToolInvocation(
plan_id="plan-sk",
tool_name="t1",
skill_name="skill-A",
sequence_number=0,
)
)
context.tracker.track(
ToolInvocation(
plan_id="plan-sk",
tool_name="t2",
skill_name="skill-B",
sequence_number=1,
)
)
context.tracker.track(
ToolInvocation(
plan_id="plan-sk",
tool_name="t3",
skill_name="skill-A",
sequence_number=2,
)
)
@then("get_invocations_for_skill should filter correctly")
def step_filter_skill(context: Any) -> None:
a = context.tracker.get_invocations_for_skill("plan-sk", "skill-A")
assert len(a) == 2
b = context.tracker.get_invocations_for_skill("plan-sk", "skill-B")
assert len(b) == 1
@when("I track invocations with change_ids")
def step_track_with_changes(context: Any) -> None:
context.tracker.track(
ToolInvocation(
plan_id="plan-chg",
tool_name="t1",
change_ids=["c1", "c2"],
sequence_number=0,
)
)
context.tracker.track(
ToolInvocation(
plan_id="plan-chg",
tool_name="t2",
change_ids=["c3"],
sequence_number=1,
)
)
@then("get_changes should return all change_ids for the plan")
def step_get_all_changes(context: Any) -> None:
changes = context.tracker.get_changes("plan-chg")
assert changes == ["c1", "c2", "c3"]
@when("I track invocations out of order")
def step_track_out_of_order(context: Any) -> None:
context.tracker.track(
ToolInvocation(
plan_id="plan-ord",
tool_name="t3",
sequence_number=2,
)
)
context.tracker.track(
ToolInvocation(
plan_id="plan-ord",
tool_name="t1",
sequence_number=0,
)
)
context.tracker.track(
ToolInvocation(
plan_id="plan-ord",
tool_name="t2",
sequence_number=1,
)
)
@then("get_invocations should return them in sequence order")
def step_verify_sequence_order(context: Any) -> None:
invs = context.tracker.get_invocations("plan-ord")
seqs = [i.sequence_number for i in invs]
assert seqs == [0, 1, 2], f"Expected [0, 1, 2], got {seqs}"
# ---------------------------------------------------------------------------
# SpecChangeSet summary
# ---------------------------------------------------------------------------
@given("a SpecChangeSet with mixed operation entries")
def step_changeset_mixed_ops(context: Any) -> None:
entries = [
ChangeEntry(
plan_id="plan-mix",
resource_id="res-A",
tool_name="builtin/file-write",
operation=ChangeOperation.CREATE,
path="new.py",
after_hash="h1",
),
ChangeEntry(
plan_id="plan-mix",
resource_id="res-A",
tool_name="builtin/file-edit",
operation=ChangeOperation.MODIFY,
path="edit.py",
before_hash="h2a",
after_hash="h2b",
),
ChangeEntry(
plan_id="plan-mix",
resource_id="res-B",
tool_name="builtin/file-delete",
operation=ChangeOperation.DELETE,
path="old.py",
before_hash="h3",
),
ChangeEntry(
plan_id="plan-mix",
resource_id="res-B",
tool_name="builtin/file-rename",
operation=ChangeOperation.RENAME,
path="moved.py",
),
]
context.changeset = SpecChangeSet(plan_id="plan-mix", entries=entries)
@when("I call summary on the changeset")
def step_call_summary(context: Any) -> None:
context.summary_result = context.changeset.summary()
@then("the summary total should be {count:d}")
def step_summary_total(context: Any, count: int) -> None:
assert context.summary_result["total"] == count, (
f"Expected total {count}, got {context.summary_result['total']}"
)
@then("the summary creates should be {count:d}")
def step_summary_creates(context: Any, count: int) -> None:
assert context.summary_result["creates"] == count
@then("the summary modifies should be {count:d}")
def step_summary_modifies(context: Any, count: int) -> None:
assert context.summary_result["modifies"] == count
@then("the summary deletes should be {count:d}")
def step_summary_deletes(context: Any, count: int) -> None:
assert context.summary_result["deletes"] == count
@then("the summary renames should be {count:d}")
def step_summary_renames(context: Any, count: int) -> None:
assert context.summary_result["renames"] == count
@then("the summary paths_changed should be {count:d}")
def step_summary_paths(context: Any, count: int) -> None:
assert context.summary_result["paths_changed"] == count
@then("the summary resources_involved should be {count:d}")
def step_summary_resources(context: Any, count: int) -> None:
assert context.summary_result["resources_involved"] == count
# ---------------------------------------------------------------------------
# add_change plan_id validation
# ---------------------------------------------------------------------------
@when("I add a change entry with a different plan_id")
def step_add_mismatched_plan(context: Any) -> None:
entry = ChangeEntry(
plan_id="wrong-plan",
resource_id="res-1",
tool_name="builtin/file-write",
operation=ChangeOperation.CREATE,
path="file.py",
after_hash="hash-new",
)
try:
context.changeset.add_change(entry)
context.validation_error = None
except Exception as exc:
context.validation_error = str(exc)
# ---------------------------------------------------------------------------
# Empty tracker queries
# ---------------------------------------------------------------------------
@then('get_changes for "{pid}" should return {count:d} ids')
def step_get_changes_empty(context: Any, pid: str, count: int) -> None:
changes = context.tracker.get_changes(pid)
assert len(changes) == count, f"Expected {count}, got {len(changes)}"
@then('get_invocations_for_skill "{pid}" "{skill}" should return {count:d}')
def step_get_skill_empty(context: Any, pid: str, skill: str, count: int) -> None:
result = context.tracker.get_invocations_for_skill(pid, skill)
assert len(result) == count, f"Expected {count}, got {len(result)}"
# ---------------------------------------------------------------------------
# get_changes ordering proof
# ---------------------------------------------------------------------------
@when("I track change invocations in reverse sequence order")
def step_track_changes_reverse(context: Any) -> None:
context.tracker.track(
ToolInvocation(
plan_id="plan-rev",
tool_name="t2",
sequence_number=1,
change_ids=["c-second"],
)
)
context.tracker.track(
ToolInvocation(
plan_id="plan-rev",
tool_name="t1",
sequence_number=0,
change_ids=["c-first"],
)
)
@then("get_changes should return change_ids in sequence order")
def step_verify_changes_sequence(context: Any) -> None:
changes = context.tracker.get_changes("plan-rev")
assert changes == ["c-first", "c-second"], (
f"Expected ['c-first', 'c-second'], got {changes}"
)
+39 -23
View File
@@ -718,6 +718,22 @@ The following work from the previous implementation has been completed and will
- Custom registered transform works
- Created `features/steps/security_eval_steps.py` with step definitions
**2026-02-19**: Stage C5.model COMMIT Complete - ChangeSet models and invocation tracker (Luis)
- Added `ToolInvocation` Pydantic model to `src/cleveragents/domain/models/core/change.py`: ULID id, plan_id, tool_name, skill_name, arguments, result, error, success, duration_ms, started_at, completed_at, change_ids, sequence_number, sandbox_path, resource_refs, provider_metadata.
- Added `InvocationTracker` Protocol and `InMemoryInvocationTracker` implementation with track(), get_invocations(), get_invocations_for_skill(), get_changes() -- all ordered by sequence_number.
- Added `ChangeType` alias for `ChangeOperation` enum.
- Added per-change field validators on `ChangeEntry`: CREATE rejects before_hash, DELETE rejects after_hash.
- Added `has_integrity_hashes` property for checking hash completeness per operation type.
- Added `sorted_entries()` (resource_id, path, timestamp) and `grouped_by_resource()` to `SpecChangeSet`.
- Added `add_change()` method to `SpecChangeSet`.
- Added `normalize_change_path()` utility for repo-relative POSIX paths.
- Updated `src/cleveragents/domain/models/core/__init__.py` exports.
- Created `features/change_tracking.feature` with 21 BDD scenarios.
- Created `robot/change_tracking.robot` with 5 integration smoke tests.
- Created `benchmarks/change_tracking_bench.py` with 4 ASV benchmark suites.
- Created `docs/reference/change_tracking.md`.
- All nox sessions pass: lint, typecheck, unit_tests (192 features / 3677 scenarios), integration_tests (339 tests), coverage_report (97.7%).
**2026-02-09**: Stage A5.6 Complete - ActionRepository (Luis tasks A5.6a-A5.6j)
- Created `ActionRepository` class in `src/cleveragents/infrastructure/database/repositories.py`
- Uses session-factory pattern: `__init__(self, session_factory: Callable[[], Session])` -- each method obtains its own session from the factory
@@ -3787,29 +3803,29 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
- [ ] Forgejo PR [Luis]: Open PR from `feature/m3-skill-git` to `master` with a suitable and thorough description
**Parallel Group C5: Tool Routing & Change Tracking [Luis + Jeff]** (depends on C3/C4)
- [ ] **COMMIT (Owner: Luis | Group: C5.model | Branch: feature/m3-change-model | Planned: Day 19 | Expected: Day 22) - Commit message: "feat(change): add ChangeSet models and invocation tracker"**
- [ ] Git [Luis]: `git checkout master`
- [ ] Git [Luis]: `git pull origin master`
- [ ] Git [Luis]: `git checkout -b feature/m3-change-model`
- [ ] Git [Luis]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Luis]: Add Change/ChangeSet/ToolInvocation models and SkillInvocationTracker.
- [ ] Code [Luis]: Ensure ChangeSet stores resource references, sandbox paths, tool metadata, and timestamps.
- [ ] Code [Luis]: Add ChangeSet serialization helper for plan diff output (group by resource).
- [ ] Code [Luis]: Enforce deterministic ordering for ChangeSet entries (by resource, then path, then timestamp).
- [ ] Code [Luis]: Link ToolInvocation to plan_id and skill/tool names for auditability.
- [ ] Code [Luis]: Add ChangeType enum (create/modify/delete/rename) and validate per-change required fields.
- [ ] Code [Luis]: Add content hash fields (before_hash/after_hash) and file mode in Change for integrity.
- [ ] Code [Luis]: Normalize file paths to repo-relative paths in ChangeSet entries.
- [ ] Docs [Luis]: Add `docs/reference/change_tracking.md` describing tool-to-change mapping.
- [ ] Tests (Behave) [Luis]: Add `features/change_tracking.feature` for ChangeSet aggregation.
- [ ] Tests (Robot) [Luis]: Add `robot/change_tracking.robot` for tracker smoke tests.
- [ ] Tests (ASV) [Luis]: Add `benchmarks/change_tracking_bench.py` for invocation tracking overhead.
- [ ] Quality [Luis]: 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 [Luis]: 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 [Luis]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [ ] Git [Luis]: `git commit -m "feat(change): add ChangeSet models and invocation tracker"`
- [ ] Git [Luis]: `git push -u origin feature/m3-change-model`
- [ ] Forgejo PR [Luis]: Open PR from `feature/m3-change-model` to `master` with a suitable and thorough description
- [X] **COMMIT (Owner: Luis | Group: C5.model | Branch: feature/m3-change-model | Done: Day 20, February 19, 2026) - Commit message: "feat(change): add ChangeSet models and invocation tracker"**
- [X] Git [Luis]: `git checkout master`
- [X] Git [Luis]: `git pull origin master`
- [X] Git [Luis]: `git checkout -b feature/m3-change-model`
- [X] Git [Luis]: `git fetch origin && git merge origin/master`
- [X] Code [Luis]: Add ToolInvocation model and InMemoryInvocationTracker (Protocol + impl) with plan_id/skill_name/tool_name linkage, sequence ordering, change_ids, provider metadata.
- [X] Code [Luis]: SpecChangeSet stores resource references, sandbox paths, tool metadata, and timestamps via ChangeEntry fields.
- [X] Code [Luis]: Add ChangeSet serialization helper grouped_by_resource() for plan diff output.
- [X] Code [Luis]: Enforce deterministic ordering via sorted_entries() (resource_id, path, timestamp).
- [X] Code [Luis]: Link ToolInvocation to plan_id and skill/tool names for auditability.
- [X] Code [Luis]: Add ChangeType alias for ChangeOperation and per-change field validators (CREATE/DELETE hash consistency).
- [X] Code [Luis]: Content hash fields (before_hash/after_hash) and file mode already in ChangeEntry; added has_integrity_hashes property.
- [X] Code [Luis]: Add normalize_change_path() for repo-relative POSIX path normalization.
- [X] Docs [Luis]: Add `docs/reference/change_tracking.md` describing tool-to-change mapping.
- [X] Tests (Behave) [Luis]: Add `features/change_tracking.feature` with 21 scenarios for ChangeSet aggregation, invocation tracking, path normalization.
- [X] Tests (Robot) [Luis]: Add `robot/change_tracking.robot` with 5 tracker smoke tests.
- [X] Tests (ASV) [Luis]: Add `benchmarks/change_tracking_bench.py` with 4 benchmark suites.
- [X] Quality [Luis]: Verify coverage >=97% via `nox -s coverage_report`. Coverage: 97.7%.
- [X] Quality [Luis]: Run `nox` (all default sessions), all pass.
- [X] Git [Luis]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [X] Git [Luis]: `git commit -m "feat(change): add ChangeSet models and invocation tracker"`
- [ ] Git [Luis]: `git push -u origin feature/m3-change-model`
- [ ] Forgejo PR [Luis]: Open PR from `feature/m3-change-model` to `master` with description "Add ChangeSet models, invocation tracking, and tests.".
- [X] **COMMIT (Owner: Jeff | Group: C5.router | Branch: feature/m3-tool-router | Planned: Day 20 | Expected: Day 20) - Commit message: "feat(change): add tool router for providers"**
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
+39
View File
@@ -0,0 +1,39 @@
*** Settings ***
Documentation Smoke tests for ChangeSet models and invocation tracking
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_change_tracking.py
*** Test Cases ***
ChangeType Alias
[Documentation] ChangeType is an alias for ChangeOperation
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} changetype cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} changetype-ok
ChangeEntry Validator
[Documentation] Per-operation hash field validation
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} validator cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} validator-ok
Sorted Entries
[Documentation] SpecChangeSet sorted_entries deterministic ordering
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} sorted cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} sorted-ok
Invocation Tracker
[Documentation] InMemoryInvocationTracker tracking and retrieval
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} tracker cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tracker-ok
Normalize Path
[Documentation] normalize_change_path strips repo root
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} normalize cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} normalize-ok
+138
View File
@@ -0,0 +1,138 @@
"""Helper script for Robot Framework change tracking 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 pydantic import ValidationError
from cleveragents.domain.models.core.change import (
ChangeEntry,
ChangeOperation,
ChangeType,
InMemoryInvocationTracker,
SpecChangeSet,
ToolInvocation,
normalize_change_path,
)
def test_changetype_alias() -> None:
assert ChangeType is ChangeOperation
assert ChangeType.CREATE == "create"
print("changetype-ok")
def test_change_entry_validator() -> None:
# CREATE with before_hash should fail
try:
ChangeEntry(
plan_id="p",
resource_id="r",
tool_name="t",
operation=ChangeOperation.CREATE,
path="x.py",
before_hash="bad",
after_hash="ok",
)
print("validator-fail: no error raised")
sys.exit(1)
except (ValidationError, ValueError):
pass
# Valid CREATE
e = ChangeEntry(
plan_id="p",
resource_id="r",
tool_name="t",
operation=ChangeOperation.CREATE,
path="x.py",
after_hash="ok",
)
assert e.has_integrity_hashes is True
print("validator-ok")
def test_sorted_entries() -> None:
from datetime import UTC, datetime
entries = [
ChangeEntry(
plan_id="p",
resource_id="res-B",
tool_name="t",
operation=ChangeOperation.CREATE,
path="z.py",
after_hash="h",
timestamp=datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC),
),
ChangeEntry(
plan_id="p",
resource_id="res-A",
tool_name="t",
operation=ChangeOperation.CREATE,
path="a.py",
after_hash="h",
timestamp=datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC),
),
]
cs = SpecChangeSet(plan_id="p", entries=entries)
s = cs.sorted_entries()
assert s[0].resource_id == "res-A"
assert s[1].resource_id == "res-B"
print("sorted-ok")
def test_invocation_tracker() -> None:
tracker = InMemoryInvocationTracker()
tracker.track(
ToolInvocation(
plan_id="p1",
tool_name="t1",
sequence_number=1,
change_ids=["c1"],
)
)
tracker.track(
ToolInvocation(
plan_id="p1",
tool_name="t2",
sequence_number=0,
change_ids=["c2"],
)
)
invs = tracker.get_invocations("p1")
assert len(invs) == 2
assert invs[0].sequence_number == 0 # sorted
changes = tracker.get_changes("p1")
assert len(changes) == 2
print("tracker-ok")
def test_normalize_path() -> None:
result = normalize_change_path(
"/home/user/project/src/main.py", "/home/user/project"
)
assert result == "src/main.py", f"Got: {result}"
print("normalize-ok")
if __name__ == "__main__":
dispatch = {
"changetype": test_changetype_alias,
"validator": test_change_entry_validator,
"sorted": test_sorted_entries,
"tracker": test_invocation_tracker,
"normalize": test_normalize_path,
}
cmd = sys.argv[1] if len(sys.argv) > 1 else "changetype"
fn = dispatch.get(cmd)
if fn:
fn()
else:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
@@ -12,11 +12,16 @@ from cleveragents.domain.models.core.change import (
ChangeOperation,
ChangeSet,
ChangeSetStore,
ChangeType,
InMemoryChangeSetStore,
InMemoryInvocationTracker,
InvocationTracker,
LegacyChangeSet,
Operation,
OperationType,
SpecChangeSet,
ToolInvocation,
normalize_change_path,
)
from cleveragents.domain.models.core.context import (
Context,
@@ -152,6 +157,7 @@ __all__ = [
"ChangeOperation",
"ChangeSet",
"ChangeSetStore",
"ChangeType",
"CheckpointScope",
"CloudBillingFields",
"Context",
@@ -165,8 +171,10 @@ __all__ = [
"CreditsTransactionType",
"DebugAttempt",
"InMemoryChangeSetStore",
"InMemoryInvocationTracker",
"InvariantSource",
"Invite",
"InvocationTracker",
"LegacyChangeSet",
"LifecyclePlan",
"LinkedResource",
@@ -226,6 +234,7 @@ __all__ = [
"TemporalScope",
"Tool",
"ToolCapability",
"ToolInvocation",
"ToolLifecycle",
"ToolSource",
"ToolType",
@@ -234,5 +243,6 @@ __all__ = [
"ValidationMode",
"can_transition",
"get_builtin_profile",
"normalize_change_path",
"parse_namespaced_name",
]
+279 -8
View File
@@ -4,13 +4,19 @@ Based on Phase 0 discovery, ADR-004 (Pydantic Validation), and the
D0.alpha changeset specification. Contains both the legacy change
models (used by existing plan workflows) and the spec-aligned
ChangeSet domain model with ULID identifiers and content hashes.
Also provides ToolInvocation tracking (C5.model) for auditability
of tool executions and their resulting changes.
"""
import contextlib
from collections import defaultdict
from datetime import UTC, datetime
from enum import StrEnum
from pathlib import PurePosixPath
from typing import Any, Protocol
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from ulid import ULID
# ------------------------------------------------------------------
@@ -137,6 +143,29 @@ class ChangeOperation(StrEnum):
RENAME = "rename"
# Alias used by some implementation-plan references.
ChangeType = ChangeOperation
def normalize_change_path(path: str, repo_root: str = "") -> str:
"""Normalize a file path to a repo-relative POSIX path.
Strips the *repo_root* prefix (if present) and converts to
forward slashes. The result never starts with ``/`` or ``./``.
Note: ``..`` components are **not** resolved; callers should
canonicalize paths before passing them in if ``..`` handling
is required.
"""
posix = PurePosixPath(path)
if repo_root:
root = PurePosixPath(repo_root)
with contextlib.suppress(ValueError):
posix = posix.relative_to(root)
parts = [p for p in posix.parts if p not in (".", "/")]
return "/".join(parts) if parts else "."
class ChangeEntry(BaseModel):
"""A single recorded change from a tool execution.
@@ -198,6 +227,49 @@ class ChangeEntry(BaseModel):
use_enum_values=True,
)
@model_validator(mode="after")
def _validate_hash_fields(self) -> "ChangeEntry":
"""Validate per-operation hash consistency.
When hashes are provided, ensure they are consistent with
the operation type:
* CREATE must not have ``before_hash`` (no prior content).
* DELETE must not have ``after_hash`` (no resulting content).
Missing hashes are permitted for backward compatibility and
for lightweight test construction. MODIFY entries with
identical ``before_hash``/``after_hash`` are allowed because
mode-only changes produce the same content hash.
"""
op = self.operation
if op == ChangeOperation.CREATE and self.before_hash is not None:
raise ValueError("CREATE entries must not include before_hash")
if op == ChangeOperation.DELETE and self.after_hash is not None:
raise ValueError("DELETE entries must not include after_hash")
return self
@property
def has_integrity_hashes(self) -> bool:
"""Return whether this entry has full hash coverage.
``True`` when the hashes required for the operation type are
all present:
* CREATE: ``after_hash`` set
* DELETE: ``before_hash`` set
* MODIFY: both hashes set
* RENAME: no hash requirement (always ``True``)
"""
op = self.operation
if op == ChangeOperation.CREATE:
return self.after_hash is not None
if op == ChangeOperation.DELETE:
return self.before_hash is not None
if op == ChangeOperation.MODIFY:
return self.before_hash is not None and self.after_hash is not None
return True
class SpecChangeSet(BaseModel):
"""Accumulated set of ChangeEntry records for a plan.
@@ -261,17 +333,216 @@ class SpecChangeSet(BaseModel):
return {e.resource_id for e in self.entries}
def summary(self) -> dict[str, Any]:
"""Return summary counts as a dict."""
"""Return summary counts as a dict (single pass)."""
creates = modifies = deletes = renames = 0
paths: set[str] = set()
resources: set[str] = set()
for e in self.entries:
op = e.operation
if op == ChangeOperation.CREATE:
creates += 1
elif op == ChangeOperation.MODIFY:
modifies += 1
elif op == ChangeOperation.DELETE:
deletes += 1
elif op == ChangeOperation.RENAME:
renames += 1
paths.add(e.path)
resources.add(e.resource_id)
return {
"total": len(self.entries),
"creates": self.creates,
"modifies": self.modifies,
"deletes": self.deletes,
"renames": self.renames,
"paths_changed": len(self.paths_changed),
"resources_involved": len(self.resources_involved),
"creates": creates,
"modifies": modifies,
"deletes": deletes,
"renames": renames,
"paths_changed": len(paths),
"resources_involved": len(resources),
}
def sorted_entries(self) -> list[ChangeEntry]:
"""Return entries in deterministic order.
Sort key: ``(resource_id, path, timestamp)``.
"""
return sorted(
self.entries,
key=lambda e: (e.resource_id, e.path, e.timestamp),
)
def grouped_by_resource(self) -> dict[str, list[ChangeEntry]]:
"""Group entries by resource_id for plan diff output.
Within each group, entries are sorted by ``(path, timestamp)``.
"""
groups: dict[str, list[ChangeEntry]] = {}
for entry in self.sorted_entries():
groups.setdefault(entry.resource_id, []).append(entry)
return groups
def add_change(self, entry: ChangeEntry) -> None:
"""Append a change entry to this changeset.
Raises ``ValueError`` if *entry.plan_id* does not match
this changeset's *plan_id*.
"""
if entry.plan_id != self.plan_id:
raise ValueError(
f"ChangeEntry plan_id '{entry.plan_id}' does not match "
f"SpecChangeSet plan_id '{self.plan_id}'"
)
self.entries.append(entry)
# ------------------------------------------------------------------
# ToolInvocation and SkillInvocationTracker (C5.model)
# ------------------------------------------------------------------
class ToolInvocation(BaseModel):
"""Record of a single tool execution for auditability.
Links a tool call to its plan, skill, arguments, result, and
the ChangeEntry records it produced.
"""
invocation_id: str = Field(
default_factory=_new_ulid,
description="ULID uniquely identifying this invocation",
)
plan_id: str = Field(
...,
description="ULID of the plan that triggered this call",
)
tool_name: str = Field(
...,
min_length=1,
description="Namespaced tool name (namespace/short_name)",
)
skill_name: str | None = Field(
default=None,
description="Skill that provided the tool, if any",
)
arguments: dict[str, Any] = Field(
default_factory=dict,
description="Input parameters passed to the tool",
)
result: dict[str, Any] | None = Field(
default=None,
description="Output payload (None when failed before return)",
)
error: str | None = Field(
default=None,
description="Error message when the tool execution failed",
)
success: bool = Field(
default=True,
description="Whether execution completed without error",
)
duration_ms: float = Field(
default=0.0,
ge=0.0,
description="Wall-clock execution time in milliseconds",
)
started_at: datetime = Field(
default_factory=lambda: datetime.now(UTC),
description="UTC timestamp when execution started",
)
completed_at: datetime | None = Field(
default=None,
description="UTC timestamp when execution finished",
)
change_ids: list[str] = Field(
default_factory=list,
description="ChangeEntry IDs produced by this invocation",
)
sequence_number: int = Field(
default=0,
ge=0,
description="Deterministic ordering within a plan",
)
sandbox_path: str | None = Field(
default=None,
description="Sandbox root used during this invocation",
)
resource_refs: list[str] = Field(
default_factory=list,
description="Resource IDs involved in this invocation",
)
provider_metadata: dict[str, Any] | None = Field(
default=None,
description="Provider info (model name, latency, etc.)",
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
class InvocationTracker(Protocol):
"""Interface for tracking tool invocations."""
def track(self, invocation: ToolInvocation) -> None:
"""Record a tool invocation."""
...
def get_invocations(self, plan_id: str) -> list[ToolInvocation]:
"""Return all invocations for a plan."""
...
def get_invocations_for_skill(
self,
plan_id: str,
skill_name: str,
) -> list[ToolInvocation]:
"""Return invocations for a specific skill within a plan."""
...
def get_changes(self, plan_id: str) -> list[str]:
"""Return all change IDs produced by invocations for a plan."""
...
class InMemoryInvocationTracker:
"""In-memory implementation of InvocationTracker.
Suitable for tests and the M1 single-process runtime.
Uses a ``defaultdict`` keyed by ``plan_id`` for O(k) per-plan
lookups instead of O(N) full-list scans.
"""
def __init__(self) -> None:
self._by_plan: defaultdict[str, list[ToolInvocation]] = defaultdict(list)
def track(self, invocation: ToolInvocation) -> None:
"""Record a tool invocation."""
self._by_plan[invocation.plan_id].append(invocation)
def get_invocations(self, plan_id: str) -> list[ToolInvocation]:
"""Return all invocations for a plan, ordered by sequence."""
return sorted(
self._by_plan[plan_id],
key=lambda i: i.sequence_number,
)
def get_invocations_for_skill(
self,
plan_id: str,
skill_name: str,
) -> list[ToolInvocation]:
"""Return invocations for a specific skill within a plan."""
return sorted(
[i for i in self._by_plan[plan_id] if i.skill_name == skill_name],
key=lambda i: i.sequence_number,
)
def get_changes(self, plan_id: str) -> list[str]:
"""Return all change IDs produced by invocations for a plan."""
change_ids: list[str] = []
for inv in self.get_invocations(plan_id):
change_ids.extend(inv.change_ids)
return change_ids
# ------------------------------------------------------------------
# ChangeSetStore protocol and in-memory implementation