feat(acms): plan execution leverages ACMS context for LLM calls #1163

Merged
aditya merged 1 commits from feature/m5-acms-llm-integration into master 2026-03-30 10:12:06 +00:00
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.