From f0442e835dc8154911459fa91c67e3ba98ea022b Mon Sep 17 00:00:00 2001 From: Aditya Chhabra Date: Thu, 26 Mar 2026 09:54:30 +0000 Subject: [PATCH] feat(acms): plan execution leverages ACMS context for LLM calls Integrate ACMS execute-phase context assembly into LLMExecuteActor and inject assembled context into execute prompts with resilient fallback when assembly fails or returns empty output. Wire the plan CLI executor to use an ACMS-backed execute context assembler, add Behave coverage for context injection/fallback/empty context, and extend Robot M5 verification helpers to assert execute-phase ACMS context usage. ISSUES CLOSED: #850 --- features/llm_actors_coverage.feature | 21 ++ features/steps/llm_actors_coverage_steps.py | 106 +++++++- robot/helper_m5_e2e_verification.py | 116 +++++++++ robot/m5_e2e_verification.robot | 11 + .../execute_phase_context_assembler.py | 241 ++++++++++++++++++ .../application/services/llm_actors.py | 55 +++- src/cleveragents/cli/commands/plan.py | 9 + .../infrastructure/database/repositories.py | 37 +++ 8 files changed, 590 insertions(+), 6 deletions(-) create mode 100644 src/cleveragents/application/services/execute_phase_context_assembler.py diff --git a/features/llm_actors_coverage.feature b/features/llm_actors_coverage.feature index 36df502a..3051c19a 100644 --- a/features/llm_actors_coverage.feature +++ b/features/llm_actors_coverage.feature @@ -167,6 +167,27 @@ Feature: LLM Actors Coverage When I call execute actor with plan_id "EXEC05" and no stream callback Then the execute result should have zero entries + Scenario: LLMExecuteActor injects assembled execute-phase context into prompt + Given a valid LLMExecuteActor with assembled execute-phase context + And the LLM returns file blocks for execute + When I call execute actor with plan_id "EXEC06" and no stream callback + Then the execute prompt should contain "ACMS Execute-Phase Context" + And the execute prompt should contain "src/main.py" + + Scenario: LLMExecuteActor falls back when context assembly fails + Given a valid LLMExecuteActor with failing context assembly + And the LLM returns file blocks for execute + When I call execute actor with plan_id "EXEC07" and no stream callback + Then the execute result should contain a changeset + And the execute prompt should not contain "ACMS Execute-Phase Context" + + Scenario: LLMExecuteActor handles empty assembled context + Given a valid LLMExecuteActor with empty assembled context + And the LLM returns file blocks for execute + When I call execute actor with plan_id "EXEC08" and no stream callback + Then the execute result should contain a changeset + And the execute prompt should not contain "ACMS Execute-Phase Context" + # --------------------------------------------------------------- # LLMExecuteActor._parse_file_blocks # --------------------------------------------------------------- diff --git a/features/steps/llm_actors_coverage_steps.py b/features/steps/llm_actors_coverage_steps.py index f0eefdbe..05f1597d 100644 --- a/features/steps/llm_actors_coverage_steps.py +++ b/features/steps/llm_actors_coverage_steps.py @@ -30,6 +30,11 @@ from cleveragents.application.services.plan_executor import ( StrategyDecision, ) from cleveragents.core.exceptions import ValidationError +from cleveragents.domain.models.acms.crp import ( + AssembledContext, + ContextFragment, + FragmentProvenance, +) from cleveragents.domain.models.core.plan import InvariantSource, PlanInvariant # --------------------------------------------------------------------------- @@ -72,7 +77,8 @@ def _make_mock_registry(llm_response): """Create a mock ProviderRegistry that returns a mock LLM.""" mock_llm = MagicMock() mock_llm.invoke.return_value = llm_response - registry = SimpleNamespace(create_llm=MagicMock(return_value=mock_llm)) + registry = MagicMock() + registry.create_llm.return_value = mock_llm return registry @@ -108,6 +114,19 @@ def _sample_decisions(): ] +class _StubContextAssembler: + """Simple execute-context assembler test double.""" + + def __init__(self, assembled: AssembledContext | None = None, fail: bool = False): + self._assembled = assembled + self._fail = fail + + def assemble(self, plan): + if self._fail: + raise RuntimeError("context assembly failed") + return self._assembled + + LLM_NUMBERED_RESPONSE = "1. Create the module\n2. Write unit tests\n3. Update docs" LLM_FILE_BLOCKS_RESPONSE = ( @@ -234,9 +253,8 @@ def step_llm_returns_no_content(context): response = _make_mock_llm_response_no_content("1. Fallback step text") mock_llm = MagicMock() mock_llm.invoke.return_value = response - context.mock_registry = SimpleNamespace( - create_llm=MagicMock(return_value=mock_llm), - ) + context.mock_registry = MagicMock() + context.mock_registry.create_llm.return_value = mock_llm context.mock_lifecycle = _make_mock_lifecycle(strategy_actor="openai/gpt-4") context.strategize_actor = LLMStrategizeActor( provider_registry=context.mock_registry, @@ -450,14 +468,76 @@ def step_setup_valid_execute_actor(context): context.sandbox_dir = None +@given("a valid LLMExecuteActor with assembled execute-phase context") +def step_setup_execute_actor_with_context(context): + llm_response = _make_mock_llm_response(LLM_FILE_BLOCKS_RESPONSE) + context.mock_registry = _make_mock_registry(llm_response) + context.mock_lifecycle = _make_mock_lifecycle(execution_actor="openai/gpt-4") + assembled = AssembledContext( + fragments=( + ContextFragment( + uko_node="resource://src/main.py", + content="def run():\n return 'ok'", + detail_depth=2, + token_count=12, + relevance_score=0.9, + provenance=FragmentProvenance( + resource_uri="local/large-repo", + location="src/main.py", + strategy="execute_phase_context", + ), + ), + ), + total_tokens=12, + budget_used=0.1, + strategies_used=("relevance",), + context_hash="ctxhash123", + preamble="execute phase context", + provenance_map={}, + ) + context.execute_actor = LLMExecuteActor( + provider_registry=context.mock_registry, + lifecycle_service=context.mock_lifecycle, + context_assembler=_StubContextAssembler(assembled=assembled), + ) + + +@given("a valid LLMExecuteActor with failing context assembly") +def step_setup_execute_actor_with_failing_context(context): + llm_response = _make_mock_llm_response(LLM_FILE_BLOCKS_RESPONSE) + context.mock_registry = _make_mock_registry(llm_response) + context.mock_lifecycle = _make_mock_lifecycle(execution_actor="openai/gpt-4") + context.execute_actor = LLMExecuteActor( + provider_registry=context.mock_registry, + lifecycle_service=context.mock_lifecycle, + context_assembler=_StubContextAssembler(fail=True), + ) + + +@given("a valid LLMExecuteActor with empty assembled context") +def step_setup_execute_actor_with_empty_context(context): + llm_response = _make_mock_llm_response(LLM_FILE_BLOCKS_RESPONSE) + context.mock_registry = _make_mock_registry(llm_response) + context.mock_lifecycle = _make_mock_lifecycle(execution_actor="openai/gpt-4") + context.execute_actor = LLMExecuteActor( + provider_registry=context.mock_registry, + lifecycle_service=context.mock_lifecycle, + context_assembler=_StubContextAssembler(assembled=None), + ) + + @given("the LLM returns file blocks for execute") def step_llm_returns_file_blocks(context): llm_response = _make_mock_llm_response(LLM_FILE_BLOCKS_RESPONSE) context.mock_registry = _make_mock_registry(llm_response) context.mock_lifecycle = _make_mock_lifecycle(execution_actor="anthropic/claude-3") + context_assembler = None + if hasattr(context, "execute_actor") and context.execute_actor is not None: + context_assembler = getattr(context.execute_actor, "_context_assembler", None) context.execute_actor = LLMExecuteActor( provider_registry=context.mock_registry, lifecycle_service=context.mock_lifecycle, + context_assembler=context_assembler, ) @@ -502,6 +582,10 @@ def step_execute_actor_no_callback(context, pid): decisions=_sample_decisions(), stream_callback=None, ) + llm = context.mock_registry.create_llm.return_value + call_args = llm.invoke.call_args + assert call_args is not None + context.execute_prompt_text = call_args.args[0][0].content @when('I call execute actor with plan_id "{pid}" and a sandbox root') @@ -576,6 +660,20 @@ def step_verify_zero_entries(context): assert context.execute_result.tool_calls_count == 0 +@then('the execute prompt should contain "{needle}"') +def step_execute_prompt_contains(context, needle): + assert needle in context.execute_prompt_text, ( + f"Expected prompt to contain '{needle}', got: {context.execute_prompt_text}" + ) + + +@then('the execute prompt should not contain "{needle}"') +def step_execute_prompt_not_contains(context, needle): + assert needle not in context.execute_prompt_text, ( + f"Expected prompt not to contain '{needle}', got: {context.execute_prompt_text}" + ) + + # --------------------------------------------------------------------------- # LLMExecuteActor._parse_file_blocks # --------------------------------------------------------------------------- diff --git a/robot/helper_m5_e2e_verification.py b/robot/helper_m5_e2e_verification.py index 46c22446..07baabd6 100644 --- a/robot/helper_m5_e2e_verification.py +++ b/robot/helper_m5_e2e_verification.py @@ -23,7 +23,9 @@ import sys import tempfile from collections.abc import Callable from pathlib import Path +from types import SimpleNamespace from typing import Any, NoReturn, cast +from unittest.mock import MagicMock from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -36,6 +38,18 @@ if _SRC not in sys.path: from cleveragents.application.services.context_phase_analysis import ( # noqa: E402 analyze_phase_summaries, ) +from cleveragents.application.services.context_tiers import ( # noqa: E402 + ContextTierService, +) +from cleveragents.application.services.execute_phase_context_assembler import ( # noqa: E402 + ACMSExecutePhaseContextAssembler, +) +from cleveragents.application.services.llm_actors import ( # noqa: E402 + LLMExecuteActor, +) +from cleveragents.application.services.plan_executor import ( # noqa: E402 + StrategyDecision, +) from cleveragents.application.services.resource_registry_service import ( # noqa: E402 ResourceRegistryService, ) @@ -43,6 +57,7 @@ from cleveragents.cli.commands.project_context import ( # noqa: E402 _read_policy, _write_policy, ) +from cleveragents.config.settings import Settings # noqa: E402 from cleveragents.domain.models.acms.tiers import ( # noqa: E402 ContextTier, TieredFragment, @@ -765,6 +780,106 @@ def context_view_validation() -> None: print("m5-context-view-validation-ok") +# ------------------------------------------------------------------- +# Subcommand: llm-execute-acms-context +# ------------------------------------------------------------------- + + +def llm_execute_acms_context() -> None: + """Verify execute-phase LLM prompt uses ACMS assembled execute context.""" + proj_repo, _link_repo, _svc, sf = _setup_db() + _create_project(proj_repo, "local/exec-ctx") + + policy = ProjectContextPolicy( + default_view=ContextView(include_paths=["**/*"]), + strategize_view=ContextView(include_paths=["docs/**"]), + execute_view=ContextView( + include_paths=["src/**"], + max_file_size=100_000, + max_total_size=100_000, + ), + ) + _write_policy(sf, "local/exec-ctx", policy) + + tier_service = ContextTierService(settings=Settings()) + tier_service.store( + TieredFragment( + fragment_id="frag-docs", + content="documentation fragment", + tier=ContextTier.HOT, + resource_id="local/large-repo", + project_name="local/exec-ctx", + token_count=20, + metadata={"path": "docs/readme.md", "relevance_score": "0.6"}, + ) + ) + tier_service.store( + TieredFragment( + fragment_id="frag-src", + content="def run():\n return 'ok'", + tier=ContextTier.HOT, + resource_id="local/large-repo", + project_name="local/exec-ctx", + token_count=16, + metadata={"path": "src/main.py", "relevance_score": "0.9"}, + ) + ) + + assembler = ACMSExecutePhaseContextAssembler( + context_tier_service=tier_service, + project_repository=proj_repo, + hot_max_tokens=1024, + ) + + mock_llm = MagicMock() + mock_llm.invoke.return_value = SimpleNamespace( + content="FILE: src/out.py\n```python\nprint('ok')\n```\n" + ) + registry = cast(Any, SimpleNamespace(create_llm=MagicMock(return_value=mock_llm))) + + plan = SimpleNamespace( + identity=SimpleNamespace(plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV"), + action_name="local/action", + project_links=[SimpleNamespace(project_name="local/exec-ctx")], + ) + action = SimpleNamespace(execution_actor="openai/gpt-4") + lifecycle = SimpleNamespace( + get_plan=MagicMock(return_value=plan), + get_action=MagicMock(return_value=action), + ) + + actor = LLMExecuteActor( + provider_registry=registry, + lifecycle_service=lifecycle, + context_assembler=assembler, + ) + actor.execute( + plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", + decisions=[ + StrategyDecision( + decision_id="d1", + step_text="Implement feature", + sequence=0, + parent_id=None, + ) + ], + stream_callback=None, + ) + + call_args = mock_llm.invoke.call_args + if call_args is None: + _fail("LLM invoke was not called") + prompt = call_args.args[0][0].content + if "ACMS Execute-Phase Context" not in prompt: + _fail("prompt missing ACMS execute-phase context section") + if "src/main.py" not in prompt: + _fail("prompt missing execute-view source fragment") + if "docs/readme.md" in prompt: + _fail("prompt should exclude strategize-only docs fragment in execute view") + + print("m5-llm-execute-acms-context-ok") + + # ------------------------------------------------------------------- # Dispatcher # ------------------------------------------------------------------- @@ -779,6 +894,7 @@ _COMMANDS: dict[str, Callable[[], None]] = { "acms-scoped-context": acms_scoped_context, "context-policy-clear": context_policy_clear, "context-view-validation": context_view_validation, + "llm-execute-acms-context": llm_execute_acms_context, } diff --git a/robot/m5_e2e_verification.robot b/robot/m5_e2e_verification.robot index e33bab2c..8cb17e57 100644 --- a/robot/m5_e2e_verification.robot +++ b/robot/m5_e2e_verification.robot @@ -329,6 +329,17 @@ ACMS Scoped Context Output Per Phase Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} m5-acms-scoped-context-ok +Execute Phase LLM Uses ACMS Context + [Documentation] Verify execute-phase plan prompting injects + ... ACMS assembled context using execute-view + ... policy (not strategize view) and excludes + ... out-of-view fragments. + ${result}= Run Process ${PYTHON} ${HELPER} llm-execute-acms-context cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} m5-llm-execute-acms-context-ok + Context Policy Clear And Inheritance Fallback [Documentation] Verify that clearing a phase view causes ... resolve_view to fall back to the parent phase. diff --git a/src/cleveragents/application/services/execute_phase_context_assembler.py b/src/cleveragents/application/services/execute_phase_context_assembler.py new file mode 100644 index 00000000..659c3046 --- /dev/null +++ b/src/cleveragents/application/services/execute_phase_context_assembler.py @@ -0,0 +1,241 @@ +"""Execute-phase ACMS context assembly for LLM execution prompts.""" + +from __future__ import annotations + +import fnmatch +from pathlib import PurePath +from typing import Any, Protocol + +import structlog + +from cleveragents.application.services.acms_service import ACMSPipeline +from cleveragents.domain.models.acms.crp import AssembledContext, ContextRequest +from cleveragents.domain.models.acms.tiers import TieredFragment +from cleveragents.domain.models.core.context_fragment import ( + ContextBudget as CoreContextBudget, +) +from cleveragents.domain.models.core.context_fragment import ( + ContextFragment as CoreContextFragment, +) +from cleveragents.domain.models.core.context_fragment import ( + FragmentProvenance as CoreFragmentProvenance, +) +from cleveragents.domain.models.core.context_policy import ProjectContextPolicy +from cleveragents.infrastructure.database.repositories import ( + NamespacedProjectRepository, +) + +logger = structlog.get_logger(__name__) + + +class ExecutePhaseContextAssembler(Protocol): + """Protocol for assembling execute-phase ACMS context.""" + + def assemble(self, plan: Any) -> AssembledContext | None: + """Return assembled context for *plan* or ``None`` when unavailable.""" + ... + + +class ACMSExecutePhaseContextAssembler(ExecutePhaseContextAssembler): + """Assemble execute-phase ACMS context from tier fragments and project policy.""" + + def __init__( + self, + context_tier_service: Any, + project_repository: NamespacedProjectRepository, + acms_pipeline: ACMSPipeline | None = None, + hot_max_tokens: int = 4096, + ) -> None: + self._tier = context_tier_service + self._project_repository = project_repository + self._pipeline = acms_pipeline or ACMSPipeline() + self._hot_max_tokens = hot_max_tokens + self._logger = logger.bind(component="execute_phase_context_assembler") + + def _resolve_execute_view(self, project_name: str) -> Any: + """Resolve the effective execute-phase view for *project_name*.""" + try: + policy = self._project_repository.get_context_policy(project_name) + except Exception: + self._logger.warning( + "context_policy_lookup_failed", + project_name=project_name, + exc_info=True, + ) + return ProjectContextPolicy().resolve_view("execute") + return policy.resolve_view("execute") + + @staticmethod + def _path_matches(path: str, include: list[str], exclude: list[str]) -> bool: + """Return whether *path* passes include/exclude path globs.""" + pure_path = PurePath(path) + if include and not any(pure_path.full_match(pattern) for pattern in include): + return False + return not ( + exclude and any(pure_path.full_match(pattern) for pattern in exclude) + ) + + @staticmethod + def _resource_matches( + resource_id: str, include: list[str], exclude: list[str] + ) -> bool: + """Return whether *resource_id* passes include/exclude resource globs.""" + if include and not any( + fnmatch.fnmatch(resource_id, pattern) for pattern in include + ): + return False + return not ( + exclude + and any(fnmatch.fnmatch(resource_id, pattern) for pattern in exclude) + ) + + def _to_context_fragment( + self, + fragment: TieredFragment, + *, + strategy: str, + ) -> CoreContextFragment: + """Convert a ``TieredFragment`` into a CRP ``ContextFragment``.""" + metadata = dict(fragment.metadata) + detail_depth_raw = metadata.get("detail_depth", 1) + detail_depth = detail_depth_raw if isinstance(detail_depth_raw, int) else 1 + score = metadata.get("relevance_score") + relevance = float(score) if isinstance(score, (int, float)) else 0.5 + bounded_relevance = min(1.0, max(0.0, relevance)) + location = metadata.get("path", "") + return CoreContextFragment( + uko_node=fragment.resource_id or f"uko:{fragment.fragment_id}", + content=fragment.content, + detail_depth=detail_depth, + token_count=fragment.token_count, + relevance_score=bounded_relevance, + provenance=CoreFragmentProvenance( + resource_uri=fragment.resource_id or "unknown", + location=location if isinstance(location, str) else "", + strategy=strategy, + ), + metadata=metadata, + ) + + def assemble(self, plan: Any) -> AssembledContext | None: + """Assemble execute-phase context for *plan* from scoped tier fragments.""" + project_names = [ + project.project_name for project in getattr(plan, "project_links", []) + ] + if not project_names: + return None + + views = {name: self._resolve_execute_view(name) for name in project_names} + scoped = self._tier.get_scoped_view(project_names) + if not scoped: + return None + + filtered: list[CoreContextFragment] = [] + excluded_resource_or_scope = 0 + excluded_path_rules = 0 + excluded_max_file = 0 + excluded_max_total = 0 + per_project_bytes: dict[str, int] = {name: 0 for name in project_names} + + for fragment in scoped: + view = views.get(fragment.project_name) + if view is None: + excluded_resource_or_scope += 1 + continue + + resource_id = fragment.resource_id or "" + if not self._resource_matches( + resource_id, + view.include_resources, + view.exclude_resources, + ): + excluded_resource_or_scope += 1 + continue + + path_value = ( + fragment.metadata.get("path") + if isinstance(fragment.metadata, dict) + else None + ) + path_text = path_value if isinstance(path_value, str) else "" + if path_text and not self._path_matches( + path_text, + view.include_paths, + view.exclude_paths, + ): + excluded_path_rules += 1 + continue + if view.include_paths and not path_text: + excluded_path_rules += 1 + continue + + byte_size = len(fragment.content.encode("utf-8")) + if view.max_file_size is not None and byte_size > view.max_file_size: + excluded_max_file += 1 + continue + + current_bytes = per_project_bytes.get(fragment.project_name, 0) + if ( + view.max_total_size is not None + and current_bytes + byte_size > view.max_total_size + ): + excluded_max_total += 1 + continue + per_project_bytes[fragment.project_name] = current_bytes + byte_size + + filtered.append( + self._to_context_fragment( + fragment, + strategy="execute_phase_context", + ) + ) + + if not filtered: + self._logger.info( + "execute_context_empty_after_filtering", + plan_id=plan.identity.plan_id, + project_names=project_names, + excluded_resource_or_scope=excluded_resource_or_scope, + excluded_path_rules=excluded_path_rules, + excluded_max_file_size=excluded_max_file, + excluded_max_total_size=excluded_max_total, + ) + return None + + budget = CoreContextBudget(max_tokens=self._hot_max_tokens, reserved_tokens=0) + request = ContextRequest( + query=( + f"Execute-phase context for plan {plan.identity.plan_id} " + f"({', '.join(project_names)})" + ), + purpose="llm_execute_phase_prompt", + max_tokens=self._hot_max_tokens, + ) + payload = self._pipeline.assemble( + plan_id=plan.identity.plan_id, + fragments=filtered, + budget=budget, + strategy="relevance", + request=request, + ) + self._logger.info( + "execute_context_assembled", + plan_id=plan.identity.plan_id, + project_names=project_names, + included_fragments=len(payload.fragments), + total_tokens=payload.total_tokens, + budget_used=payload.budget_used, + excluded_resource_or_scope=excluded_resource_or_scope, + excluded_path_rules=excluded_path_rules, + excluded_max_file_size=excluded_max_file, + excluded_max_total_size=excluded_max_total, + ) + return AssembledContext( + fragments=payload.fragments, + total_tokens=payload.total_tokens, + budget_used=payload.budget_used, + strategies_used=payload.strategies_used, + context_hash=payload.context_hash, + preamble=payload.preamble, + provenance_map=payload.provenance_map, + ) diff --git a/src/cleveragents/application/services/llm_actors.py b/src/cleveragents/application/services/llm_actors.py index 19e6db3c..c815c75f 100644 --- a/src/cleveragents/application/services/llm_actors.py +++ b/src/cleveragents/application/services/llm_actors.py @@ -15,6 +15,9 @@ from typing import TYPE_CHECKING, Any import structlog from ulid import ULID +from cleveragents.application.services.execute_phase_context_assembler import ( + ExecutePhaseContextAssembler, +) from cleveragents.application.services.plan_executor import ( ExecuteResult, StrategizeResult, @@ -22,6 +25,7 @@ from cleveragents.application.services.plan_executor import ( StreamCallback, ) from cleveragents.core.exceptions import ValidationError +from cleveragents.domain.models.acms.crp import AssembledContext from cleveragents.domain.models.core.plan import PlanInvariant from cleveragents.tool.builtins.changeset import ChangeSet, ChangeSetEntry @@ -61,7 +65,7 @@ class LLMStrategizeActor: def __init__( self, - provider_registry: ProviderRegistry, + provider_registry: ProviderRegistry | None, lifecycle_service: Any, ) -> None: if provider_registry is None: @@ -214,8 +218,9 @@ class LLMExecuteActor: def __init__( self, - provider_registry: ProviderRegistry, + provider_registry: ProviderRegistry | None, lifecycle_service: Any, + context_assembler: ExecutePhaseContextAssembler | None = None, ) -> None: if provider_registry is None: raise ValidationError("provider_registry must not be None") @@ -223,8 +228,35 @@ class LLMExecuteActor: raise ValidationError("lifecycle_service must not be None") self._registry = provider_registry self._lifecycle = lifecycle_service + self._context_assembler = context_assembler self._logger = logger.bind(actor="llm_execute") + @staticmethod + def _format_context_for_prompt(assembled: AssembledContext) -> str: + """Render assembled ACMS context into a deterministic prompt section.""" + lines = [ + "ACMS Execute-Phase Context:", + f"- total_tokens: {assembled.total_tokens}", + f"- budget_used: {assembled.budget_used:.4f}", + f"- context_hash: {assembled.context_hash}", + f"- strategies_used: {', '.join(assembled.strategies_used) or '(none)'}", + ] + if assembled.preamble: + lines.append(f"- preamble: {assembled.preamble}") + lines.append("- fragments:") + for idx, fragment in enumerate(assembled.fragments, start=1): + lines.append( + " " + f"{idx}. {fragment.provenance.resource_uri} " + f"({fragment.token_count} tokens, depth={fragment.detail_depth})" + ) + lines.append( + f" location: {fragment.provenance.location or '(unknown)'}" + ) + lines.append(" content:") + lines.append(f"{fragment.content}") + return "\n".join(lines) + def execute( self, plan_id: str, @@ -258,12 +290,31 @@ class LLMExecuteActor: llm = self._registry.create_llm(provider_type=provider_type, model_id=model_id) + assembled_context: AssembledContext | None = None + if self._context_assembler is not None: + try: + assembled_context = self._context_assembler.assemble(plan) + except Exception: + self._logger.warning( + "execute_context_assembly_failed", + plan_id=plan_id, + exc_info=True, + ) + # Build a prompt summarising the decisions steps_text = "\n".join(f"{d.sequence + 1}. {d.step_text}" for d in decisions) + context_section = "" + if assembled_context is not None: + context_section = ( + "\n\n" + f"{self._format_context_for_prompt(assembled_context)}" + "\n\nUse this assembled context instead of requesting full raw files." + ) prompt = ( "You are an expert software engineer. " "Implement the following steps by producing file changes.\n\n" f"Steps:\n{steps_text}\n\n" + f"{context_section}" "For each file you create or modify, output a block:\n" "FILE: \n```\n\n```\n\n" "Only output file blocks. Do not add commentary." diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index fcc836db..f0546c3a 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -1277,6 +1277,9 @@ def _get_plan_executor(lifecycle_service: PlanLifecycleService | None = None) -> bugs caused by the DI ``Factory`` creating independent instances. """ from cleveragents.application.container import get_container + from cleveragents.application.services.execute_phase_context_assembler import ( + ACMSExecutePhaseContextAssembler, + ) from cleveragents.application.services.llm_actors import ( LLMExecuteActor, LLMStrategizeActor, @@ -1292,9 +1295,15 @@ def _get_plan_executor(lifecycle_service: PlanLifecycleService | None = None) -> provider_registry=registry, lifecycle_service=lifecycle_service, ) + context_assembler = ACMSExecutePhaseContextAssembler( + context_tier_service=container.context_tier_service(), + project_repository=container.namespaced_project_repo(), + hot_max_tokens=container.settings().context_max_tokens_hot, + ) execute_actor = LLMExecuteActor( provider_registry=registry, lifecycle_service=lifecycle_service, + context_assembler=context_assembler, ) return PlanExecutor( diff --git a/src/cleveragents/infrastructure/database/repositories.py b/src/cleveragents/infrastructure/database/repositories.py index c9f6e7cc..ae718674 100644 --- a/src/cleveragents/infrastructure/database/repositories.py +++ b/src/cleveragents/infrastructure/database/repositories.py @@ -2976,6 +2976,43 @@ class NamespacedProjectRepository: except (OperationalError, SQLAlchemyDatabaseError) as exc: raise DatabaseError(f"Failed to list projects: {exc}") from exc + @database_retry + def get_context_policy(self, namespaced_name: str) -> Any: + """Return a project's stored ``ProjectContextPolicy`` or the default.""" + from cleveragents.domain.models.core.context_policy import ProjectContextPolicy + + session = self._session() + try: + row = ( + session.query(NamespacedProjectModel) + .filter_by(namespaced_name=namespaced_name) + .first() + ) + if row is None or row.context_policy_json is None: + return ProjectContextPolicy() + + payload = json.loads(cast(str, row.context_policy_json)) + if not isinstance(payload, dict): + _log.warning( + "project_context_policy_payload_invalid", + project_name=namespaced_name, + ) + return ProjectContextPolicy() + return ProjectContextPolicy.model_validate(payload) + except (ValueError, TypeError): + _log.warning( + "project_context_policy_invalid", + project_name=namespaced_name, + exc_info=True, + ) + return ProjectContextPolicy() + except (OperationalError, SQLAlchemyDatabaseError) as exc: + raise DatabaseError( + f"Failed to load project context policy for '{namespaced_name}': {exc}" + ) from exc + finally: + session.close() + @database_retry def update(self, project: Any) -> Any: """Update mutable fields of an existing project.