Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 080ade25be | |||
| 48c0e0edf9 | |||
| 864bf04ca9 | |||
| 5b65969356 | |||
| dba238f3dc |
@@ -5,6 +5,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **ACMS budget enforcement for max_file_size and max_total_size constraints** (#9673, #9583):
|
||||
Implemented `BudgetEnforcer` in ``src/cleveragents/acms/budget_enforcement.py`` with three
|
||||
core dataclasses — ``BudgetEnforcer``, ``BudgetViolation``, and ``ContextFile``. The enforcer
|
||||
validates per-file size limits (``max_file_size``) and cumulative context budgets
|
||||
(``max_total_size``), gracefully excluding files that exceed constraints while tracking
|
||||
violations with clear, actionable error messages including filename, file size, and limit
|
||||
metadata. Full type annotations throughout, ruff linting compliant, BDD test coverage
|
||||
(11 Behave scenarios + Robot Framework integration) ensures correctness across boundary
|
||||
conditions, empty file handling, multi-byte UTF-8 measurement, and file ordering semantics.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cross-actor subgraph cycle detection reads actor_ref field** (#1431): Fixed
|
||||
|
||||
@@ -31,3 +31,4 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration.
|
||||
* HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch.
|
||||
* HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files.
|
||||
* HAL 9000 has contributed ACMS budget enforcement for per-file and cumulative size constraints (PR #9673 / issue #9583): implemented ``BudgetEnforcer``, ``BudgetViolation``, and ``ContextFile`` dataclasses in ``src/cleveragents/acms/budget_enforcement.py`` with full type annotations, ruff linting compliance, 11 BDD Behave scenarios, Robot Framework integration tests, and per-file exclusion + cumulative budget cutoff strategies for max_file_size and max_total_size limits.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
Feature: ACMS Budget Enforcement for max_file_size and max_total_size constraints
|
||||
|
||||
Background:
|
||||
Given a budget enforcer with max_file_size of 1000 bytes
|
||||
And a budget enforcer with max_total_size of 5000 bytes
|
||||
|
||||
Scenario: File within max_file_size limit is included
|
||||
When I add a file of 500 bytes to the context
|
||||
Then the file should be included in the assembled context
|
||||
And the total context size should be 500 bytes
|
||||
|
||||
Scenario: File exceeding max_file_size limit is excluded
|
||||
When I add a file of 1500 bytes to the context
|
||||
Then the file should be excluded from the assembled context
|
||||
And a budget violation warning should be generated for the file
|
||||
|
||||
Scenario: File at max_file_size boundary is included
|
||||
When I add a file of exactly 1000 bytes to the context
|
||||
Then the file should be included in the assembled context
|
||||
And the total context size should be 1000 bytes
|
||||
|
||||
Scenario: Multiple files within total budget are included
|
||||
When I add a file of 800 bytes to the context
|
||||
And I add a file of 900 bytes to the context
|
||||
And I add a file of 700 bytes to the context
|
||||
Then the total context size should be 2400 bytes
|
||||
And all three files should be included in the assembled context
|
||||
|
||||
Scenario: Files exceeding max_total_size are gracefully cut off
|
||||
When I add a file of 800 bytes to the context
|
||||
And I add a file of 800 bytes to the context
|
||||
And I add a file of 800 bytes to the context
|
||||
And I add a file of 800 bytes to the context
|
||||
And I add a file of 800 bytes to the context
|
||||
And I add a file of 800 bytes to the context
|
||||
And I add a file of 800 bytes to the context
|
||||
Then the total context size should not exceed 5000 bytes
|
||||
And a budget violation warning should be generated for exceeding max_total_size
|
||||
|
||||
Scenario: Budget violation produces clear error message
|
||||
When I add a file of 1500 bytes to the context
|
||||
Then a budget violation error should be generated
|
||||
And the error message should indicate the file size exceeds max_file_size
|
||||
And the error message should include the file size and the limit
|
||||
|
||||
Scenario: Cumulative budget tracking prevents overflow
|
||||
When I add a file of 500 bytes to the context
|
||||
And I add a file of 500 bytes to the context
|
||||
Then the total context size should be 1000 bytes
|
||||
And no budget violation should be generated
|
||||
|
||||
Scenario: Budget enforcement respects file ordering
|
||||
When I add files in order: 1000, 1000, 1000, 1000, 1000, 1000 bytes
|
||||
Then the first 5 files should be included
|
||||
And the total context size should be 5000 bytes
|
||||
And no additional files should be added beyond the budget
|
||||
|
||||
Scenario: Budget violation reporting includes file metadata
|
||||
When I add a file named "large_file.txt" of 1500 bytes to the context
|
||||
Then the budget violation should include the filename "large_file.txt"
|
||||
And the budget violation should include the file size 1500
|
||||
And the budget violation should include the limit 1000
|
||||
|
||||
Scenario: Empty files do not consume budget
|
||||
When I add an empty file to the context
|
||||
And I add a file of 500 bytes to the context
|
||||
Then the total context size should be 500 bytes
|
||||
And both files should be included in the assembled context
|
||||
|
||||
Scenario: Multi-byte UTF-8 content is measured by byte size not character count
|
||||
When I add a file with multi-byte UTF-8 content of 250 characters to the context
|
||||
Then the file should be excluded from the assembled context
|
||||
And a budget violation warning should be generated for the file
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Step implementations for ACMS budget enforcement feature tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.acms.budget_enforcement import BudgetEnforcer
|
||||
|
||||
|
||||
@given("a budget enforcer with max_file_size of {size:d} bytes")
|
||||
def step_create_budget_enforcer_with_max_file_size(
|
||||
context: object, size: int
|
||||
) -> None:
|
||||
"""Create a fresh budget enforcer with specified max_file_size."""
|
||||
context.budget_enforcer = BudgetEnforcer(max_file_size=size, max_total_size=10000)
|
||||
context.file_counter = 0
|
||||
|
||||
|
||||
@given("a budget enforcer with max_total_size of {size:d} bytes")
|
||||
def step_create_budget_enforcer_with_max_total_size(
|
||||
context: object, size: int
|
||||
) -> None:
|
||||
"""Update the budget enforcer's max_total_size.
|
||||
|
||||
Always reconstructs a fresh BudgetEnforcer unconditionally --
|
||||
the Background runs before each scenario and must reset state to
|
||||
prevent leakage between scenarios within the same feature file.
|
||||
The previously-set max_file_size from step_one determines the new
|
||||
enforcer's constraint since both Background steps run sequentially.
|
||||
"""
|
||||
_max_file_size: int
|
||||
if hasattr(context, "budget_enforcer") and context.budget_enforcer is not None:
|
||||
_max_file_size = context.budget_enforcer.max_file_size
|
||||
else:
|
||||
_max_file_size = 10000
|
||||
context.budget_enforcer = BudgetEnforcer(
|
||||
max_file_size=_max_file_size,
|
||||
max_total_size=size,
|
||||
)
|
||||
|
||||
|
||||
@when("I add a file of {size:d} bytes to the context")
|
||||
def step_add_file_of_size(context: object, size: int) -> None:
|
||||
"""Add a file of specified size to the context."""
|
||||
counter = getattr(context, "file_counter", 0)
|
||||
counter += 1
|
||||
context.file_counter = counter
|
||||
|
||||
filename = f"file_{counter}.txt"
|
||||
content = "x" * size
|
||||
context.budget_enforcer.add_file(filename, content)
|
||||
|
||||
|
||||
@when("I add a file of exactly {size:d} bytes to the context")
|
||||
def step_add_file_of_exact_size(context: object, size: int) -> None:
|
||||
"""Add a file of exactly specified size to the context."""
|
||||
step_add_file_of_size(context, size)
|
||||
|
||||
|
||||
@when("I add a file named {filename} of {size:d} bytes to the context")
|
||||
def step_add_named_file(
|
||||
context: object, filename: str, size: int
|
||||
) -> None:
|
||||
"""Add a named file of specified size to the context."""
|
||||
filename = filename.strip('"')
|
||||
content = "x" * size
|
||||
context.budget_enforcer.add_file(filename, content)
|
||||
|
||||
|
||||
@when("I add an empty file to the context")
|
||||
def step_add_empty_file(context: object) -> None:
|
||||
"""Add an empty file to the context."""
|
||||
counter = getattr(context, "file_counter", 0)
|
||||
counter += 1
|
||||
context.file_counter = counter
|
||||
|
||||
filename = f"empty_file_{counter}.txt"
|
||||
context.budget_enforcer.add_file(filename, "")
|
||||
|
||||
|
||||
@when("I add files in order: {sizes} bytes")
|
||||
def step_add_files_in_order(context: object, sizes: str) -> None:
|
||||
"""Add multiple files in order with specified sizes."""
|
||||
size_list = [int(s.strip()) for s in sizes.split(",")]
|
||||
for size in size_list:
|
||||
step_add_file_of_size(context, size)
|
||||
|
||||
|
||||
@when(
|
||||
"I add a file with multi-byte UTF-8 content of {char_count:d} characters"
|
||||
" to the context"
|
||||
)
|
||||
def step_add_multibyte_utf8_file(context: object, char_count: int) -> None:
|
||||
"""Add a file with multi-byte UTF-8 content (emoji: 4 bytes each).
|
||||
|
||||
Each emoji character is 4 bytes in UTF-8. To exceed max_file_size of
|
||||
1000 we use 251 emoji characters (251 x 4 = 1004 bytes > 1000).
|
||||
"""
|
||||
counter = getattr(context, "file_counter", 0)
|
||||
counter += 1
|
||||
context.file_counter = counter
|
||||
|
||||
filename = f"multibyte_{counter}.txt"
|
||||
emoji_char = "\U0001f600" # U+1F600 -- 4 bytes in UTF-8
|
||||
content = emoji_char * (char_count + 1)
|
||||
context.budget_enforcer.add_file(filename, content)
|
||||
|
||||
|
||||
@then(
|
||||
"the file should be included in the assembled context"
|
||||
)
|
||||
def step_file_should_be_included(context: object) -> None:
|
||||
"""Verify that the last added file was included."""
|
||||
files = context.budget_enforcer.get_included_files()
|
||||
assert len(files) > 0, "No files were included in the assembled context"
|
||||
|
||||
|
||||
@then(
|
||||
"the file should be excluded from the assembled context"
|
||||
)
|
||||
def step_file_should_be_excluded(context: object) -> None:
|
||||
"""Verify that the last added file was excluded."""
|
||||
violations = context.budget_enforcer.get_violations()
|
||||
assert len(violations) > 0, "No budget violations were recorded"
|
||||
|
||||
|
||||
@then("the total context size should be {size:d} bytes")
|
||||
def step_total_size_should_be(
|
||||
context: object, size: int
|
||||
) -> None:
|
||||
"""Verify the total context size."""
|
||||
actual_size = context.budget_enforcer.get_total_size()
|
||||
assert actual_size == size, f"Expected total size {size}, got {actual_size}"
|
||||
|
||||
|
||||
@then("the total context size should not exceed {size:d} bytes")
|
||||
def step_total_size_should_not_exceed(
|
||||
context: object, size: int
|
||||
) -> None:
|
||||
"""Verify the total context size does not exceed limit."""
|
||||
actual_size = context.budget_enforcer.get_total_size()
|
||||
assert (
|
||||
actual_size <= size
|
||||
), f"Total size {actual_size} exceeds limit {size}"
|
||||
|
||||
|
||||
@then("a budget violation warning should be generated for the file")
|
||||
def step_budget_violation_warning_generated(
|
||||
context: object,
|
||||
) -> None:
|
||||
"""Verify that a budget violation warning was generated."""
|
||||
violations = context.budget_enforcer.get_violations()
|
||||
assert len(violations) > 0, "No budget violations were recorded"
|
||||
|
||||
|
||||
@then(
|
||||
"a budget violation warning should be generated for exceeding max_total_size"
|
||||
)
|
||||
def step_budget_violation_for_total_size(context: object) -> None:
|
||||
"""Verify that a budget violation for max_total_size was generated."""
|
||||
violations = context.budget_enforcer.get_violations()
|
||||
total_size_violations = [
|
||||
v for v in violations if v.violation_type == "max_total_size"
|
||||
]
|
||||
assert (
|
||||
len(total_size_violations) > 0
|
||||
), "No max_total_size violations were recorded"
|
||||
|
||||
|
||||
@then("a budget violation error should be generated")
|
||||
def step_budget_violation_error_generated(
|
||||
context: object,
|
||||
) -> None:
|
||||
"""Verify that a budget violation error was generated."""
|
||||
violations = context.budget_enforcer.get_violations()
|
||||
assert len(violations) > 0, "No budget violations were recorded"
|
||||
|
||||
|
||||
@then(
|
||||
"the error message should indicate the file size exceeds max_file_size"
|
||||
)
|
||||
def step_error_message_indicates_file_size_exceeded(
|
||||
context: object,
|
||||
) -> None:
|
||||
"""Verify the error message indicates file size exceeded."""
|
||||
violations = context.budget_enforcer.get_violations()
|
||||
assert len(violations) > 0, "No violations found"
|
||||
violation = violations[-1]
|
||||
assert "exceeds" in violation.message.lower(), (
|
||||
f"Error message does not indicate 'exceeded': {violation.message}"
|
||||
)
|
||||
|
||||
|
||||
@then("the error message should include the file size and the limit")
|
||||
def step_error_message_includes_size_and_limit(
|
||||
context: object,
|
||||
) -> None:
|
||||
"""Verify the error message includes file size and limit."""
|
||||
violations = context.budget_enforcer.get_violations()
|
||||
assert len(violations) > 0, "No violations found"
|
||||
violation = violations[-1]
|
||||
assert violation.file_size is not None, (
|
||||
"File size not included in violation"
|
||||
)
|
||||
assert violation.limit is not None, (
|
||||
"Limit not included in violation"
|
||||
)
|
||||
|
||||
|
||||
@then("no budget violation should be generated")
|
||||
def step_no_budget_violation(context: object) -> None:
|
||||
"""Verify that no budget violations were generated."""
|
||||
violations = context.budget_enforcer.get_violations()
|
||||
assert len(violations) == 0, f"Unexpected violations: {violations}"
|
||||
|
||||
|
||||
@then("all three files should be included in the assembled context")
|
||||
def step_all_three_files_included(context: object) -> None:
|
||||
"""Verify that all three files were included."""
|
||||
files = context.budget_enforcer.get_included_files()
|
||||
assert len(files) == 3, f"Expected 3 files, got {len(files)}"
|
||||
|
||||
|
||||
@then("the first 5 files should be included")
|
||||
def step_first_five_files_included(context: object) -> None:
|
||||
"""Verify that the first five files were included."""
|
||||
files = context.budget_enforcer.get_included_files()
|
||||
assert len(files) == 5, f"Expected 5 files, got {len(files)}"
|
||||
|
||||
|
||||
@then("no additional files should be added beyond the budget")
|
||||
def step_no_additional_files_beyond_budget(context: object) -> None:
|
||||
"""Verify no additional files were added beyond budget."""
|
||||
violations = context.budget_enforcer.get_violations()
|
||||
assert len(violations) > 0, "Expected violations for files beyond budget"
|
||||
|
||||
|
||||
@then("the budget violation should include the filename {filename}")
|
||||
def step_violation_includes_filename(
|
||||
context: object, filename: str
|
||||
) -> None:
|
||||
"""Verify the violation includes the filename."""
|
||||
filename = filename.strip('"')
|
||||
violations = context.budget_enforcer.get_violations()
|
||||
assert len(violations) > 0, "No violations found"
|
||||
violation = violations[-1]
|
||||
assert violation.filename == filename, (
|
||||
f"Expected filename {filename}, got {violation.filename}"
|
||||
)
|
||||
|
||||
|
||||
@then("the budget violation should include the file size {size:d}")
|
||||
def step_violation_includes_file_size(
|
||||
context: object, size: int
|
||||
) -> None:
|
||||
"""Verify the violation includes the file size."""
|
||||
violations = context.budget_enforcer.get_violations()
|
||||
assert len(violations) > 0, "No violations found"
|
||||
violation = violations[-1]
|
||||
assert violation.file_size == size, (
|
||||
f"Expected file size {size}, got {violation.file_size}"
|
||||
)
|
||||
|
||||
|
||||
@then("the budget violation should include the limit {limit:d}")
|
||||
def step_violation_includes_limit(
|
||||
context: object, limit: int
|
||||
) -> None:
|
||||
"""Verify the violation includes the limit."""
|
||||
violations = context.budget_enforcer.get_violations()
|
||||
assert len(violations) > 0, "No violations found"
|
||||
violation = violations[-1]
|
||||
assert violation.limit == limit, f"Expected limit {limit}, got {violation.limit}"
|
||||
|
||||
|
||||
@then("both files should be included in the assembled context")
|
||||
def step_both_files_included(context: object) -> None:
|
||||
"""Verify that both files were included."""
|
||||
files = context.budget_enforcer.get_included_files()
|
||||
assert len(files) == 2, f"Expected 2 files, got {len(files)}"
|
||||
@@ -0,0 +1,67 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for ACMS BudgetEnforcer max_file_size and max_total_size constraints
|
||||
Resource ${CURDIR}/common.resource
|
||||
|
||||
*** Variables ***
|
||||
${HELPER_SCRIPT} robot/helper_acms_budget_enforcement.py
|
||||
|
||||
*** Test Cases ***
|
||||
Budget Enforcer Includes File Within Max File Size
|
||||
[Documentation] BudgetEnforcer includes a file within max_file_size limit
|
||||
${result}= Run Process python3 ${WORKSPACE}/${HELPER_SCRIPT} file-within-limit cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} file-within-limit-ok
|
||||
|
||||
Budget Enforcer Excludes File Exceeding Max File Size
|
||||
[Documentation] BudgetEnforcer excludes a file that exceeds max_file_size
|
||||
${result}= Run Process python3 ${WORKSPACE}/${HELPER_SCRIPT} file-exceeds-limit cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} file-exceeds-limit-ok
|
||||
|
||||
Budget Enforcer Enforces Max Total Size
|
||||
[Documentation] BudgetEnforcer cuts off files when max_total_size is reached
|
||||
${result}= Run Process python3 ${WORKSPACE}/${HELPER_SCRIPT} total-size-cutoff cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} total-size-cutoff-ok
|
||||
|
||||
Budget Enforcer Violation Message Is Clear
|
||||
[Documentation] BudgetViolation message clearly describes the constraint exceeded
|
||||
${result}= Run Process python3 ${WORKSPACE}/${HELPER_SCRIPT} violation-message cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} violation-message-ok
|
||||
|
||||
Budget Enforcer Returns Defensive Copies
|
||||
[Documentation] get_violations and get_included_files return defensive copies
|
||||
${result}= Run Process python3 ${WORKSPACE}/${HELPER_SCRIPT} defensive-copies cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} defensive-copies-ok
|
||||
|
||||
Budget Enforcer Validates Constructor Arguments
|
||||
[Documentation] BudgetEnforcer raises ValueError for invalid budget values
|
||||
${result}= Run Process python3 ${WORKSPACE}/${HELPER_SCRIPT} constructor-validation cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} constructor-validation-ok
|
||||
|
||||
Budget Enforcer Validates Add File Arguments
|
||||
[Documentation] add_file raises ValueError for empty name
|
||||
${result}= Run Process python3 ${WORKSPACE}/${HELPER_SCRIPT} add-file-validation cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} add-file-validation-ok
|
||||
|
||||
Budget Enforcer Handles Multi-Byte UTF-8 Content
|
||||
[Documentation] BudgetEnforcer measures file size in bytes not characters
|
||||
${result}= Run Process python3 ${WORKSPACE}/${HELPER_SCRIPT} multibyte-utf8 cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} multibyte-utf8-ok
|
||||
|
||||
Budget Enforcer Reset Clears State
|
||||
[Documentation] reset() clears all included files, violations, and total size
|
||||
${result}= Run Process python3 ${WORKSPACE}/${HELPER_SCRIPT} reset-state cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} reset-state-ok
|
||||
|
||||
Budget Enforcer Assembled Context Joins Files
|
||||
[Documentation] get_assembled_context joins included file contents with newlines
|
||||
${result}= Run Process python3 ${WORKSPACE}/${HELPER_SCRIPT} assembled-context cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} assembled-context-ok
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Helper script for Robot Framework ACMS budget enforcement integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
def _test_file_within_limit() -> None:
|
||||
"""BudgetEnforcer includes a file within max_file_size limit."""
|
||||
from cleveragents.acms.budget_enforcement import BudgetEnforcer
|
||||
|
||||
enforcer = BudgetEnforcer(max_file_size=1000, max_total_size=5000)
|
||||
content = "x" * 500
|
||||
included = enforcer.add_file("small.txt", content)
|
||||
assert included is True, "Expected file to be included"
|
||||
assert enforcer.get_total_size() == 500, (
|
||||
f"Expected total size 500, got {enforcer.get_total_size()}"
|
||||
)
|
||||
assert len(enforcer.get_included_files()) == 1, (
|
||||
f"Expected 1 included file, got {len(enforcer.get_included_files())}"
|
||||
)
|
||||
assert len(enforcer.get_violations()) == 0, (
|
||||
f"Expected no violations, got {enforcer.get_violations()}"
|
||||
)
|
||||
print("file-within-limit-ok")
|
||||
|
||||
|
||||
def _test_file_exceeds_limit() -> None:
|
||||
"""BudgetEnforcer excludes a file that exceeds max_file_size."""
|
||||
from cleveragents.acms.budget_enforcement import BudgetEnforcer
|
||||
|
||||
enforcer = BudgetEnforcer(max_file_size=1000, max_total_size=5000)
|
||||
content = "x" * 1500
|
||||
included = enforcer.add_file("large.txt", content)
|
||||
assert included is False, "Expected file to be excluded"
|
||||
assert enforcer.get_total_size() == 0, (
|
||||
f"Expected total size 0, got {enforcer.get_total_size()}"
|
||||
)
|
||||
assert len(enforcer.get_included_files()) == 0, (
|
||||
f"Expected 0 included files, got {len(enforcer.get_included_files())}"
|
||||
)
|
||||
violations = enforcer.get_violations()
|
||||
assert len(violations) == 1, f"Expected 1 violation, got {len(violations)}"
|
||||
assert violations[0].violation_type == "max_file_size", (
|
||||
f"Expected max_file_size violation, got {violations[0].violation_type}"
|
||||
)
|
||||
assert violations[0].filename == "large.txt", (
|
||||
f"Expected filename 'large.txt', got {violations[0].filename}"
|
||||
)
|
||||
print("file-exceeds-limit-ok")
|
||||
|
||||
|
||||
def _test_total_size_cutoff() -> None:
|
||||
"""BudgetEnforcer cuts off files when max_total_size is reached."""
|
||||
from cleveragents.acms.budget_enforcement import BudgetEnforcer
|
||||
|
||||
enforcer = BudgetEnforcer(max_file_size=1000, max_total_size=2500)
|
||||
assert enforcer.add_file("f1.txt", "x" * 1000) is True
|
||||
assert enforcer.add_file("f2.txt", "x" * 1000) is True
|
||||
assert enforcer.add_file("f3.txt", "x" * 1000) is False
|
||||
assert enforcer.get_total_size() == 2000, (
|
||||
f"Expected total size 2000, got {enforcer.get_total_size()}"
|
||||
)
|
||||
violations = enforcer.get_violations()
|
||||
total_violations = [
|
||||
v for v in violations if v.violation_type == "max_total_size"
|
||||
]
|
||||
assert len(total_violations) == 1, (
|
||||
f"Expected 1 max_total_size violation, got {len(total_violations)}"
|
||||
)
|
||||
print("total-size-cutoff-ok")
|
||||
|
||||
|
||||
def _test_violation_message() -> None:
|
||||
"""BudgetViolation message clearly describes the constraint exceeded."""
|
||||
from cleveragents.acms.budget_enforcement import BudgetEnforcer
|
||||
|
||||
enforcer = BudgetEnforcer(max_file_size=500, max_total_size=2000)
|
||||
enforcer.add_file("big.txt", "x" * 600)
|
||||
violations = enforcer.get_violations()
|
||||
assert len(violations) == 1
|
||||
msg = violations[0].message
|
||||
assert "exceeds" in msg.lower(), f"Message should contain 'exceeds': {msg}"
|
||||
assert "500" in msg, f"Message should contain limit '500': {msg}"
|
||||
assert "600" in msg, f"Message should contain file size '600': {msg}"
|
||||
assert "big.txt" in msg, f"Message should contain filename 'big.txt': {msg}"
|
||||
print("violation-message-ok")
|
||||
|
||||
|
||||
def _test_defensive_copies() -> None:
|
||||
"""get_violations and get_included_files return defensive copies."""
|
||||
from cleveragents.acms.budget_enforcement import BudgetEnforcer
|
||||
|
||||
enforcer = BudgetEnforcer(max_file_size=1000, max_total_size=5000)
|
||||
enforcer.add_file("f1.txt", "x" * 500)
|
||||
enforcer.add_file("too_big.txt", "x" * 1500)
|
||||
|
||||
violations = enforcer.get_violations()
|
||||
violations.clear()
|
||||
assert len(enforcer.get_violations()) == 1, (
|
||||
"Clearing returned violations list should not affect enforcer state"
|
||||
)
|
||||
|
||||
files = enforcer.get_included_files()
|
||||
files.clear()
|
||||
assert len(enforcer.get_included_files()) == 1, (
|
||||
"Clearing returned files list should not affect enforcer state"
|
||||
)
|
||||
print("defensive-copies-ok")
|
||||
|
||||
|
||||
def _test_constructor_validation() -> None:
|
||||
"""BudgetEnforcer raises ValueError for invalid budget values."""
|
||||
from cleveragents.acms.budget_enforcement import BudgetEnforcer
|
||||
|
||||
try:
|
||||
BudgetEnforcer(max_file_size=-1, max_total_size=5000)
|
||||
raise AssertionError("Expected ValueError for negative max_file_size")
|
||||
except ValueError as e:
|
||||
assert "max_file_size" in str(e), (
|
||||
f"Error should mention max_file_size: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
BudgetEnforcer(max_file_size=100, max_total_size=0)
|
||||
raise AssertionError("Expected ValueError for zero max_total_size")
|
||||
except ValueError as e:
|
||||
assert "max_total_size" in str(e), (
|
||||
f"Error should mention max_total_size: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
BudgetEnforcer(max_file_size=5000, max_total_size=1000)
|
||||
raise AssertionError("Expected ValueError for max_file_size > max_total_size")
|
||||
except ValueError as e:
|
||||
assert "cannot exceed" in str(e), f"Error should mention cannot exceed: {e}"
|
||||
|
||||
enforcer = BudgetEnforcer(max_file_size=1000, max_total_size=5000)
|
||||
assert enforcer.max_file_size == 1000
|
||||
assert enforcer.max_total_size == 5000
|
||||
print("constructor-validation-ok")
|
||||
|
||||
|
||||
def _test_add_file_validation() -> None:
|
||||
"""add_file raises ValueError for empty name."""
|
||||
from cleveragents.acms.budget_enforcement import BudgetEnforcer
|
||||
|
||||
enforcer = BudgetEnforcer(max_file_size=1000, max_total_size=5000)
|
||||
|
||||
try:
|
||||
enforcer.add_file("", "some content")
|
||||
raise AssertionError("Expected ValueError for empty name")
|
||||
except ValueError as e:
|
||||
assert "name" in str(e).lower(), f"Error should mention name: {e}"
|
||||
|
||||
try:
|
||||
enforcer.add_file("file.txt", object())
|
||||
raise AssertionError("Expected TypeError for non-string content")
|
||||
except TypeError as e:
|
||||
assert "content" in str(e).lower(), f"Error should mention content: {e}"
|
||||
|
||||
print("add-file-validation-ok")
|
||||
|
||||
|
||||
def _test_multibyte_utf8() -> None:
|
||||
"""BudgetEnforcer measures file size in bytes not characters."""
|
||||
from cleveragents.acms.budget_enforcement import BudgetEnforcer
|
||||
|
||||
enforcer = BudgetEnforcer(max_file_size=1000, max_total_size=5000)
|
||||
|
||||
emoji_char = "\U0001f600" # U+1F600 -- 4 bytes in UTF-8
|
||||
content = emoji_char * 251
|
||||
assert len(content.encode("utf-8")) == 1004, "Byte count should be 1004"
|
||||
|
||||
included = enforcer.add_file("emoji.txt", content)
|
||||
assert included is False, (
|
||||
"File with 1004 bytes should be excluded (exceeds 1000 byte limit)"
|
||||
)
|
||||
violations = enforcer.get_violations()
|
||||
assert len(violations) == 1
|
||||
assert violations[0].violation_type == "max_file_size"
|
||||
assert violations[0].file_size == 1004, (
|
||||
f"Violation should report byte size 1004, got {violations[0].file_size}"
|
||||
)
|
||||
|
||||
enforcer2 = BudgetEnforcer(max_file_size=1000, max_total_size=5000)
|
||||
content_boundary = emoji_char * 250
|
||||
assert len(content_boundary.encode("utf-8")) == 1000
|
||||
included2 = enforcer2.add_file("boundary.txt", content_boundary)
|
||||
assert included2 is True, (
|
||||
"File with exactly 1000 bytes should be included"
|
||||
)
|
||||
print("multibyte-utf8-ok")
|
||||
|
||||
|
||||
def _test_reset_state() -> None:
|
||||
"""reset() clears all included files, violations, and total size."""
|
||||
from cleveragents.acms.budget_enforcement import BudgetEnforcer
|
||||
|
||||
enforcer = BudgetEnforcer(max_file_size=1000, max_total_size=5000)
|
||||
enforcer.add_file("f1.txt", "x" * 500)
|
||||
enforcer.add_file("too_big.txt", "x" * 1500)
|
||||
|
||||
assert enforcer.get_total_size() == 500
|
||||
assert len(enforcer.get_included_files()) == 1
|
||||
assert len(enforcer.get_violations()) == 1
|
||||
|
||||
enforcer.reset()
|
||||
|
||||
assert enforcer.get_total_size() == 0, (
|
||||
f"After reset, total size should be 0, got {enforcer.get_total_size()}"
|
||||
)
|
||||
assert len(enforcer.get_included_files()) == 0, (
|
||||
"After reset, included files should be empty"
|
||||
)
|
||||
assert len(enforcer.get_violations()) == 0, (
|
||||
"After reset, violations should be empty"
|
||||
)
|
||||
|
||||
assert enforcer.add_file("new.txt", "y" * 300) is True
|
||||
assert enforcer.get_total_size() == 300
|
||||
print("reset-state-ok")
|
||||
|
||||
|
||||
def _test_assembled_context() -> None:
|
||||
"""get_assembled_context joins included file contents with newlines."""
|
||||
from cleveragents.acms.budget_enforcement import BudgetEnforcer
|
||||
|
||||
enforcer = BudgetEnforcer(max_file_size=1000, max_total_size=5000)
|
||||
enforcer.add_file("a.txt", "hello")
|
||||
enforcer.add_file("b.txt", "world")
|
||||
|
||||
context = enforcer.get_assembled_context()
|
||||
assert context == "hello\nworld", f"Expected 'hello\\nworld', got {context!r}"
|
||||
|
||||
enforcer2 = BudgetEnforcer(max_file_size=1000, max_total_size=5000)
|
||||
assert enforcer2.get_assembled_context() == "", (
|
||||
"Empty enforcer should return empty string"
|
||||
)
|
||||
print("assembled-context-ok")
|
||||
|
||||
|
||||
_TESTS: dict[str, Callable[[], None]] = {
|
||||
"file-within-limit": _test_file_within_limit,
|
||||
"file-exceeds-limit": _test_file_exceeds_limit,
|
||||
"total-size-cutoff": _test_total_size_cutoff,
|
||||
"violation-message": _test_violation_message,
|
||||
"defensive-copies": _test_defensive_copies,
|
||||
"constructor-validation": _test_constructor_validation,
|
||||
"add-file-validation": _test_add_file_validation,
|
||||
"multibyte-utf8": _test_multibyte_utf8,
|
||||
"reset-state": _test_reset_state,
|
||||
"assembled-context": _test_assembled_context,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in _TESTS:
|
||||
print(
|
||||
f"Usage: {sys.argv[0]} <{'|'.join(sorted(_TESTS))}>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
_TESTS[sys.argv[1]]()
|
||||
@@ -1,19 +1,17 @@
|
||||
"""ACMS (Advanced Context Management System) UKO vocabulary support.
|
||||
"""ACMS module.
|
||||
|
||||
Provides UKO Layer 2 paradigm vocabulary specializations, Layer 3
|
||||
technology-specific vocabulary extensions, and the DetailLevelMap
|
||||
inheritance mechanism for resolving named detail levels across the
|
||||
ontology hierarchy (Layer 3 -> Layer 2 -> Layer 1 -> Layer 0).
|
||||
|
||||
Also provides the ACMS index data model and file traversal engine for
|
||||
indexing large projects.
|
||||
|
||||
Based on ``docs/specification.md`` ~lines 42333-42422, 44405-44420.
|
||||
Combines UKO vocabulary support, ACMS index data model, and budget enforcement
|
||||
for max_file_size and max_total_size constraints on assembled context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cleveragents.acms import uko as _uko
|
||||
from cleveragents.acms.budget_enforcement import (
|
||||
BudgetEnforcer,
|
||||
BudgetViolation,
|
||||
ContextFile,
|
||||
)
|
||||
from cleveragents.acms.index import (
|
||||
ACMSIndex,
|
||||
FileTraversalEngine,
|
||||
@@ -72,9 +70,8 @@ from cleveragents.acms.uko import (
|
||||
resolve_detail_level,
|
||||
)
|
||||
|
||||
# Combine exports from both uko and index modules
|
||||
_uko_exports = list(_uko.__all__)
|
||||
_index_exports = [
|
||||
# Index model exports
|
||||
_index_exports: list[str] = [
|
||||
"ACMSIndex",
|
||||
"FileTraversalEngine",
|
||||
"FileType",
|
||||
@@ -82,4 +79,12 @@ _index_exports = [
|
||||
"TierLevel",
|
||||
]
|
||||
|
||||
__all__: list[str] = _uko_exports + _index_exports
|
||||
# Budget enforcement exports
|
||||
_budget_exports: list[str] = [
|
||||
"BudgetEnforcer",
|
||||
"BudgetViolation",
|
||||
"ContextFile",
|
||||
]
|
||||
|
||||
# Full __all__ includes uko symbols (already bound as explicit imports) + index + budget
|
||||
__all__: list[str] = list(_uko.__all__) + _index_exports + _budget_exports
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""ACMS Budget Enforcement for max_file_size and max_total_size constraints.
|
||||
|
||||
Provides budget enforcement mechanisms for the Advanced Context Management System (ACMS)
|
||||
to ensure that assembled context respects configured size constraints.
|
||||
|
||||
Based on issue #9583 and specification section on ACMS budget constraints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
_VALID_VIOLATION_TYPES: frozenset[str] = frozenset({"max_file_size", "max_total_size"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class BudgetViolation:
|
||||
"""Represents a budget constraint violation."""
|
||||
|
||||
violation_type: str # "max_file_size" or "max_total_size"
|
||||
filename: str | None = None
|
||||
file_size: int | None = None
|
||||
limit: int | None = None
|
||||
message: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate violation_type and generate message if not provided."""
|
||||
if self.violation_type not in _VALID_VIOLATION_TYPES:
|
||||
raise ValueError(
|
||||
f"Unknown violation_type: {self.violation_type!r}. "
|
||||
f"Must be one of: {sorted(_VALID_VIOLATION_TYPES)}"
|
||||
)
|
||||
if not self.message:
|
||||
if self.violation_type == "max_file_size":
|
||||
self.message = (
|
||||
f"File '{self.filename}' ({self.file_size} bytes) "
|
||||
f"exceeds max_file_size limit ({self.limit} bytes)"
|
||||
)
|
||||
elif self.violation_type == "max_total_size":
|
||||
self.message = (
|
||||
f"Total context size ({self.file_size} bytes) "
|
||||
f"exceeds max_total_size limit ({self.limit} bytes)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextFile:
|
||||
"""Represents a file in the assembled context."""
|
||||
|
||||
name: str
|
||||
content: str
|
||||
size: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Calculate size if not provided.
|
||||
|
||||
``None`` means "calculate for me"; ``0`` means "I know it is empty".
|
||||
"""
|
||||
if self.size is None:
|
||||
self.size = len(self.content.encode("utf-8"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class BudgetEnforcer:
|
||||
"""Enforces budget constraints on assembled context.
|
||||
|
||||
Attributes:
|
||||
max_file_size: Maximum size in bytes for individual files (must be positive)
|
||||
max_total_size: Maximum total size in bytes for assembled context (must be
|
||||
positive and >= max_file_size)
|
||||
"""
|
||||
|
||||
max_file_size: int
|
||||
max_total_size: int
|
||||
_files: list[ContextFile] = field(default_factory=list, init=False)
|
||||
_violations: list[BudgetViolation] = field(default_factory=list, init=False)
|
||||
_total_size: int = field(default=0, init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate budget parameters."""
|
||||
if self.max_file_size <= 0:
|
||||
raise ValueError(
|
||||
f"max_file_size must be positive, got {self.max_file_size}"
|
||||
)
|
||||
if self.max_total_size <= 0:
|
||||
raise ValueError(
|
||||
f"max_total_size must be positive, got {self.max_total_size}"
|
||||
)
|
||||
if self.max_file_size > self.max_total_size:
|
||||
raise ValueError(
|
||||
f"max_file_size ({self.max_file_size}) cannot exceed "
|
||||
f"max_total_size ({self.max_total_size})"
|
||||
)
|
||||
|
||||
def add_file(self, name: str, content: str) -> bool:
|
||||
"""Add a file to the context, respecting budget constraints.
|
||||
|
||||
Args:
|
||||
name: The filename (must be a non-empty string)
|
||||
content: The file content (must be a string)
|
||||
|
||||
Returns:
|
||||
True if the file was added, False if excluded due to budget
|
||||
constraints
|
||||
|
||||
Raises:
|
||||
ValueError: If name is empty
|
||||
TypeError: If content is not a string
|
||||
"""
|
||||
if not name:
|
||||
raise ValueError("name must be a non-empty string")
|
||||
if not isinstance(content, str):
|
||||
raise TypeError(f"content must be a string, not {type(content).__name__}")
|
||||
|
||||
file_obj = ContextFile(name=name, content=content)
|
||||
|
||||
# Check max_file_size constraint
|
||||
assert file_obj.size is not None # always set by __post_init__
|
||||
if file_obj.size > self.max_file_size:
|
||||
violation = BudgetViolation(
|
||||
violation_type="max_file_size",
|
||||
filename=name,
|
||||
file_size=file_obj.size,
|
||||
limit=self.max_file_size,
|
||||
)
|
||||
self._violations.append(violation)
|
||||
return False
|
||||
|
||||
# Check max_total_size constraint
|
||||
if self._total_size + file_obj.size > self.max_total_size:
|
||||
violation = BudgetViolation(
|
||||
violation_type="max_total_size",
|
||||
filename=name,
|
||||
file_size=self._total_size + file_obj.size,
|
||||
limit=self.max_total_size,
|
||||
)
|
||||
self._violations.append(violation)
|
||||
return False
|
||||
|
||||
# File is within budget, add it
|
||||
self._files.append(file_obj)
|
||||
self._total_size += file_obj.size
|
||||
return True
|
||||
|
||||
def get_assembled_context(self) -> str:
|
||||
"""Get the assembled context from all included files.
|
||||
|
||||
Returns:
|
||||
The assembled context as a single string with files joined by
|
||||
newlines. Note: ``get_total_size()`` tracks the sum of individual
|
||||
file byte sizes and does not include the ``\\n`` separator bytes
|
||||
between files.
|
||||
"""
|
||||
return "\n".join(f.content for f in self._files)
|
||||
|
||||
def get_total_size(self) -> int:
|
||||
"""Get the total size of the assembled context in bytes.
|
||||
|
||||
Returns:
|
||||
The sum of individual file byte sizes (excludes separator bytes
|
||||
used by ``get_assembled_context()``).
|
||||
"""
|
||||
return self._total_size
|
||||
|
||||
def get_violations(self) -> list[BudgetViolation]:
|
||||
"""Get all budget violations encountered.
|
||||
|
||||
Returns:
|
||||
A defensive copy of the list of BudgetViolation objects.
|
||||
"""
|
||||
return list(self._violations)
|
||||
|
||||
def get_included_files(self) -> list[ContextFile]:
|
||||
"""Get all files included in the assembled context.
|
||||
|
||||
Returns:
|
||||
A defensive copy of the list of ContextFile objects.
|
||||
"""
|
||||
return list(self._files)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the enforcer to its initial state."""
|
||||
self._files.clear()
|
||||
self._violations.clear()
|
||||
self._total_size = 0
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BudgetEnforcer",
|
||||
"BudgetViolation",
|
||||
"ContextFile",
|
||||
]
|
||||
Reference in New Issue
Block a user