feat(acms): add skeleton compressor #525

Merged
freemo merged 1 commits from feature/m6-acms-skeleton-compress into master 2026-03-03 03:41:13 +00:00
14 changed files with 1331 additions and 0 deletions
+5
View File
@@ -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.01.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
+97
View File
@@ -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)
+109
View File
@@ -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.
+134
View File
@@ -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
+419
View File
@@ -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
+163
View File
@@ -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()
+49
View File
@@ -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,
)
+13
View File
@@ -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