diff --git a/CHANGELOG.md b/CHANGELOG.md index 672c4835e..c7b0c80e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,16 @@ Changed `wf10_batch.robot` to be less likely to create files, and - **Plan Rollback Command** (#8557): Implemented `agents plan rollback []` for checkpoint-based plan state restoration in Epic #8493. The command restores a plan's sandbox to the state captured at a given checkpoint, discarding all decisions made after that checkpoint. The checkpoint can be specified as an optional positional second argument or via the `--to-checkpoint` named option. Supports `--yes/-y` flag to skip confirmation prompts and `--format/-f` for output format selection (rich/plain/json/yaml). Included with comprehensive BDD test coverage (>= 97%) and spec-aligned output formatting showing rollback summary, changes reverted, impact analysis, and post-rollback state panels. ### Fixed +- **ACMS execute-phase assembler respects project-level hot_max_tokens** (#11035): Fixed + ``_resolve_hot_max_tokens()`` to read ``hot_max_tokens`` from + ``context_policy_json["acms_config"]["hot_max_tokens"]`` — the correct sub-key written + by ``agents project context set --hot-max-tokens``. The previous implementation read + from the top-level key (``config_dict.get("hot_max_tokens")``), which was always + ``None``, causing the assembler to silently fall back to the global 16K default even + when a project-level override was configured. Also adds two Behave regression scenarios + with ``@tdd_issue @tdd_issue_11035`` tags that exercise the DB query code path and + verify the project-level budget is applied to ``CoreContextBudget`` and + ``ContextRequest``. - **Guard cleanup_stale against execute/processing and execute/complete plans** (#11121): ``_create_sandbox_for_plan()`` in ``src/cleveragents/cli/commands/plan.py`` now skips ``GitWorktreeSandbox.cleanup_stale()`` when the plan is in diff --git a/features/execute_phase_context_assembler_coverage.feature b/features/execute_phase_context_assembler_coverage.feature index d4f95a5ba..f9f26069d 100644 --- a/features/execute_phase_context_assembler_coverage.feature +++ b/features/execute_phase_context_assembler_coverage.feature @@ -235,3 +235,21 @@ Feature: Execute-phase context assembler coverage When epcov I call assemble on the plan Then epcov the assembled result should be an AssembledContext And epcov the assembled result should have fragments and metadata + + # ---- _resolve_hot_max_tokens: reads context_policy_json from DB ---- + + @tdd_issue @tdd_issue_11035 + Scenario: epcov assemble uses project-level hot_max_tokens from context_policy_json + Given epcov an assembler with context_policy_json hot_max_tokens 32000 + And epcov a plan with project links + And epcov scoped fragments that pass all filters + When epcov I call assemble on the plan + Then epcov the pipeline received budget max_tokens of 32000 + + @tdd_issue @tdd_issue_11035 + Scenario: epcov assemble falls back to global hot_max_tokens when context_policy_json has no override + Given epcov an assembler with no hot_max_tokens in context_policy_json + And epcov a plan with project links + And epcov scoped fragments that pass all filters + When epcov I call assemble on the plan + Then epcov the pipeline received budget max_tokens of 4096 diff --git a/features/steps/execute_phase_context_assembler_coverage_steps.py b/features/steps/execute_phase_context_assembler_coverage_steps.py index fd8303acd..53a0ddf2d 100644 --- a/features/steps/execute_phase_context_assembler_coverage_steps.py +++ b/features/steps/execute_phase_context_assembler_coverage_steps.py @@ -12,6 +12,7 @@ Exercises all uncovered lines in from __future__ import annotations +import json from typing import Any from unittest.mock import MagicMock, patch @@ -937,3 +938,112 @@ def step_epcov_assembled_has_data(context: Context) -> None: assert result.budget_used >= 0.0, "Expected non-negative budget_used" assert result.context_hash, "Expected non-empty context_hash" assert result.strategies_used, "Expected non-empty strategies_used" + + +# --------------------------------------------------------------------------- +# _resolve_hot_max_tokens: reads hot_max_tokens from context_policy_json +# --------------------------------------------------------------------------- + + +def _make_assembler_with_policy_json( + policy_json: str | None, +) -> ACMSExecutePhaseContextAssembler: + """Build an assembler whose DB session returns a row with *policy_json*.""" + pr = _make_pipeline_result() + pr.fragments = () + pr.total_tokens = 5 + pr.budget_used = 0.1 + pr.strategies_used = ("relevance",) + pr.context_hash = "hash-policy" + pr.preamble = None + pr.provenance_map = {} + + mock_pipeline = MagicMock() + mock_pipeline.assemble.return_value = pr + + tier_service = MagicMock() + # Return a minimal passing fragment so assemble() does not short-circuit + frag = _make_tiered_fragment( + fragment_id="frag-policy", + content="content", + token_count=5, + metadata={"path": "src/good.py"}, + ) + tier_service.get_scoped_view.return_value = [frag] + + # Build a mock row with context_policy_json + mock_row = MagicMock() + mock_row.context_policy_json = policy_json + + # Mock the session so _resolve_hot_max_tokens can query it + mock_session = MagicMock() + mock_session.query.return_value.filter_by.return_value.first.return_value = mock_row + + repo = MagicMock() + repo._session.return_value = mock_session + repo.get_context_policy.return_value = ProjectContextPolicy() + + return ACMSExecutePhaseContextAssembler( + context_tier_service=tier_service, + project_repository=repo, + acms_pipeline=mock_pipeline, + hot_max_tokens=4096, + ) + + +@given("epcov an assembler with context_policy_json hot_max_tokens 32000") +def step_epcov_assembler_policy_json_32k(context: Context) -> None: + """Assembler backed by a DB row with hot_max_tokens=32000 in policy JSON. + + Mirrors the real storage format written by + ``agents project context set --hot-max-tokens 32000``: + ``{"acms_config": {"hot_max_tokens": 32000, ...}, ...}``. + """ + policy_json = json.dumps({"acms_config": {"hot_max_tokens": 32000}}) + context.epcov_assembler = _make_assembler_with_policy_json(policy_json) + + +@given("epcov an assembler with no hot_max_tokens in context_policy_json") +def step_epcov_assembler_policy_json_no_override(context: Context) -> None: + """Assembler backed by a DB row with no hot_max_tokens — global fallback.""" + policy_json = json.dumps({"acms_config": {"other_setting": "value"}}) + context.epcov_assembler = _make_assembler_with_policy_json(policy_json) + + +@then("epcov the pipeline received budget max_tokens of 32000") +def step_epcov_pipeline_budget_32k(context: Context) -> None: + """Verify the pipeline was called with max_tokens=32000 in the budget.""" + if context.epcov_error is not None: + raise AssertionError(f"Unexpected error: {context.epcov_error}") + pipeline = context.epcov_assembler._pipeline + assert pipeline.assemble.called, "Pipeline.assemble() was not called" + call_args = pipeline.assemble.call_args + budget = call_args.kwargs.get("budget") or ( + call_args[1].get("budget") if call_args[1] else None + ) + if budget is None and call_args[0]: + budget = call_args[0][2] if len(call_args[0]) > 2 else None + assert budget is not None, "Could not find budget in pipeline.assemble() call" + assert budget.max_tokens == 32000, ( + f"Expected budget.max_tokens=32000 (from context_policy_json), " + f"got {budget.max_tokens}" + ) + + +@then("epcov the pipeline received budget max_tokens of 4096") +def step_epcov_pipeline_budget_global(context: Context) -> None: + """Verify the pipeline fell back to the global hot_max_tokens=4096.""" + if context.epcov_error is not None: + raise AssertionError(f"Unexpected error: {context.epcov_error}") + pipeline = context.epcov_assembler._pipeline + assert pipeline.assemble.called, "Pipeline.assemble() was not called" + call_args = pipeline.assemble.call_args + budget = call_args.kwargs.get("budget") or ( + call_args[1].get("budget") if call_args[1] else None + ) + if budget is None and call_args[0]: + budget = call_args[0][2] if len(call_args[0]) > 2 else None + assert budget is not None, "Could not find budget in pipeline.assemble() call" + assert budget.max_tokens == 4096, ( + f"Expected budget.max_tokens=4096 (global fallback), got {budget.max_tokens}" + ) diff --git a/src/cleveragents/application/services/execute_phase_context_assembler.py b/src/cleveragents/application/services/execute_phase_context_assembler.py index d0dc746af..da2af9895 100644 --- a/src/cleveragents/application/services/execute_phase_context_assembler.py +++ b/src/cleveragents/application/services/execute_phase_context_assembler.py @@ -116,7 +116,10 @@ class ACMSExecutePhaseContextAssembler(ExecutePhaseContextAssembler): if row is not None and row.context_policy_json is not None: try: config_dict = json.loads(cast(str, row.context_policy_json)) - tokens = config_dict.get("hot_max_tokens") + # hot_max_tokens is stored under the "acms_config" sub-key + # by agents project context set. + acms = config_dict.get("acms_config") or {} + tokens = acms.get("hot_max_tokens") if tokens is not None and isinstance(tokens, int) and tokens > 0: candidates.append(tokens) except (ValueError, TypeError):