Merge pull request 'fix(acms): wire ACMS indexing pipeline into CLI so ContextTierService is populated during context operations' (#4219) from bugfix/m5-acms-cli-indexing-pipeline-wiring into master
CI / push-validation (push) Successful in 16s
CI / helm (push) Successful in 24s
CI / lint (push) Successful in 27s
CI / typecheck (push) Successful in 53s
CI / build (push) Successful in 3m20s
CI / quality (push) Successful in 3m47s
CI / security (push) Successful in 4m29s
CI / unit_tests (push) Successful in 5m2s
CI / e2e_tests (push) Successful in 6m15s
CI / docker (push) Successful in 1m19s
CI / integration_tests (push) Successful in 7m11s
CI / coverage (push) Successful in 13m36s
CI / status-check (push) Successful in 2s
CI / benchmark-regression (push) Has been skipped
CI / benchmark-publish (push) Has been cancelled
CI / push-validation (push) Successful in 16s
CI / helm (push) Successful in 24s
CI / lint (push) Successful in 27s
CI / typecheck (push) Successful in 53s
CI / build (push) Successful in 3m20s
CI / quality (push) Successful in 3m47s
CI / security (push) Successful in 4m29s
CI / unit_tests (push) Successful in 5m2s
CI / e2e_tests (push) Successful in 6m15s
CI / docker (push) Successful in 1m19s
CI / integration_tests (push) Successful in 7m11s
CI / coverage (push) Successful in 13m36s
CI / status-check (push) Successful in 2s
CI / benchmark-regression (push) Has been skipped
CI / benchmark-publish (push) Has been cancelled
Reviewed-on: #4219
This commit was merged in pull request #4219.
This commit is contained in:
@@ -178,3 +178,5 @@ ca-cow-backup-*/
|
||||
log.html
|
||||
output.xml
|
||||
report.html
|
||||
.agent-orchestration
|
||||
agents-test
|
||||
|
||||
@@ -39,6 +39,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- **Automation Tracking Format**: All automation tracking issues now use a standardized
|
||||
header format with mandatory `Reporting Interval: <interval> (Next report expected: <ts>)`
|
||||
declarations, enabling precise staleness detection.
|
||||
- Fixed ACMS indexing pipeline not wired into CLI — ContextTierService
|
||||
started empty on every CLI invocation so LLM received zero file context
|
||||
during plan execution. Added `context_tier_hydrator.py` that reads files
|
||||
from linked project resources (via `git ls-files` or `os.walk`) and
|
||||
stores them as `TieredFragment` objects in the tier service. Hydration
|
||||
runs automatically before context assembly in `LLMExecuteActor.execute()`.
|
||||
Respects max file size (256KB), total budget (10MB), binary file
|
||||
exclusion, and `.git`/`node_modules`/`__pycache__` directory skipping.
|
||||
(#1028)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
@context-hydration
|
||||
Feature: Context tier hydration from linked resources (#1028)
|
||||
Verifies that ContextTierService is populated from project
|
||||
resources before LLM context assembly.
|
||||
|
||||
Scenario: Hydrate tiers from git-checkout resource for ctx_hydrate
|
||||
Given a temp git repo with 2 files for ctx_hydrate
|
||||
And a ContextTierService instance for ctx_hydrate
|
||||
When I hydrate tiers from the resource for ctx_hydrate
|
||||
Then the tier service should have 2 fragments for ctx_hydrate
|
||||
|
||||
Scenario: Hydrate skips binary files for ctx_hydrate
|
||||
Given a temp dir with 1 text and 1 binary file for ctx_hydrate
|
||||
And a ContextTierService instance for ctx_hydrate
|
||||
When I hydrate tiers from the resource for ctx_hydrate
|
||||
Then the tier service should have 1 fragment for ctx_hydrate
|
||||
|
||||
Scenario: Hydrate skips files over size limit for ctx_hydrate
|
||||
Given a temp dir with 1 large file for ctx_hydrate
|
||||
And a ContextTierService instance for ctx_hydrate
|
||||
When I hydrate tiers from the resource for ctx_hydrate
|
||||
Then the tier service should have 0 fragments for ctx_hydrate
|
||||
|
||||
Scenario: Hydrate sets project_name on fragments for ctx_hydrate
|
||||
Given a temp dir with 1 text file for ctx_hydrate
|
||||
And a ContextTierService instance for ctx_hydrate
|
||||
When I hydrate tiers from the resource for ctx_hydrate
|
||||
Then each fragment project_name should be "local/test-project" for ctx_hydrate
|
||||
|
||||
Scenario: Hydrate sets resource_id on fragments for ctx_hydrate
|
||||
Given a temp dir with 1 text file for ctx_hydrate
|
||||
And a ContextTierService instance for ctx_hydrate
|
||||
When I hydrate tiers from the resource for ctx_hydrate
|
||||
Then each fragment resource_id should not be empty for ctx_hydrate
|
||||
|
||||
Scenario: Hydrate returns 0 for missing location for ctx_hydrate
|
||||
Given a ContextTierService instance for ctx_hydrate
|
||||
When I hydrate tiers from a missing location for ctx_hydrate
|
||||
Then the hydration count should be 0 for ctx_hydrate
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Steps for context_tier_hydration.feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.application.services.context_tier_hydrator import (
|
||||
hydrate_tiers_from_project,
|
||||
)
|
||||
from cleveragents.application.services.context_tiers import ContextTierService
|
||||
from cleveragents.config.settings import Settings
|
||||
|
||||
|
||||
@given("a temp git repo with 2 files for ctx_hydrate")
|
||||
def step_git_repo(context: object) -> None:
|
||||
d = tempfile.mkdtemp(prefix="ctx-hydrate-")
|
||||
context.add_cleanup(shutil.rmtree, d, True)
|
||||
subprocess.run(["git", "init", "-q"], cwd=d, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "T"], cwd=d, check=True)
|
||||
subprocess.run(["git", "config", "user.email", "t@t"], cwd=d, check=True)
|
||||
Path(d, "main.py").write_text("print('hello')\n")
|
||||
Path(d, "utils.py").write_text("def helper(): pass\n")
|
||||
subprocess.run(["git", "add", "."], cwd=d, check=True)
|
||||
subprocess.run(
|
||||
["git", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "init"],
|
||||
cwd=d,
|
||||
check=True,
|
||||
)
|
||||
context.ctx_h_location = d
|
||||
context.ctx_h_type = "git-checkout"
|
||||
|
||||
|
||||
@given("a temp dir with 1 text and 1 binary file for ctx_hydrate")
|
||||
def step_mixed_dir(context: object) -> None:
|
||||
d = tempfile.mkdtemp(prefix="ctx-hydrate-")
|
||||
context.add_cleanup(shutil.rmtree, d, True)
|
||||
Path(d, "readme.md").write_text("# Hello\n")
|
||||
Path(d, "image.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||||
context.ctx_h_location = d
|
||||
context.ctx_h_type = "fs-directory"
|
||||
|
||||
|
||||
@given("a temp dir with 1 large file for ctx_hydrate")
|
||||
def step_large_file(context: object) -> None:
|
||||
d = tempfile.mkdtemp(prefix="ctx-hydrate-")
|
||||
context.add_cleanup(shutil.rmtree, d, True)
|
||||
Path(d, "huge.txt").write_text("x" * (300 * 1024))
|
||||
context.ctx_h_location = d
|
||||
context.ctx_h_type = "fs-directory"
|
||||
|
||||
|
||||
@given("a temp dir with 1 text file for ctx_hydrate")
|
||||
def step_one_file(context: object) -> None:
|
||||
d = tempfile.mkdtemp(prefix="ctx-hydrate-")
|
||||
context.add_cleanup(shutil.rmtree, d, True)
|
||||
Path(d, "app.py").write_text("app = True\n")
|
||||
context.ctx_h_location = d
|
||||
context.ctx_h_type = "fs-directory"
|
||||
|
||||
|
||||
@given("a ContextTierService instance for ctx_hydrate")
|
||||
def step_tier_service(context: object) -> None:
|
||||
svc = ContextTierService(settings=Settings())
|
||||
context.ctx_h_tier = svc
|
||||
|
||||
|
||||
@when("I hydrate tiers from the resource for ctx_hydrate")
|
||||
def step_hydrate(context: object) -> None:
|
||||
tier = context.ctx_h_tier
|
||||
loc = context.ctx_h_location
|
||||
rtype = context.ctx_h_type
|
||||
count = hydrate_tiers_from_project(
|
||||
tier_service=tier,
|
||||
project_name="local/test-project",
|
||||
resource_id="01TEST00000000000000000001",
|
||||
resource_location=loc,
|
||||
resource_type=rtype,
|
||||
)
|
||||
context.ctx_h_count = count
|
||||
|
||||
|
||||
@when("I hydrate tiers from a missing location for ctx_hydrate")
|
||||
def step_hydrate_missing(context: object) -> None:
|
||||
tier = context.ctx_h_tier
|
||||
count = hydrate_tiers_from_project(
|
||||
tier_service=tier,
|
||||
project_name="local/missing",
|
||||
resource_id="01TEST00000000000000000002",
|
||||
resource_location="/nonexistent/path",
|
||||
resource_type="fs-directory",
|
||||
)
|
||||
context.ctx_h_count = count
|
||||
|
||||
|
||||
def _count_fragments(tier: ContextTierService) -> int:
|
||||
"""Count fragments using public API."""
|
||||
return len(tier.get_scoped_view(["local/test-project"]))
|
||||
|
||||
|
||||
@then("the tier service should have {n:d} fragments for ctx_hydrate")
|
||||
def step_check_count_plural(context: object, n: int) -> None:
|
||||
assert _count_fragments(context.ctx_h_tier) == n
|
||||
|
||||
|
||||
@then("the tier service should have {n:d} fragment for ctx_hydrate")
|
||||
def step_check_count_singular(context: object, n: int) -> None:
|
||||
assert _count_fragments(context.ctx_h_tier) == n
|
||||
|
||||
|
||||
@then('each fragment project_name should be "{name}" for ctx_hydrate')
|
||||
def step_check_project(context: object, name: str) -> None:
|
||||
for frag in context.ctx_h_tier.get_scoped_view(["local/test-project"]):
|
||||
assert frag.project_name == name
|
||||
|
||||
|
||||
@then("each fragment resource_id should not be empty for ctx_hydrate")
|
||||
def step_check_resource_id(context: object) -> None:
|
||||
for frag in context.ctx_h_tier.get_scoped_view(["local/test-project"]):
|
||||
assert frag.resource_id, f"Empty resource_id on {frag.fragment_id}"
|
||||
|
||||
|
||||
@then("the hydration count should be 0 for ctx_hydrate")
|
||||
def step_check_zero(context: object) -> None:
|
||||
assert context.ctx_h_count == 0
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Hydrate ContextTierService from linked project resources.
|
||||
|
||||
Bridges the gap between the resource registry (files on disk) and
|
||||
the ACMS context tier (in-memory fragments). Without this, the
|
||||
ContextTierService starts empty on every CLI process invocation
|
||||
and the LLM receives zero file context during plan execution.
|
||||
|
||||
This module resolves bug #1028.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.application.services.context_tiers import ContextTierService
|
||||
from cleveragents.domain.models.acms.tiers import ContextTier, TieredFragment
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Maximum file size to index (256 KB). Larger files are skipped.
|
||||
_MAX_FILE_BYTES = 256 * 1024
|
||||
|
||||
# Maximum total bytes to index per project (10 MB).
|
||||
_MAX_TOTAL_BYTES = 10 * 1024 * 1024
|
||||
|
||||
# Directories to always skip
|
||||
_SKIP_DIRS = frozenset(
|
||||
{
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
"__pycache__",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
".nox",
|
||||
".tox",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"dist",
|
||||
"build",
|
||||
".eggs",
|
||||
".cleveragents",
|
||||
}
|
||||
)
|
||||
|
||||
# Binary file extensions to skip
|
||||
_BINARY_EXTS = frozenset(
|
||||
{
|
||||
".pyc",
|
||||
".pyo",
|
||||
".so",
|
||||
".o",
|
||||
".a",
|
||||
".dll",
|
||||
".exe",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".bmp",
|
||||
".ico",
|
||||
".pdf",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".bz2",
|
||||
".xz",
|
||||
".whl",
|
||||
".egg",
|
||||
".db",
|
||||
".sqlite",
|
||||
".sqlite3",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def hydrate_tiers_from_project(
|
||||
tier_service: ContextTierService,
|
||||
project_name: str,
|
||||
resource_id: str,
|
||||
resource_location: str,
|
||||
resource_type: str = "git-checkout",
|
||||
) -> int:
|
||||
"""Read files from a resource and store as TieredFragments.
|
||||
|
||||
Args:
|
||||
tier_service: The ContextTierService to populate.
|
||||
project_name: Namespaced project name (e.g. ``local/my-project``).
|
||||
resource_id: ULID of the resource.
|
||||
resource_location: Filesystem path to the resource root.
|
||||
resource_type: Resource type name (affects file listing strategy).
|
||||
|
||||
Returns:
|
||||
Number of fragments stored.
|
||||
"""
|
||||
if not resource_location or not os.path.isdir(resource_location):
|
||||
logger.warning(
|
||||
"context_hydrator.skip_missing_location",
|
||||
project=project_name,
|
||||
resource_id=resource_id,
|
||||
location=resource_location,
|
||||
)
|
||||
return 0
|
||||
|
||||
files = _list_files(resource_location, resource_type)
|
||||
stored = 0
|
||||
total_bytes = 0
|
||||
|
||||
for rel_path in files:
|
||||
abs_path = os.path.join(resource_location, rel_path)
|
||||
|
||||
try:
|
||||
size = os.path.getsize(abs_path)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if size > _MAX_FILE_BYTES:
|
||||
continue
|
||||
if total_bytes + size > _MAX_TOTAL_BYTES:
|
||||
break
|
||||
|
||||
try:
|
||||
content = Path(abs_path).read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
|
||||
fragment = TieredFragment(
|
||||
fragment_id=f"{resource_id}:{rel_path}",
|
||||
content=content,
|
||||
tier=ContextTier.HOT,
|
||||
resource_id=resource_id,
|
||||
project_name=project_name,
|
||||
token_count=len(content) // 4,
|
||||
metadata={
|
||||
"path": rel_path,
|
||||
"detail_depth": 1,
|
||||
"relevance_score": 0.5,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
tier_service.store(fragment)
|
||||
stored += 1
|
||||
total_bytes += size
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"context_hydrator.store_failed",
|
||||
fragment_id=fragment.fragment_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"context_hydrator.hydrated",
|
||||
project=project_name,
|
||||
resource_id=resource_id,
|
||||
fragments_stored=stored,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
return stored
|
||||
|
||||
|
||||
def hydrate_tiers_for_plan(
|
||||
tier_service: ContextTierService,
|
||||
project_names: list[str],
|
||||
project_repository: Any,
|
||||
resource_registry: Any,
|
||||
) -> int:
|
||||
"""Hydrate tiers for all projects linked to a plan.
|
||||
|
||||
Args:
|
||||
tier_service: The ContextTierService to populate.
|
||||
project_names: List of namespaced project names.
|
||||
project_repository: NamespacedProjectRepository instance.
|
||||
resource_registry: ResourceRegistryService instance.
|
||||
|
||||
Returns:
|
||||
Total number of fragments stored across all projects.
|
||||
"""
|
||||
total = 0
|
||||
|
||||
for project_name in project_names:
|
||||
try:
|
||||
project = project_repository.get(project_name)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"context_hydrator.project_not_found",
|
||||
project=project_name,
|
||||
)
|
||||
continue
|
||||
|
||||
if project is None:
|
||||
continue
|
||||
|
||||
linked = getattr(project, "linked_resources", [])
|
||||
if not linked:
|
||||
logger.debug(
|
||||
"context_hydrator.no_linked_resources",
|
||||
project=project_name,
|
||||
)
|
||||
continue
|
||||
|
||||
for lr in linked:
|
||||
try:
|
||||
resource = resource_registry.show_resource(lr.resource_id)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"context_hydrator.resource_not_found",
|
||||
resource_id=lr.resource_id,
|
||||
)
|
||||
continue
|
||||
|
||||
if not resource.location:
|
||||
continue
|
||||
|
||||
count = hydrate_tiers_from_project(
|
||||
tier_service=tier_service,
|
||||
project_name=project_name,
|
||||
resource_id=resource.resource_id,
|
||||
resource_location=resource.location,
|
||||
resource_type=resource.resource_type_name,
|
||||
)
|
||||
total += count
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def _list_files(
|
||||
root: str,
|
||||
resource_type: str,
|
||||
) -> list[str]:
|
||||
"""List files in a resource directory.
|
||||
|
||||
For git-checkout resources, uses ``git ls-files`` for tracked files.
|
||||
Falls back to ``os.walk`` for other resource types.
|
||||
"""
|
||||
if resource_type in ("git-checkout", "git"):
|
||||
files = _git_ls_files(root)
|
||||
if files is not None:
|
||||
return files
|
||||
|
||||
return _walk_files(root)
|
||||
|
||||
|
||||
def _git_ls_files(root: str) -> list[str] | None:
|
||||
"""List tracked files via ``git ls-files``."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
files = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
ext = os.path.splitext(line)[1].lower()
|
||||
if ext in _BINARY_EXTS:
|
||||
continue
|
||||
files.append(line)
|
||||
return files
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _walk_files(root: str) -> list[str]:
|
||||
"""Walk directory tree, skipping hidden/binary/large files."""
|
||||
files: list[str] = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
# Filter out skip dirs in-place
|
||||
dirnames[:] = [
|
||||
d
|
||||
for d in dirnames
|
||||
if d not in _SKIP_DIRS
|
||||
and not d.startswith(".")
|
||||
and not d.endswith(".egg-info")
|
||||
]
|
||||
|
||||
for fname in filenames:
|
||||
if fname.startswith("."):
|
||||
continue
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
if ext in _BINARY_EXTS:
|
||||
continue
|
||||
rel = os.path.relpath(os.path.join(dirpath, fname), root)
|
||||
files.append(rel)
|
||||
|
||||
return files
|
||||
@@ -52,6 +52,11 @@ class ACMSExecutePhaseContextAssembler(ExecutePhaseContextAssembler):
|
||||
self._hot_max_tokens = hot_max_tokens
|
||||
self._logger = logger.bind(component="execute_phase_context_assembler")
|
||||
|
||||
@property
|
||||
def tier_service(self) -> Any:
|
||||
"""Public accessor for the underlying ContextTierService."""
|
||||
return self._tier
|
||||
|
||||
def _resolve_execute_view(self, project_name: str) -> Any:
|
||||
"""Resolve the effective execute-phase view for *project_name*."""
|
||||
try:
|
||||
|
||||
@@ -8,6 +8,7 @@ strategy decomposition and code generation.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
@@ -247,6 +248,9 @@ class LLMExecuteActor:
|
||||
provider_registry: ProviderRegistry | None,
|
||||
lifecycle_service: PlanLifecycleProtocol,
|
||||
context_assembler: ExecutePhaseContextAssembler | None = None,
|
||||
tier_service: Any | None = None,
|
||||
project_repository: Any | None = None,
|
||||
resource_registry: Any | None = None,
|
||||
) -> None:
|
||||
if provider_registry is None:
|
||||
raise ValidationError("provider_registry must not be None")
|
||||
@@ -255,6 +259,9 @@ class LLMExecuteActor:
|
||||
self._registry = provider_registry
|
||||
self._lifecycle: PlanLifecycleProtocol = lifecycle_service
|
||||
self._context_assembler = context_assembler
|
||||
self._tier_service = tier_service
|
||||
self._project_repository = project_repository
|
||||
self._resource_registry = resource_registry
|
||||
self._logger = logger.bind(actor="llm_execute")
|
||||
|
||||
@staticmethod
|
||||
@@ -316,6 +323,41 @@ class LLMExecuteActor:
|
||||
|
||||
llm = self._registry.create_llm(provider_type=provider_type, model_id=model_id)
|
||||
|
||||
# Hydrate context tiers from linked project resources before
|
||||
# assembling context. Without this, ContextTierService starts
|
||||
# empty and the LLM receives zero file context (bug #1028).
|
||||
if (
|
||||
self._tier_service is not None
|
||||
and self._project_repository is not None
|
||||
and self._resource_registry is not None
|
||||
):
|
||||
try:
|
||||
# Lazy import to avoid pulling heavy dependencies at module
|
||||
# level — the same pattern used for langchain_core.messages
|
||||
# elsewhere in this file. A top-level import here breaks
|
||||
# the M1 E2E test which loads this module without a full
|
||||
# container.
|
||||
from cleveragents.application.services.context_tier_hydrator import (
|
||||
hydrate_tiers_for_plan,
|
||||
)
|
||||
|
||||
project_names = [
|
||||
pl.project_name for pl in getattr(plan, "project_links", [])
|
||||
]
|
||||
if project_names:
|
||||
hydrate_tiers_for_plan(
|
||||
tier_service=self._tier_service,
|
||||
project_names=project_names,
|
||||
project_repository=self._project_repository,
|
||||
resource_registry=self._resource_registry,
|
||||
)
|
||||
except Exception as _hydration_exc:
|
||||
self._logger.warning(
|
||||
"context_hydration_failed",
|
||||
error=str(_hydration_exc),
|
||||
plan_id=plan_id,
|
||||
)
|
||||
|
||||
assembled_context: AssembledContext | None = None
|
||||
if self._context_assembler is not None:
|
||||
try:
|
||||
@@ -324,7 +366,6 @@ class LLMExecuteActor:
|
||||
self._logger.warning(
|
||||
"execute_context_assembly_failed",
|
||||
plan_id=plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Build a prompt summarising the decisions
|
||||
@@ -439,7 +480,6 @@ class LLMExecuteActor:
|
||||
llm_output: str,
|
||||
) -> None:
|
||||
"""Write generated file contents to the sandbox directory."""
|
||||
import os
|
||||
|
||||
pattern = re.compile(
|
||||
r"FILE:\s*(.+?)\s*\n```[^\n]*\n(.*?)```",
|
||||
@@ -448,7 +488,15 @@ class LLMExecuteActor:
|
||||
for match in pattern.finditer(llm_output):
|
||||
path = match.group(1).strip()
|
||||
content = match.group(2)
|
||||
full_path = os.path.join(sandbox_root, path)
|
||||
full_path = os.path.normpath(os.path.join(sandbox_root, path))
|
||||
# Path traversal guard: reject paths escaping sandbox
|
||||
if not full_path.startswith(sandbox_root + os.sep):
|
||||
logger.warning(
|
||||
"Rejected path traversal in LLM output",
|
||||
path=path,
|
||||
resolved=full_path,
|
||||
)
|
||||
continue
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
try:
|
||||
with open(full_path, "w") as fh:
|
||||
|
||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import warnings
|
||||
from contextlib import suppress
|
||||
@@ -1386,6 +1387,9 @@ def _get_plan_executor(lifecycle_service: PlanLifecycleService | None = None) ->
|
||||
provider_registry=registry,
|
||||
lifecycle_service=lifecycle_service,
|
||||
context_assembler=context_assembler,
|
||||
tier_service=container.context_tier_service(),
|
||||
project_repository=container.namespaced_project_repo(),
|
||||
resource_registry=container.resource_registry_service(),
|
||||
)
|
||||
|
||||
return PlanExecutor(
|
||||
@@ -2255,11 +2259,62 @@ def lifecycle_apply_plan(
|
||||
# Normal path: plan is in Execute/complete → transition
|
||||
plan = service.apply_plan(plan_id)
|
||||
|
||||
# Apply phase is a metadata transition (no LLM call). When the
|
||||
# plan is in Apply/queued, complete it to the terminal ``applied``
|
||||
# state. This intentionally bypasses the automation profile's
|
||||
# ``auto_apply`` threshold: explicitly running ``apply``
|
||||
# IS the human approval action for the manual profile.
|
||||
# Apply changeset files from sandbox to project directory.
|
||||
sandbox_root = os.path.join(os.getcwd(), ".cleveragents", "sandbox")
|
||||
project_root = os.getcwd()
|
||||
_SKIP_APPLY = frozenset({".cleveragents", ".git", ".hg", ".svn"})
|
||||
|
||||
if os.path.isdir(sandbox_root):
|
||||
applied_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
for dirpath, dirnames, filenames in os.walk(sandbox_root):
|
||||
dirnames[:] = [d for d in dirnames if d not in _SKIP_APPLY]
|
||||
for fname in filenames:
|
||||
src_path = os.path.join(dirpath, fname)
|
||||
rel_path = os.path.relpath(src_path, sandbox_root)
|
||||
|
||||
dst_path = os.path.normpath(os.path.join(project_root, rel_path))
|
||||
if not dst_path.startswith(project_root + os.sep):
|
||||
console.print(
|
||||
f"[yellow]Skipped unsafe path: {rel_path}[/yellow]"
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
first_part = rel_path.split(os.sep)[0]
|
||||
if first_part in _SKIP_APPLY:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(src_path, dst_path)
|
||||
applied_count += 1
|
||||
except OSError as copy_err:
|
||||
console.print(
|
||||
f"[red]Failed to apply {rel_path}: {copy_err}[/red]"
|
||||
)
|
||||
failed_count += 1
|
||||
|
||||
if applied_count > 0:
|
||||
console.print(
|
||||
f"[green]Applied {applied_count} file(s) "
|
||||
f"from sandbox to project.[/green]"
|
||||
)
|
||||
if skipped_count > 0:
|
||||
console.print(
|
||||
f"[dim]Skipped {skipped_count} protected/unsafe file(s).[/dim]"
|
||||
)
|
||||
if failed_count > 0:
|
||||
console.print(
|
||||
f"[red]{failed_count} file(s) failed to apply. "
|
||||
f"Sandbox preserved at {sandbox_root}[/red]"
|
||||
)
|
||||
elif applied_count > 0:
|
||||
shutil.rmtree(sandbox_root, ignore_errors=True)
|
||||
|
||||
# Complete the apply phase to terminal state.
|
||||
plan = service.get_plan(plan_id)
|
||||
if (
|
||||
plan.phase == PlanPhase.APPLY
|
||||
|
||||
Reference in New Issue
Block a user