feat(change): add ChangeSet models and invocation tracker
This commit is contained in:
@@ -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", "")
|
||||
@@ -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")
|
||||
```
|
||||
@@ -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 "."
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user