feat(acms): add skeleton compressor #525
@@ -14,6 +14,11 @@
|
||||
resource types with `DevcontainerHandler` and auto-discovery logic that scans for
|
||||
`.devcontainer/` directories when `git-checkout` or `fs-directory` resources are linked.
|
||||
Includes CLI support, Behave/Robot/ASV tests, and reference documentation. (#511)
|
||||
- Added skeleton compressor service (`SkeletonCompressorService`) for ACMS context inheritance.
|
||||
Compresses parent plan context fragments by `skeleton_ratio` (0.0–1.0) for propagation to
|
||||
child plans. Persists `SkeletonMetadata` (ratio, token counts, source decision IDs) on the
|
||||
plan model for auditability. Includes stable fragment ordering, ratio validation with default
|
||||
handling, and compression summary. (#194)
|
||||
- Replaced behave-parallel subprocess-per-feature execution model (342 Python interpreter
|
||||
startups) with in-process execution via behave's `Runner` API. Sequential mode runs all
|
||||
features in a single `Runner.run()` call; parallel mode uses `multiprocessing.Pool` with
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""ASV benchmarks for skeleton compressor overhead.
|
||||
|
||||
Measures the time to compress context fragments at various ratios
|
||||
and fragment counts. The benchmark covers the hot path that runs
|
||||
during subplan context inheritance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the local *source* tree is importable even when ASV has an
|
||||
# older build of the package installed.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.application.services.skeleton_compressor import ( # noqa: E402
|
||||
ContextFragment,
|
||||
SkeletonCompressorService,
|
||||
)
|
||||
|
||||
|
||||
def _build_fragments(count: int, tokens_each: int = 100) -> list[ContextFragment]:
|
||||
"""Generate *count* fragments for benchmarking."""
|
||||
return [
|
||||
ContextFragment(
|
||||
fragment_id=f"bench-{i:05d}",
|
||||
content="x" * tokens_each,
|
||||
token_count=tokens_each,
|
||||
relevance=round(1.0 - (i / max(count, 1)), 4),
|
||||
source_decision_id=f"01HX{'B' * 22}{i:02d}" if i < 99 else None,
|
||||
)
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
class SkeletonCompressorSmallSuite:
|
||||
"""Benchmark compression of a small fragment set (10 fragments)."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._svc = SkeletonCompressorService()
|
||||
self._fragments = _build_fragments(10)
|
||||
|
||||
def time_compress_ratio_0(self) -> None:
|
||||
"""No compression (pass-through)."""
|
||||
self._svc.compress(self._fragments, skeleton_ratio=0.0)
|
||||
|
||||
def time_compress_ratio_03(self) -> None:
|
||||
"""Default ratio compression."""
|
||||
self._svc.compress(self._fragments, skeleton_ratio=0.3)
|
||||
|
||||
def time_compress_ratio_05(self) -> None:
|
||||
"""Moderate compression."""
|
||||
self._svc.compress(self._fragments, skeleton_ratio=0.5)
|
||||
|
||||
def time_compress_ratio_1(self) -> None:
|
||||
"""Maximum compression."""
|
||||
self._svc.compress(self._fragments, skeleton_ratio=1.0)
|
||||
|
||||
|
||||
class SkeletonCompressorMediumSuite:
|
||||
"""Benchmark compression of a medium fragment set (100 fragments)."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._svc = SkeletonCompressorService()
|
||||
self._fragments = _build_fragments(100)
|
||||
|
||||
def time_compress_ratio_03(self) -> None:
|
||||
"""Default ratio compression."""
|
||||
self._svc.compress(self._fragments, skeleton_ratio=0.3)
|
||||
|
||||
def time_compress_ratio_05(self) -> None:
|
||||
"""Moderate compression."""
|
||||
self._svc.compress(self._fragments, skeleton_ratio=0.5)
|
||||
|
||||
|
||||
class SkeletonCompressorLargeSuite:
|
||||
"""Benchmark compression of a large fragment set (1000 fragments)."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._svc = SkeletonCompressorService()
|
||||
self._fragments = _build_fragments(1000)
|
||||
|
||||
def time_compress_ratio_05(self) -> None:
|
||||
"""Moderate compression over 1000 fragments."""
|
||||
self._svc.compress(self._fragments, skeleton_ratio=0.5)
|
||||
|
||||
def time_compress_ratio_08(self) -> None:
|
||||
"""Heavy compression over 1000 fragments."""
|
||||
self._svc.compress(self._fragments, skeleton_ratio=0.8)
|
||||
@@ -0,0 +1,109 @@
|
||||
# Skeleton Compressor
|
||||
|
||||
## Overview
|
||||
|
||||
The **Skeleton Compressor** produces a compressed representation of a
|
||||
plan's accumulated context for propagation to child plans as inherited
|
||||
context. Compression is governed by the `skeleton_ratio` budget
|
||||
parameter set on a project's context policy.
|
||||
|
||||
The compressor lives in
|
||||
`cleveragents.application.services.skeleton_compressor.SkeletonCompressorService`
|
||||
and is registered in the DI container as `skeleton_compressor_service`.
|
||||
|
||||
## Skeleton Ratio
|
||||
|
||||
| Ratio | Meaning | Behaviour |
|
||||
|------:|:--------|:----------|
|
||||
| `0.0` | No compression | All fragments pass through unchanged. |
|
||||
| `0.3` | Default | ~70 % of tokens retained (top-relevance first). |
|
||||
| `0.5` | Moderate | ~50 % of tokens retained. |
|
||||
| `0.8` | Heavy | ~20 % of tokens retained. |
|
||||
| `1.0` | Maximum | Only the single highest-relevance fragment is kept. |
|
||||
|
||||
The ratio is validated to the closed interval `[0.0, 1.0]`. Values
|
||||
outside this range raise `ValueError`.
|
||||
|
||||
### Default Handling
|
||||
|
||||
When a plan or project context policy does not set `skeleton_ratio`,
|
||||
the service applies the constant `DEFAULT_SKELETON_RATIO = 0.3`.
|
||||
|
||||
## Fragment Ordering
|
||||
|
||||
Fragments are sorted by **relevance descending** with a stable
|
||||
secondary sort on **fragment_id ascending**. This guarantees
|
||||
deterministic output: identical inputs always produce identical
|
||||
compressed payloads regardless of the order in which fragments
|
||||
arrive.
|
||||
|
||||
## Compression Algorithm
|
||||
|
||||
1. Validate all inputs (ratio, fragment fields).
|
||||
2. Compute `original_tokens` — sum of `token_count` across all
|
||||
fragments.
|
||||
3. Sort fragments by `(-relevance, fragment_id)`.
|
||||
4. Compute a token budget: `budget = original_tokens * (1 - ratio)`.
|
||||
5. Iterate sorted fragments, accumulating tokens until the budget is
|
||||
exhausted.
|
||||
6. Return the kept fragments and a `SkeletonMetadata` record.
|
||||
|
||||
## Metadata
|
||||
|
||||
Every compression pass produces a frozen `SkeletonMetadata`:
|
||||
|
||||
| Field | Type | Description |
|
||||
|:------|:-----|:------------|
|
||||
| `ratio` | `float` | The ratio applied. |
|
||||
| `original_tokens` | `int` | Tokens before compression. |
|
||||
| `compressed_tokens` | `int` | Tokens after compression. |
|
||||
| `source_decision_ids` | `tuple[str, ...]` | Decision ULIDs included. |
|
||||
|
||||
The metadata is persisted on the `Plan` model via the
|
||||
`skeleton_metadata` field, making compression auditable.
|
||||
|
||||
### Compression Summary
|
||||
|
||||
The metadata doubles as a compression summary: compare
|
||||
`original_tokens` to `compressed_tokens` to see how much context was
|
||||
removed. The summary is included in `plan status` CLI output under
|
||||
the `skeleton` key.
|
||||
|
||||
## Example: Multi-Decision Plan
|
||||
|
||||
Consider a plan with three decision context fragments:
|
||||
|
||||
```text
|
||||
Fragment A (relevance=0.9, tokens=400, decision=01HX...)
|
||||
Fragment B (relevance=0.6, tokens=300, decision=01HY...)
|
||||
Fragment C (relevance=0.3, tokens=300, decision=01HZ...)
|
||||
```
|
||||
|
||||
With `skeleton_ratio = 0.5` the token budget is
|
||||
`1000 * (1 - 0.5) = 500` tokens:
|
||||
|
||||
- Fragment A (400 tokens, cumulative 400) — kept.
|
||||
- Fragment B (300 tokens, cumulative 700) — exceeds budget; skipped.
|
||||
|
||||
**Result:**
|
||||
|
||||
```text
|
||||
Compressed fragments: [A]
|
||||
original_tokens: 1000
|
||||
compressed_tokens: 400
|
||||
source_decision_ids: (01HX...)
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
The skeleton output feeds into the **subplan context inheritance**
|
||||
pipeline. When a parent plan spawns a child, the strategy coordinator
|
||||
calls the compressor on the parent's accumulated context, stores the
|
||||
resulting `SkeletonMetadata` on the child plan, and passes the
|
||||
compressed fragments as the child's inherited context budget.
|
||||
|
||||
## CLI
|
||||
|
||||
The `--skeleton-ratio` flag on `agents project context set` sets the
|
||||
ratio for a project's context policy. The `plan status` command
|
||||
displays the compression summary when `skeleton_metadata` is present.
|
||||
@@ -0,0 +1,134 @@
|
||||
Feature: Skeleton compressor
|
||||
As an ACMS subsystem
|
||||
I want to compress context fragments for subplan inheritance
|
||||
So that child plans receive relevant context within a token budget
|
||||
|
||||
Background:
|
||||
Given a skeleton compressor service
|
||||
|
||||
# --- ratio validation -------------------------------------------------
|
||||
|
||||
Scenario: Reject ratio below 0.0
|
||||
Given context fragments with total tokens 1000
|
||||
When I compress with skeleton_ratio -0.1
|
||||
Then the compressor should raise a ValueError for invalid ratio
|
||||
|
||||
Scenario: Reject ratio above 1.0
|
||||
Given context fragments with total tokens 1000
|
||||
When I compress with skeleton_ratio 1.5
|
||||
Then the compressor should raise a ValueError for invalid ratio
|
||||
|
||||
Scenario: Accept ratio 0.0
|
||||
Given context fragments with total tokens 1000
|
||||
When I compress with skeleton_ratio 0.0
|
||||
Then all fragments should be returned unchanged
|
||||
|
||||
Scenario: Accept ratio 1.0
|
||||
Given context fragments with total tokens 1000
|
||||
When I compress with skeleton_ratio 1.0
|
||||
Then only the highest-relevance fragment should be returned
|
||||
|
||||
Scenario: Accept ratio at boundary 0.5
|
||||
Given context fragments with total tokens 1000
|
||||
When I compress with skeleton_ratio 0.5
|
||||
Then compressed tokens should be at most 500
|
||||
|
||||
# --- default handling -------------------------------------------------
|
||||
|
||||
Scenario: Default ratio applied when None
|
||||
Given context fragments with total tokens 1000
|
||||
When I compress with skeleton_ratio not specified
|
||||
Then the metadata ratio should equal the default 0.3
|
||||
|
||||
# --- stable ordering --------------------------------------------------
|
||||
|
||||
Scenario: Fragments with equal relevance are ordered by id
|
||||
Given three fragments with equal relevance 0.5
|
||||
When I compress with skeleton_ratio 0.0
|
||||
Then fragments should be ordered by fragment_id ascending
|
||||
|
||||
Scenario: Fragments are ordered by relevance descending
|
||||
Given fragments with relevances 0.9, 0.3, and 0.7
|
||||
When I compress with skeleton_ratio 0.0
|
||||
Then the first fragment should have relevance 0.9
|
||||
And the last fragment should have relevance 0.3
|
||||
|
||||
# --- metadata ----------------------------------------------------------
|
||||
|
||||
Scenario: Metadata records correct token counts
|
||||
Given context fragments with total tokens 1000
|
||||
When I compress with skeleton_ratio 0.5
|
||||
Then metadata original_tokens should be 1000
|
||||
And metadata compressed_tokens should be at most 500
|
||||
|
||||
Scenario: Metadata records source decision IDs
|
||||
Given fragments with known decision IDs
|
||||
When I compress with skeleton_ratio 0.0
|
||||
Then metadata should contain all source decision IDs
|
||||
|
||||
Scenario: Metadata ratio matches input
|
||||
Given context fragments with total tokens 1000
|
||||
When I compress with skeleton_ratio 0.7
|
||||
Then metadata ratio should be 0.7
|
||||
|
||||
# --- edge cases -------------------------------------------------------
|
||||
|
||||
Scenario: Empty fragment list compresses to empty
|
||||
Given an empty fragment list
|
||||
When I compress with skeleton_ratio 0.5
|
||||
Then the result should contain zero fragments
|
||||
And metadata original_tokens should be 0
|
||||
And metadata compressed_tokens should equal 0
|
||||
|
||||
Scenario: Single fragment at ratio 0.5
|
||||
Given a single fragment with 100 tokens
|
||||
When I compress with skeleton_ratio 0.5
|
||||
Then the result should contain one fragment
|
||||
|
||||
# --- argument validation ----------------------------------------------
|
||||
|
||||
Scenario: Reject non-list fragments argument
|
||||
When I compress with a non-list fragments argument
|
||||
Then the compressor should raise a TypeError
|
||||
|
||||
Scenario: Reject fragment with negative token count
|
||||
Given a fragment with negative token count
|
||||
When I compress with skeleton_ratio 0.5
|
||||
Then the compressor should raise a ValueError for invalid fragment
|
||||
|
||||
Scenario: Reject fragment with empty id
|
||||
Given a fragment with empty fragment_id
|
||||
When I compress with skeleton_ratio 0.5
|
||||
Then the compressor should raise a ValueError for invalid fragment
|
||||
|
||||
Scenario: Reject fragment with relevance out of range
|
||||
Given a fragment with relevance 1.5
|
||||
When I compress with skeleton_ratio 0.5
|
||||
Then the compressor should raise a ValueError for invalid fragment
|
||||
|
||||
Scenario: Reject non-ContextFragment item in list
|
||||
When I compress with a list containing a non-fragment item
|
||||
Then the compressor should raise a TypeError for invalid item
|
||||
|
||||
Scenario: Reject non-numeric skeleton_ratio
|
||||
Given context fragments with total tokens 1000
|
||||
When I compress with a non-numeric skeleton_ratio
|
||||
Then the compressor should raise a TypeError for invalid ratio type
|
||||
|
||||
Scenario: Reject skeleton metadata with compressed exceeding original
|
||||
When I create skeleton metadata with compressed exceeding original
|
||||
Then a validation error should be raised for compressed exceeding original
|
||||
|
||||
# --- compression summary (original vs compressed) ----------------------
|
||||
|
||||
Scenario: Compression summary stored in plan metadata
|
||||
Given context fragments with total tokens 1000
|
||||
When I compress with skeleton_ratio 0.6
|
||||
Then compressed_tokens should be less than original_tokens
|
||||
|
||||
# --- skeleton_ratio integration with plan model ------------------------
|
||||
|
||||
Scenario: Plan model accepts skeleton_metadata
|
||||
Given a skeleton metadata with ratio 0.5 and 1000 original tokens and 500 compressed
|
||||
When I attach skeleton_metadata to a plan
|
||||
Then the plan should expose skeleton metadata in cli dict
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Step definitions for the Skeleton Compressor feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.skeleton_compressor import (
|
||||
DEFAULT_SKELETON_RATIO,
|
||||
ContextFragment,
|
||||
SkeletonCompressorService,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanPhase,
|
||||
ProcessingState,
|
||||
)
|
||||
from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_fragments(total_tokens: int, count: int = 4) -> list[ContextFragment]:
|
||||
"""Create *count* fragments summing to *total_tokens*."""
|
||||
base = total_tokens // count
|
||||
remainder = total_tokens - base * count
|
||||
relevances = [0.9, 0.7, 0.5, 0.3]
|
||||
frags: list[ContextFragment] = []
|
||||
for i in range(count):
|
||||
tokens = base + (remainder if i == 0 else 0)
|
||||
frags.append(
|
||||
ContextFragment(
|
||||
fragment_id=f"frag-{i:03d}",
|
||||
content=f"content-{i}" * max(1, tokens // 10),
|
||||
token_count=tokens,
|
||||
relevance=relevances[i % len(relevances)],
|
||||
source_decision_id=f"01HX{'A' * 22}{i}" if i < 3 else None,
|
||||
)
|
||||
)
|
||||
return frags
|
||||
|
||||
|
||||
def _make_plan_id() -> str:
|
||||
return "01HXAAAAAAAAAAAAAAAAAAAAAA"
|
||||
|
||||
|
||||
# --- Background ------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a skeleton compressor service")
|
||||
def step_create_service(context: Context) -> None:
|
||||
context.service = SkeletonCompressorService()
|
||||
|
||||
|
||||
# --- Fragment setup --------------------------------------------------------
|
||||
|
||||
|
||||
@given("context fragments with total tokens {total:d}")
|
||||
def step_fragments_total(context: Context, total: int) -> None:
|
||||
context.fragments = _make_fragments(total)
|
||||
|
||||
|
||||
@given("three fragments with equal relevance {rel:g}")
|
||||
def step_equal_relevance(context: Context, rel: float) -> None:
|
||||
context.fragments = [
|
||||
ContextFragment(
|
||||
fragment_id=f"frag-{chr(ord('c') - i)}",
|
||||
content="x" * 50,
|
||||
token_count=100,
|
||||
relevance=rel,
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
|
||||
@given("fragments with relevances 0.9, 0.3, and 0.7")
|
||||
def step_varied_relevances(context: Context) -> None:
|
||||
context.fragments = [
|
||||
ContextFragment(fragment_id="f-1", content="a", token_count=100, relevance=0.9),
|
||||
ContextFragment(fragment_id="f-2", content="b", token_count=100, relevance=0.3),
|
||||
ContextFragment(fragment_id="f-3", content="c", token_count=100, relevance=0.7),
|
||||
]
|
||||
|
||||
|
||||
@given("fragments with known decision IDs")
|
||||
def step_known_ids(context: Context) -> None:
|
||||
context.fragments = [
|
||||
ContextFragment(
|
||||
fragment_id="f-1",
|
||||
content="a",
|
||||
token_count=100,
|
||||
relevance=0.9,
|
||||
source_decision_id="01HXDECISION00000000000001",
|
||||
),
|
||||
ContextFragment(
|
||||
fragment_id="f-2",
|
||||
content="b",
|
||||
token_count=100,
|
||||
relevance=0.5,
|
||||
source_decision_id="01HXDECISION00000000000002",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@given("an empty fragment list")
|
||||
def step_empty_frags(context: Context) -> None:
|
||||
context.fragments = []
|
||||
|
||||
|
||||
@given("a single fragment with {tokens:d} tokens")
|
||||
def step_single_frag(context: Context, tokens: int) -> None:
|
||||
context.fragments = [
|
||||
ContextFragment(
|
||||
fragment_id="only",
|
||||
content="x" * tokens,
|
||||
token_count=tokens,
|
||||
relevance=0.8,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@given("a fragment with negative token count")
|
||||
def step_neg_tokens(context: Context) -> None:
|
||||
context.fragments = [
|
||||
ContextFragment(
|
||||
fragment_id="bad",
|
||||
content="x",
|
||||
token_count=-10,
|
||||
relevance=0.5,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@given("a fragment with empty fragment_id")
|
||||
def step_empty_id(context: Context) -> None:
|
||||
context.fragments = [
|
||||
ContextFragment(
|
||||
fragment_id="",
|
||||
content="x",
|
||||
token_count=10,
|
||||
relevance=0.5,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@given("a fragment with relevance {rel:g}")
|
||||
def step_bad_relevance(context: Context, rel: float) -> None:
|
||||
context.fragments = [
|
||||
ContextFragment(
|
||||
fragment_id="bad",
|
||||
content="x",
|
||||
token_count=10,
|
||||
relevance=rel,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@given(
|
||||
"a skeleton metadata with ratio {ratio:g} and {orig:d} original tokens and {comp:d} compressed"
|
||||
)
|
||||
def step_make_metadata(context: Context, ratio: float, orig: int, comp: int) -> None:
|
||||
context.skel_meta = SkeletonMetadata(
|
||||
ratio=ratio,
|
||||
original_tokens=orig,
|
||||
compressed_tokens=comp,
|
||||
source_decision_ids=("01HXDECISION00000000000001",),
|
||||
)
|
||||
|
||||
|
||||
# --- When clauses ----------------------------------------------------------
|
||||
|
||||
|
||||
@when("I compress with skeleton_ratio {ratio:g}")
|
||||
def step_compress_ratio(context: Context, ratio: float) -> None:
|
||||
try:
|
||||
context.result = context.service.compress(
|
||||
context.fragments, skeleton_ratio=ratio
|
||||
)
|
||||
context.error = None
|
||||
except (ValueError, TypeError) as exc:
|
||||
context.error = exc
|
||||
context.result = None
|
||||
|
||||
|
||||
@when("I compress with skeleton_ratio not specified")
|
||||
def step_compress_default(context: Context) -> None:
|
||||
context.result = context.service.compress(context.fragments)
|
||||
context.error = None
|
||||
|
||||
|
||||
@when("I compress with a non-list fragments argument")
|
||||
def step_compress_non_list(context: Context) -> None:
|
||||
try:
|
||||
context.result = context.service.compress("not-a-list", skeleton_ratio=0.5) # type: ignore[arg-type]
|
||||
context.error = None
|
||||
except TypeError as exc:
|
||||
context.error = exc
|
||||
context.result = None
|
||||
|
||||
|
||||
@when("I compress with a list containing a non-fragment item")
|
||||
def step_compress_non_fragment_item(context: Context) -> None:
|
||||
try:
|
||||
context.result = context.service.compress(
|
||||
[
|
||||
ContextFragment(
|
||||
fragment_id="ok", content="x", token_count=10, relevance=0.5
|
||||
),
|
||||
"not-a-fragment",
|
||||
], # type: ignore[list-item]
|
||||
skeleton_ratio=0.5,
|
||||
)
|
||||
context.error = None
|
||||
except TypeError as exc:
|
||||
context.error = exc
|
||||
context.result = None
|
||||
|
||||
|
||||
@when("I compress with a non-numeric skeleton_ratio")
|
||||
def step_compress_non_numeric_ratio(context: Context) -> None:
|
||||
try:
|
||||
context.result = context.service.compress(
|
||||
context.fragments,
|
||||
skeleton_ratio="bad", # type: ignore[arg-type]
|
||||
)
|
||||
context.error = None
|
||||
except TypeError as exc:
|
||||
context.error = exc
|
||||
context.result = None
|
||||
|
||||
|
||||
@when("I create skeleton metadata with compressed exceeding original")
|
||||
def step_create_bad_metadata(context: Context) -> None:
|
||||
try:
|
||||
context.bad_meta = SkeletonMetadata(
|
||||
ratio=0.5,
|
||||
original_tokens=100,
|
||||
compressed_tokens=200,
|
||||
)
|
||||
context.meta_error = None
|
||||
except Exception as exc:
|
||||
context.meta_error = exc
|
||||
context.bad_meta = None
|
||||
|
||||
|
||||
@when("I attach skeleton_metadata to a plan")
|
||||
def step_attach_to_plan(context: Context) -> None:
|
||||
context.plan = Plan(
|
||||
identity=PlanIdentity(plan_id=_make_plan_id()),
|
||||
namespaced_name=NamespacedName.parse("local/test-plan"),
|
||||
description="Test plan",
|
||||
action_name="local/test-action",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.QUEUED,
|
||||
skeleton_metadata=context.skel_meta,
|
||||
)
|
||||
|
||||
|
||||
# --- Then clauses ----------------------------------------------------------
|
||||
|
||||
|
||||
@then("the compressor should raise a ValueError for invalid ratio")
|
||||
def step_check_value_error_ratio(context: Context) -> None:
|
||||
assert context.error is not None, "Expected ValueError"
|
||||
assert isinstance(context.error, ValueError), (
|
||||
f"Expected ValueError, got {type(context.error)}"
|
||||
)
|
||||
|
||||
|
||||
@then("all fragments should be returned unchanged")
|
||||
def step_all_returned(context: Context) -> None:
|
||||
assert context.result is not None
|
||||
assert len(context.result.fragments) == len(context.fragments)
|
||||
|
||||
|
||||
@then("only the highest-relevance fragment should be returned")
|
||||
def step_top_one(context: Context) -> None:
|
||||
assert context.result is not None
|
||||
assert len(context.result.fragments) == 1
|
||||
top_relevance = max(f.relevance for f in context.fragments)
|
||||
assert context.result.fragments[0].relevance == top_relevance
|
||||
|
||||
|
||||
@then("compressed tokens should be at most {limit:d}")
|
||||
def step_tokens_limit(context: Context, limit: int) -> None:
|
||||
assert context.result is not None
|
||||
assert context.result.metadata.compressed_tokens <= limit
|
||||
|
||||
|
||||
@then("the metadata ratio should equal the default {expected:g}")
|
||||
def step_default_ratio(context: Context, expected: float) -> None:
|
||||
assert context.result is not None
|
||||
assert context.result.metadata.ratio == expected
|
||||
assert expected == DEFAULT_SKELETON_RATIO
|
||||
|
||||
|
||||
@then("fragments should be ordered by fragment_id ascending")
|
||||
def step_ordered_by_id(context: Context) -> None:
|
||||
assert context.result is not None
|
||||
ids = [f.fragment_id for f in context.result.fragments]
|
||||
assert ids == sorted(ids), f"Expected sorted IDs, got {ids}"
|
||||
|
||||
|
||||
@then("the first fragment should have relevance {rel:g}")
|
||||
def step_first_relevance(context: Context, rel: float) -> None:
|
||||
assert context.result is not None
|
||||
assert context.result.fragments[0].relevance == rel
|
||||
|
||||
|
||||
@then("the last fragment should have relevance {rel:g}")
|
||||
def step_last_relevance(context: Context, rel: float) -> None:
|
||||
assert context.result is not None
|
||||
assert context.result.fragments[-1].relevance == rel
|
||||
|
||||
|
||||
@then("metadata original_tokens should be {expected:d}")
|
||||
def step_original_tokens(context: Context, expected: int) -> None:
|
||||
assert context.result is not None
|
||||
assert context.result.metadata.original_tokens == expected
|
||||
|
||||
|
||||
@then("metadata compressed_tokens should equal {expected:d}")
|
||||
def step_compressed_equals(context: Context, expected: int) -> None:
|
||||
assert context.result is not None
|
||||
assert context.result.metadata.compressed_tokens == expected
|
||||
|
||||
|
||||
@then("metadata compressed_tokens should be at most {limit:d}")
|
||||
def step_compressed_at_most(context: Context, limit: int) -> None:
|
||||
assert context.result is not None
|
||||
assert context.result.metadata.compressed_tokens <= limit
|
||||
|
||||
|
||||
@then("metadata should contain all source decision IDs")
|
||||
def step_all_decision_ids(context: Context) -> None:
|
||||
assert context.result is not None
|
||||
expected_ids = {
|
||||
f.source_decision_id
|
||||
for f in context.fragments
|
||||
if f.source_decision_id is not None
|
||||
}
|
||||
actual_ids = set(context.result.metadata.source_decision_ids)
|
||||
assert expected_ids == actual_ids
|
||||
|
||||
|
||||
@then("metadata ratio should be {expected:g}")
|
||||
def step_ratio_value(context: Context, expected: float) -> None:
|
||||
assert context.result is not None
|
||||
assert context.result.metadata.ratio == expected
|
||||
|
||||
|
||||
@then("the result should contain zero fragments")
|
||||
def step_zero_frags(context: Context) -> None:
|
||||
assert context.result is not None
|
||||
assert len(context.result.fragments) == 0
|
||||
|
||||
|
||||
@then("the result should contain one fragment")
|
||||
def step_one_frag(context: Context) -> None:
|
||||
assert context.result is not None
|
||||
assert len(context.result.fragments) == 1
|
||||
|
||||
|
||||
@then("the compressor should raise a TypeError")
|
||||
def step_type_error(context: Context) -> None:
|
||||
assert context.error is not None, "Expected TypeError"
|
||||
assert isinstance(context.error, TypeError), (
|
||||
f"Expected TypeError, got {type(context.error)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the compressor should raise a ValueError for invalid fragment")
|
||||
def step_frag_value_error(context: Context) -> None:
|
||||
assert context.error is not None, "Expected ValueError"
|
||||
assert isinstance(context.error, ValueError), (
|
||||
f"Expected ValueError, got {type(context.error)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the compressor should raise a TypeError for invalid item")
|
||||
def step_type_error_item(context: Context) -> None:
|
||||
assert context.error is not None, "Expected TypeError"
|
||||
assert isinstance(context.error, TypeError), (
|
||||
f"Expected TypeError, got {type(context.error)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the compressor should raise a TypeError for invalid ratio type")
|
||||
def step_type_error_ratio_type(context: Context) -> None:
|
||||
assert context.error is not None, "Expected TypeError"
|
||||
assert isinstance(context.error, TypeError), (
|
||||
f"Expected TypeError, got {type(context.error)}"
|
||||
)
|
||||
|
||||
|
||||
@then("a validation error should be raised for compressed exceeding original")
|
||||
def step_validation_error_compressed(context: Context) -> None:
|
||||
assert context.meta_error is not None, "Expected validation error"
|
||||
|
||||
|
||||
@then("compressed_tokens should be less than original_tokens")
|
||||
def step_less_tokens(context: Context) -> None:
|
||||
assert context.result is not None
|
||||
assert (
|
||||
context.result.metadata.compressed_tokens
|
||||
< context.result.metadata.original_tokens
|
||||
)
|
||||
|
||||
|
||||
@then("the plan should expose skeleton metadata in cli dict")
|
||||
def step_plan_cli_dict(context: Context) -> None:
|
||||
cli_dict = context.plan.as_cli_dict()
|
||||
assert "skeleton" in cli_dict
|
||||
skel = cli_dict["skeleton"]
|
||||
assert skel["ratio"] == context.skel_meta.ratio
|
||||
assert skel["original_tokens"] == context.skel_meta.original_tokens
|
||||
assert skel["compressed_tokens"] == context.skel_meta.compressed_tokens
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Helper script for skeleton compressor Robot Framework tests.
|
||||
|
||||
Usage:
|
||||
python helper_skeleton_compressor.py compress <ratio>
|
||||
python helper_skeleton_compressor.py validate-ratio-bounds
|
||||
python helper_skeleton_compressor.py metadata-fields
|
||||
python helper_skeleton_compressor.py stable-ordering
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure source tree is importable
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.application.services.skeleton_compressor import ( # noqa: E402
|
||||
ContextFragment,
|
||||
SkeletonCompressorService,
|
||||
)
|
||||
|
||||
|
||||
def _sample_fragments() -> list[ContextFragment]:
|
||||
"""Build a repeatable set of sample fragments."""
|
||||
return [
|
||||
ContextFragment(
|
||||
fragment_id="frag-001",
|
||||
content="High relevance content " * 20,
|
||||
token_count=400,
|
||||
relevance=0.9,
|
||||
source_decision_id="01HXDECISION00000000000001",
|
||||
),
|
||||
ContextFragment(
|
||||
fragment_id="frag-002",
|
||||
content="Medium relevance content " * 15,
|
||||
token_count=300,
|
||||
relevance=0.6,
|
||||
source_decision_id="01HXDECISION00000000000002",
|
||||
),
|
||||
ContextFragment(
|
||||
fragment_id="frag-003",
|
||||
content="Low relevance content " * 15,
|
||||
token_count=300,
|
||||
relevance=0.3,
|
||||
source_decision_id="01HXDECISION00000000000003",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def cmd_compress(ratio_str: str) -> None:
|
||||
"""Compress sample fragments and print summary."""
|
||||
ratio = float(ratio_str)
|
||||
svc = SkeletonCompressorService()
|
||||
result = svc.compress(_sample_fragments(), skeleton_ratio=ratio)
|
||||
meta = result.metadata
|
||||
print(f"skeleton-compress-ok ratio={meta.ratio}")
|
||||
print(f"original_tokens={meta.original_tokens}")
|
||||
print(f"compressed_tokens={meta.compressed_tokens}")
|
||||
print(f"fragment_count={len(result.fragments)}")
|
||||
print(f"decision_ids={len(meta.source_decision_ids)}")
|
||||
|
||||
|
||||
def cmd_validate_ratio_bounds() -> None:
|
||||
"""Verify that out-of-range ratios raise ValueError."""
|
||||
svc = SkeletonCompressorService()
|
||||
frags = _sample_fragments()
|
||||
|
||||
for bad in (-0.1, 1.5, 2.0):
|
||||
try:
|
||||
svc.compress(frags, skeleton_ratio=bad)
|
||||
print(f"FAIL: ratio {bad} did not raise")
|
||||
sys.exit(1)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Valid bounds
|
||||
for good in (0.0, 0.5, 1.0):
|
||||
svc.compress(frags, skeleton_ratio=good)
|
||||
|
||||
print("skeleton-ratio-bounds-ok")
|
||||
|
||||
|
||||
def cmd_metadata_fields() -> None:
|
||||
"""Verify metadata fields are populated correctly."""
|
||||
svc = SkeletonCompressorService()
|
||||
result = svc.compress(_sample_fragments(), skeleton_ratio=0.5)
|
||||
meta = result.metadata
|
||||
|
||||
checks_passed = True
|
||||
|
||||
if meta.ratio != 0.5:
|
||||
print(f"FAIL: ratio={meta.ratio}")
|
||||
checks_passed = False
|
||||
if meta.original_tokens != 1000:
|
||||
print(f"FAIL: original_tokens={meta.original_tokens}")
|
||||
checks_passed = False
|
||||
if meta.compressed_tokens > 500:
|
||||
print(f"FAIL: compressed_tokens={meta.compressed_tokens} > 500")
|
||||
checks_passed = False
|
||||
if not meta.source_decision_ids:
|
||||
print("FAIL: no decision IDs")
|
||||
checks_passed = False
|
||||
|
||||
if checks_passed:
|
||||
print("skeleton-metadata-ok")
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_stable_ordering() -> None:
|
||||
"""Verify fragments come out in deterministic order."""
|
||||
svc = SkeletonCompressorService()
|
||||
frags = [
|
||||
ContextFragment(
|
||||
fragment_id=f"frag-{chr(ord('c') - i)}",
|
||||
content="x",
|
||||
token_count=10,
|
||||
relevance=0.5,
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
r1 = svc.compress(frags, skeleton_ratio=0.0)
|
||||
r2 = svc.compress(list(reversed(frags)), skeleton_ratio=0.0)
|
||||
|
||||
ids1 = [f.fragment_id for f in r1.fragments]
|
||||
ids2 = [f.fragment_id for f in r2.fragments]
|
||||
|
||||
if ids1 == ids2:
|
||||
print("skeleton-stable-ordering-ok")
|
||||
else:
|
||||
print(f"FAIL: {ids1} != {ids2}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Dispatch subcommand."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: helper_skeleton_compressor.py <command> [args]")
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "compress":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: compress <ratio>")
|
||||
sys.exit(1)
|
||||
cmd_compress(sys.argv[2])
|
||||
elif cmd == "validate-ratio-bounds":
|
||||
cmd_validate_ratio_bounds()
|
||||
elif cmd == "metadata-fields":
|
||||
cmd_metadata_fields()
|
||||
elif cmd == "stable-ordering":
|
||||
cmd_stable_ordering()
|
||||
else:
|
||||
print(f"Unknown command: {cmd}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for skeleton compressor service
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_skeleton_compressor.py
|
||||
|
||||
*** Test Cases ***
|
||||
Compress Fragments At Ratio 0.5
|
||||
[Documentation] Compress sample fragments at 50% ratio and verify output
|
||||
${result}= Run Process ${PYTHON} ${HELPER} compress 0.5 cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} skeleton-compress-ok
|
||||
|
||||
Compress Fragments At Ratio 0.0
|
||||
[Documentation] No compression — all fragments should survive
|
||||
${result}= Run Process ${PYTHON} ${HELPER} compress 0.0 cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} skeleton-compress-ok
|
||||
Should Contain ${result.stdout} fragment_count=3
|
||||
|
||||
Compress Fragments At Ratio 1.0
|
||||
[Documentation] Maximum compression — only top fragment should survive
|
||||
${result}= Run Process ${PYTHON} ${HELPER} compress 1.0 cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} skeleton-compress-ok
|
||||
Should Contain ${result.stdout} fragment_count=1
|
||||
|
||||
Validate Ratio Bounds
|
||||
[Documentation] Out-of-range ratios must raise ValueError
|
||||
${result}= Run Process ${PYTHON} ${HELPER} validate-ratio-bounds cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} skeleton-ratio-bounds-ok
|
||||
|
||||
Verify Metadata Fields
|
||||
[Documentation] Metadata should record ratio, tokens, and decision IDs
|
||||
${result}= Run Process ${PYTHON} ${HELPER} metadata-fields cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} skeleton-metadata-ok
|
||||
|
||||
Verify Stable Fragment Ordering
|
||||
[Documentation] Fragments with equal relevance must be ordered deterministically
|
||||
${result}= Run Process ${PYTHON} ${HELPER} stable-ordering cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} skeleton-stable-ordering-ok
|
||||
@@ -25,6 +25,9 @@ from cleveragents.application.services.project_service import ProjectService
|
||||
from cleveragents.application.services.resource_registry_service import (
|
||||
ResourceRegistryService,
|
||||
)
|
||||
from cleveragents.application.services.skeleton_compressor import (
|
||||
SkeletonCompressorService,
|
||||
)
|
||||
from cleveragents.application.services.subplan_service import SubplanService
|
||||
from cleveragents.application.services.vector_store_service import VectorStoreService
|
||||
from cleveragents.config.settings import Settings, get_settings
|
||||
@@ -296,6 +299,11 @@ class Container(containers.DeclarativeContainer):
|
||||
database_url=database_url,
|
||||
)
|
||||
|
||||
# Skeleton Compressor Service - stateless, Singleton is sufficient
|
||||
skeleton_compressor_service = providers.Singleton(
|
||||
SkeletonCompressorService,
|
||||
)
|
||||
|
||||
# Autonomy Guardrail Service - Singleton so all callers share state
|
||||
autonomy_guardrail_service = providers.Singleton(
|
||||
AutonomyGuardrailService,
|
||||
|
||||
@@ -57,6 +57,11 @@ from cleveragents.application.services.semantic_validation_service import (
|
||||
from cleveragents.application.services.session_service import (
|
||||
PersistentSessionService,
|
||||
)
|
||||
from cleveragents.application.services.skeleton_compressor import (
|
||||
CompressionResult,
|
||||
ContextFragment,
|
||||
SkeletonCompressorService,
|
||||
)
|
||||
from cleveragents.application.services.skill_registry_service import (
|
||||
SkillRegistryService,
|
||||
)
|
||||
@@ -106,9 +111,11 @@ __all__ = [
|
||||
"AttachmentScope",
|
||||
"AutonomyGuardrailService",
|
||||
"BrokenReferenceRule",
|
||||
"CompressionResult",
|
||||
"ConfigEntry",
|
||||
"ConfigLevel",
|
||||
"ConfigService",
|
||||
"ContextFragment",
|
||||
"CorrectionService",
|
||||
"DecisionService",
|
||||
"DefaultValidationRunner",
|
||||
@@ -133,6 +140,7 @@ __all__ = [
|
||||
"SemanticValidationRule",
|
||||
"SemanticValidationService",
|
||||
"SemanticValidationSeverity",
|
||||
"SkeletonCompressorService",
|
||||
"SkillRegistryService",
|
||||
"SpawnEntry",
|
||||
"SpawnMetadata",
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Skeleton compressor service for ACMS context inheritance.
|
||||
|
||||
The ``SkeletonCompressorService`` takes a collection of context
|
||||
fragments from a parent plan and produces a compressed representation
|
||||
suitable for propagation to child plans. The compression is governed
|
||||
by ``skeleton_ratio``:
|
||||
|
||||
- **0.0** — no compression; all fragments pass through unchanged.
|
||||
- **1.0** — maximum compression; only the single highest-relevance
|
||||
fragment is kept (with minimal content).
|
||||
|
||||
Fragments are sorted by ``relevance`` score in **descending** order
|
||||
(highest first). A stable secondary sort on ``fragment_id`` ensures
|
||||
deterministic output for equal-relevance fragments.
|
||||
|
||||
Based on ``docs/specification.md`` ACMS Skeleton section.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public data structures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContextFragment:
|
||||
"""A single fragment of context to be compressed.
|
||||
|
||||
Attributes:
|
||||
fragment_id: Stable identifier for this fragment.
|
||||
content: The textual content of the fragment.
|
||||
token_count: Number of tokens in ``content``.
|
||||
relevance: A score in [0.0, 1.0] indicating how relevant
|
||||
this fragment is (higher = more relevant).
|
||||
source_decision_id: Optional decision ULID that produced
|
||||
this fragment.
|
||||
"""
|
||||
|
||||
fragment_id: str
|
||||
content: str
|
||||
token_count: int
|
||||
relevance: float
|
||||
source_decision_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CompressionResult:
|
||||
"""Output of a skeleton compression pass.
|
||||
|
||||
Attributes:
|
||||
fragments: The compressed (filtered/truncated) fragments,
|
||||
ordered by relevance descending then fragment_id ascending.
|
||||
metadata: Auditable metadata for the compression pass.
|
||||
"""
|
||||
|
||||
fragments: tuple[ContextFragment, ...]
|
||||
metadata: SkeletonMetadata
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Default skeleton_ratio when the caller does not specify one.
|
||||
DEFAULT_SKELETON_RATIO: float = 0.3
|
||||
|
||||
|
||||
class SkeletonCompressorService:
|
||||
"""Compress context fragments for subplan context inheritance.
|
||||
|
||||
The service is stateless; all state lives in the arguments and
|
||||
the returned ``CompressionResult``.
|
||||
"""
|
||||
|
||||
def compress(
|
||||
self,
|
||||
fragments: list[ContextFragment],
|
||||
skeleton_ratio: float | None = None,
|
||||
) -> CompressionResult:
|
||||
"""Compress *fragments* according to *skeleton_ratio*.
|
||||
|
||||
Args:
|
||||
fragments: Context fragments to compress. Each must
|
||||
have ``token_count >= 0`` and ``relevance`` in
|
||||
``[0.0, 1.0]``.
|
||||
skeleton_ratio: Compression ratio in ``[0.0, 1.0]``.
|
||||
``None`` falls back to ``DEFAULT_SKELETON_RATIO``.
|
||||
|
||||
Returns:
|
||||
A ``CompressionResult`` containing the filtered fragments
|
||||
and associated ``SkeletonMetadata``.
|
||||
|
||||
Raises:
|
||||
ValueError: If *skeleton_ratio* is outside ``[0.0, 1.0]``
|
||||
or any fragment has invalid fields.
|
||||
TypeError: If *fragments* is not a list.
|
||||
"""
|
||||
# -- argument validation ------------------------------------------
|
||||
if not isinstance(fragments, list):
|
||||
raise TypeError(f"fragments must be a list, got {type(fragments).__name__}")
|
||||
|
||||
effective_ratio = (
|
||||
skeleton_ratio if skeleton_ratio is not None else DEFAULT_SKELETON_RATIO
|
||||
)
|
||||
|
||||
if not isinstance(effective_ratio, (int, float)):
|
||||
raise TypeError(
|
||||
f"skeleton_ratio must be a float, got {type(effective_ratio).__name__}"
|
||||
)
|
||||
|
||||
if effective_ratio < 0.0 or effective_ratio > 1.0:
|
||||
raise ValueError(
|
||||
f"skeleton_ratio must be in [0.0, 1.0], got {effective_ratio}"
|
||||
)
|
||||
|
||||
self._validate_fragments(fragments)
|
||||
|
||||
# -- compute totals -----------------------------------------------
|
||||
original_tokens = sum(f.token_count for f in fragments)
|
||||
|
||||
# -- stable sort: relevance desc, fragment_id asc -----------------
|
||||
sorted_fragments = sorted(
|
||||
fragments,
|
||||
key=lambda f: (-f.relevance, f.fragment_id),
|
||||
)
|
||||
|
||||
# -- select fragments within budget -------------------------------
|
||||
kept = self._select_fragments(sorted_fragments, effective_ratio)
|
||||
|
||||
compressed_tokens = sum(f.token_count for f in kept)
|
||||
|
||||
source_ids = tuple(
|
||||
f.source_decision_id for f in kept if f.source_decision_id is not None
|
||||
)
|
||||
|
||||
metadata = SkeletonMetadata(
|
||||
ratio=effective_ratio,
|
||||
original_tokens=original_tokens,
|
||||
compressed_tokens=compressed_tokens,
|
||||
source_decision_ids=source_ids,
|
||||
)
|
||||
|
||||
return CompressionResult(
|
||||
fragments=tuple(kept),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _validate_fragments(fragments: list[ContextFragment]) -> None:
|
||||
"""Validate every fragment in the list.
|
||||
|
||||
Raises:
|
||||
TypeError: If an element is not a ``ContextFragment``.
|
||||
ValueError: If a fragment has invalid field values.
|
||||
"""
|
||||
for idx, frag in enumerate(fragments):
|
||||
if not isinstance(frag, ContextFragment):
|
||||
raise TypeError(
|
||||
f"fragments[{idx}] must be a ContextFragment, "
|
||||
f"got {type(frag).__name__}"
|
||||
)
|
||||
if frag.token_count < 0:
|
||||
raise ValueError(
|
||||
f"fragments[{idx}].token_count must be >= 0, got {frag.token_count}"
|
||||
)
|
||||
if frag.relevance < 0.0 or frag.relevance > 1.0:
|
||||
raise ValueError(
|
||||
f"fragments[{idx}].relevance must be in [0.0, 1.0], "
|
||||
f"got {frag.relevance}"
|
||||
)
|
||||
if not frag.fragment_id:
|
||||
raise ValueError(f"fragments[{idx}].fragment_id must be non-empty")
|
||||
|
||||
@staticmethod
|
||||
def _select_fragments(
|
||||
sorted_fragments: list[ContextFragment],
|
||||
ratio: float,
|
||||
) -> list[ContextFragment]:
|
||||
"""Select which fragments to keep given the compression ratio.
|
||||
|
||||
When *ratio* is 0.0 every fragment is kept. When *ratio* is
|
||||
1.0 only the single highest-relevance fragment survives (or
|
||||
none if the input is empty). For intermediate values the
|
||||
token budget is ``original_tokens * (1 - ratio)``; fragments
|
||||
are added in relevance order until the budget is exhausted.
|
||||
"""
|
||||
if not sorted_fragments:
|
||||
return []
|
||||
|
||||
if ratio == 0.0:
|
||||
return list(sorted_fragments)
|
||||
|
||||
original_tokens = sum(f.token_count for f in sorted_fragments)
|
||||
|
||||
# Budget: fraction of tokens to *keep*
|
||||
budget = int(original_tokens * (1.0 - ratio))
|
||||
|
||||
# At maximum compression keep at most one fragment
|
||||
if ratio == 1.0:
|
||||
budget = 0
|
||||
|
||||
kept: list[ContextFragment] = []
|
||||
used = 0
|
||||
for frag in sorted_fragments:
|
||||
if used + frag.token_count > budget and kept:
|
||||
# Already have at least one fragment and budget exceeded
|
||||
break
|
||||
kept.append(frag)
|
||||
used += frag.token_count
|
||||
if used >= budget and budget > 0:
|
||||
break
|
||||
|
||||
# At ratio 1.0, keep exactly the top fragment
|
||||
if ratio == 1.0 and sorted_fragments:
|
||||
return [sorted_fragments[0]]
|
||||
|
||||
return kept
|
||||
@@ -215,6 +215,9 @@ from cleveragents.domain.models.core.session import (
|
||||
SessionTokenUsage,
|
||||
)
|
||||
|
||||
# Skeleton compression metadata (M6 — ACMS)
|
||||
from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata
|
||||
|
||||
# Skill domain models
|
||||
from cleveragents.domain.models.core.skill import (
|
||||
ResolvedToolEntry,
|
||||
@@ -376,6 +379,7 @@ __all__ = [
|
||||
"SessionService",
|
||||
"SessionServiceError",
|
||||
"SessionTokenUsage",
|
||||
"SkeletonMetadata",
|
||||
"Skill",
|
||||
"SkillAgentSource",
|
||||
"SkillCapabilitySummary",
|
||||
|
||||
@@ -64,6 +64,7 @@ from typing import Any, ClassVar
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from cleveragents.domain.models.core.cost_metadata import CostMetadata
|
||||
from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata
|
||||
|
||||
# ULID is 26 characters, all uppercase alphanumeric (Crockford's base32)
|
||||
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"
|
||||
@@ -648,6 +649,15 @@ class Plan(BaseModel):
|
||||
description="Token/cost tracking and budget exhaustion events",
|
||||
)
|
||||
|
||||
# Skeleton compression metadata (M6 — ACMS context inheritance)
|
||||
skeleton_metadata: SkeletonMetadata | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Metadata from skeleton compression pass used for "
|
||||
"subplan context inheritance auditability"
|
||||
),
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_by: str | None = Field(None, description="User/session that created plan")
|
||||
tags: list[str] = Field(default_factory=list, description="Tags for organization")
|
||||
@@ -885,6 +895,13 @@ class Plan(BaseModel):
|
||||
result["subplan_count"] = len(self.subplan_statuses)
|
||||
if self.cost_metadata is not None:
|
||||
result["cost"] = self.cost_metadata.as_display_dict()
|
||||
if self.skeleton_metadata is not None:
|
||||
result["skeleton"] = {
|
||||
"ratio": self.skeleton_metadata.ratio,
|
||||
"original_tokens": self.skeleton_metadata.original_tokens,
|
||||
"compressed_tokens": self.skeleton_metadata.compressed_tokens,
|
||||
"source_decision_ids": list(self.skeleton_metadata.source_decision_ids),
|
||||
}
|
||||
if self.last_completed_step >= 0:
|
||||
result["last_completed_step"] = self.last_completed_step
|
||||
if self.last_checkpoint_id:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Skeleton metadata model for ACMS context compression.
|
||||
|
||||
A ``SkeletonMetadata`` captures the auditable state of a skeleton
|
||||
compression pass: the ratio applied, token counts before and after
|
||||
compression, and the decision IDs whose context fragments were
|
||||
compressed. The model is frozen (immutable) so that once persisted
|
||||
it cannot be accidentally mutated.
|
||||
|
||||
Based on ``docs/specification.md`` ACMS Skeleton section.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class SkeletonMetadata(BaseModel):
|
||||
"""Immutable record of a skeleton compression pass.
|
||||
|
||||
Persisted alongside the plan to provide full auditability of
|
||||
context inheritance between parent and child plans.
|
||||
|
||||
Attributes:
|
||||
ratio: The skeleton_ratio applied (0.0 = no compression,
|
||||
1.0 = maximum compression).
|
||||
original_tokens: Total token count before compression.
|
||||
compressed_tokens: Total token count after compression.
|
||||
source_decision_ids: Decision ULIDs whose context fragments
|
||||
were included in the compression input.
|
||||
"""
|
||||
|
||||
ratio: float = Field(
|
||||
...,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description=(
|
||||
"Skeleton compression ratio applied "
|
||||
"(0.0 = no compression, 1.0 = maximum compression)"
|
||||
),
|
||||
)
|
||||
original_tokens: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
description="Total token count before compression",
|
||||
)
|
||||
compressed_tokens: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
description="Total token count after compression",
|
||||
)
|
||||
source_decision_ids: tuple[str, ...] = Field(
|
||||
default_factory=tuple,
|
||||
description=(
|
||||
"Decision ULIDs whose context fragments "
|
||||
"were included in the compression input"
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("compressed_tokens")
|
||||
@classmethod
|
||||
def compressed_not_greater_than_original(
|
||||
cls: type[SkeletonMetadata],
|
||||
v: int,
|
||||
info: object,
|
||||
) -> int:
|
||||
"""Ensure compressed tokens do not exceed original tokens."""
|
||||
# info.data contains already-validated fields at this point
|
||||
data = getattr(info, "data", {})
|
||||
original = data.get("original_tokens")
|
||||
if original is not None and v > original:
|
||||
raise ValueError(
|
||||
f"compressed_tokens ({v}) cannot exceed original_tokens ({original})"
|
||||
)
|
||||
return v
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True,
|
||||
str_strip_whitespace=True,
|
||||
)
|
||||
@@ -505,6 +505,19 @@ get_spawn_decisions # noqa: B018, F821
|
||||
build_spawn_entries # noqa: B018, F821
|
||||
validate_spawn # noqa: B018, F821
|
||||
|
||||
# Skeleton compressor — public API (M6 ACMS, issue #194)
|
||||
SkeletonMetadata # noqa: B018, F821
|
||||
SkeletonCompressorService # noqa: B018, F821
|
||||
ContextFragment # noqa: B018, F821
|
||||
CompressionResult # noqa: B018, F821
|
||||
DEFAULT_SKELETON_RATIO # noqa: B018, F821
|
||||
skeleton_compressor_service # noqa: B018, F821
|
||||
skeleton_metadata # noqa: B018, F821
|
||||
source_decision_ids # noqa: B018, F821
|
||||
compressed_tokens # noqa: B018, F821
|
||||
original_tokens # noqa: B018, F821
|
||||
fragment_id # noqa: B018, F821
|
||||
|
||||
# Server client stubs — public API for server mode (#201)
|
||||
ServerClient # noqa: B018, F821
|
||||
RemoteExecutionClient # noqa: B018, F821
|
||||
|
||||
Reference in New Issue
Block a user