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

Reviewed-on: #4219
This commit was merged in pull request #4219.
This commit is contained in:
2026-04-09 13:18:46 +00:00
committed by Forgejo
8 changed files with 595 additions and 8 deletions
+2
View File
@@ -178,3 +178,5 @@ ca-cow-backup-*/
log.html
output.xml
report.html
.agent-orchestration
agents-test
+9
View File
@@ -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)
---
+39
View File
@@ -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:
+60 -5
View File
@@ -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