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.