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
This commit is contained in:
2026-03-26 09:54:30 +00:00
parent abf7b47de1
commit f0442e835d
8 changed files with 590 additions and 6 deletions
+21
View File
@@ -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
# ---------------------------------------------------------------
+102 -4
View File
@@ -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
# ---------------------------------------------------------------------------
+116
View File
@@ -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,
}
+11
View File
@@ -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.
@@ -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,
)
@@ -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: <path>\n```\n<full file content>\n```\n\n"
"Only output file blocks. Do not add commentary."
+9
View File
@@ -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(
@@ -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.