Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c6b37f5f6 | |||
| 6e2761088b | |||
| cb718fef0c | |||
| e2c6850ced | |||
| 1dd344978c | |||
| f418ce0b83 | |||
| a7cdcf82d8 | |||
| f4f1b0c00b | |||
| 24f9ed0a12 | |||
| 15a3e6fb21 | |||
| ab0a99afcc |
@@ -460,7 +460,7 @@ def step_then_tier_svc_singleton(context: Any) -> None:
|
||||
def step_then_settings_hot(context: Any) -> None:
|
||||
s = Settings()
|
||||
assert hasattr(s, "context_max_tokens_hot")
|
||||
assert s.context_max_tokens_hot == 16000
|
||||
assert s.context_max_tokens_hot == 32000
|
||||
|
||||
|
||||
@then("settings should have context_max_decisions_warm")
|
||||
|
||||
@@ -130,8 +130,8 @@ class _StubContextAssembler:
|
||||
LLM_NUMBERED_RESPONSE = "1. Create the module\n2. Write unit tests\n3. Update docs"
|
||||
|
||||
LLM_FILE_BLOCKS_RESPONSE = (
|
||||
"FILE: src/main.py\n```python\nprint('hello')\n```\n\n"
|
||||
"FILE: tests/test_main.py\n```python\ndef test_main(): pass\n```\n"
|
||||
"FILE: src/main.py\n<<<<<<< CLEVERAGENTS_FILE_START >>>>>>>\nprint('hello')\n<<<<<<< CLEVERAGENTS_FILE_END >>>>>>>\n\n"
|
||||
"FILE: tests/test_main.py\n<<<<<<< CLEVERAGENTS_FILE_START >>>>>>>\ndef test_main(): pass\n<<<<<<< CLEVERAGENTS_FILE_END >>>>>>>\n"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1027,3 +1027,17 @@ class ACMSPipeline:
|
||||
"""Register a custom context strategy instance."""
|
||||
self._strategies[name] = strategy
|
||||
self._logger.info("Registered strategy", name=name)
|
||||
|
||||
def get_context_summary(self) -> str | None:
|
||||
"""Get a summary of the available ACMS context.
|
||||
|
||||
This method provides a high-level overview of what context is
|
||||
available. The actual context comes from the tier service which
|
||||
is hydrated before the strategize phase runs. This method serves
|
||||
as a fallback when tier_service is not available.
|
||||
|
||||
Returns:
|
||||
A string summary indicating ACMS pipeline is available.
|
||||
The StrategyActor uses tier_service directly for detailed context.
|
||||
"""
|
||||
return "ACMS pipeline is available. Use tier_service for detailed context."
|
||||
|
||||
@@ -47,6 +47,7 @@ _SKIP_DIRS = frozenset(
|
||||
"build",
|
||||
".eggs",
|
||||
".cleveragents",
|
||||
"opencode",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -384,15 +384,24 @@ class LLMExecuteActor:
|
||||
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"
|
||||
"FILE: <path>\n"
|
||||
"<<<<<<< CLEVERAGENTS_FILE_START >>>>>>>\n"
|
||||
"<full file content>\n"
|
||||
"<<<<<<< CLEVERAGENTS_FILE_END >>>>>>>\n\n"
|
||||
"Only output file blocks. Do not add commentary."
|
||||
)
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
# TODO(#650): Wire actor-configured response_format into provider calls
|
||||
# when structured-output enforcement is implemented in runtime execution.
|
||||
response = llm.invoke([HumanMessage(content=prompt)])
|
||||
# when structured-output enforcement is implemented in runtime
|
||||
# execution. Increase max_tokens to allow longer responses (e.g.,
|
||||
# full architecture reports)
|
||||
response = llm.invoke(
|
||||
[HumanMessage(content=prompt)],
|
||||
config={"configurable": {"max_tokens": 16384}},
|
||||
)
|
||||
|
||||
content = response.content if hasattr(response, "content") else str(response)
|
||||
|
||||
self._logger.debug(
|
||||
@@ -453,11 +462,13 @@ class LLMExecuteActor:
|
||||
|
||||
@staticmethod
|
||||
def _parse_file_blocks(llm_output: str, plan_id: str) -> list[ChangeSetEntry]:
|
||||
"""Extract ``FILE: <path>`` + fenced code blocks from LLM output."""
|
||||
"""Extract ``FILE: <path>`` + content between delimiters."""
|
||||
entries: list[ChangeSetEntry] = []
|
||||
# Pattern: FILE: <path> followed by a fenced code block
|
||||
# Pattern: FILE: <path> followed by unique delimiters
|
||||
_DELIM_START = "<<<<<<< CLEVERAGENTS_FILE_START >>>>>>>"
|
||||
_DELIM_END = "<<<<<<< CLEVERAGENTS_FILE_END >>>>>>>"
|
||||
pattern = re.compile(
|
||||
r"FILE:\s*(.+?)\s*\n```[^\n]*\n(.*?)```",
|
||||
rf"FILE:\s*(.+?)\s*\n{_DELIM_START}\n(.*?)\n{_DELIM_END}",
|
||||
re.DOTALL,
|
||||
)
|
||||
for match in pattern.finditer(llm_output):
|
||||
@@ -481,9 +492,10 @@ class LLMExecuteActor:
|
||||
llm_output: str,
|
||||
) -> None:
|
||||
"""Write generated file contents to the sandbox directory."""
|
||||
|
||||
_DELIM_START = "<<<<<<< CLEVERAGENTS_FILE_START >>>>>>>"
|
||||
_DELIM_END = "<<<<<<< CLEVERAGENTS_FILE_END >>>>>>>"
|
||||
pattern = re.compile(
|
||||
r"FILE:\s*(.+?)\s*\n```[^\n]*\n(.*?)```",
|
||||
rf"FILE:\s*(.+?)\s*\n{_DELIM_START}\n(.*?)\n{_DELIM_END}",
|
||||
re.DOTALL,
|
||||
)
|
||||
for match in pattern.finditer(llm_output):
|
||||
@@ -502,6 +514,11 @@ class LLMExecuteActor:
|
||||
try:
|
||||
with open(full_path, "w") as fh:
|
||||
fh.write(content)
|
||||
logger.debug(
|
||||
"Wrote generated file to sandbox",
|
||||
path=full_path,
|
||||
content_length=len(content),
|
||||
)
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Failed to write generated file to sandbox",
|
||||
|
||||
@@ -29,6 +29,12 @@ from ulid import ULID
|
||||
from cleveragents.application.services.autonomy_guardrail_service import (
|
||||
AutonomyGuardrailService,
|
||||
)
|
||||
from cleveragents.application.services.context_tier_hydrator import (
|
||||
hydrate_tiers_for_plan,
|
||||
)
|
||||
from cleveragents.application.services.context_tiers import (
|
||||
ContextTierService,
|
||||
)
|
||||
from cleveragents.application.services.fix_then_revalidate import (
|
||||
FixThenRevalidateOrchestrator,
|
||||
)
|
||||
@@ -37,6 +43,9 @@ from cleveragents.application.services.plan_execution_context import (
|
||||
RuntimeExecuteActor,
|
||||
RuntimeExecuteResult,
|
||||
)
|
||||
from cleveragents.application.services.resource_registry_service import (
|
||||
ResourceRegistryService,
|
||||
)
|
||||
from cleveragents.application.services.strategy_models import StrategyTree
|
||||
from cleveragents.core.exceptions import PlanError, ValidationError
|
||||
from cleveragents.domain.models.core.change import ChangeSetStore
|
||||
@@ -51,6 +60,9 @@ from cleveragents.domain.models.observability.metrics import (
|
||||
MetricCollector,
|
||||
OperationalMetricKey,
|
||||
)
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
NamespacedProjectRepository,
|
||||
)
|
||||
from cleveragents.infrastructure.sandbox.checkpoint import (
|
||||
CheckpointManager,
|
||||
SandboxCheckpoint,
|
||||
@@ -326,6 +338,9 @@ class PlanExecutor:
|
||||
fix_revalidate_orchestrator: FixThenRevalidateOrchestrator | None = None,
|
||||
subplan_service: SubplanService | None = None,
|
||||
subplan_execution_service: SubplanExecutionService | None = None,
|
||||
tier_service: ContextTierService | None = None,
|
||||
project_repository: NamespacedProjectRepository | None = None,
|
||||
resource_registry: ResourceRegistryService | None = None,
|
||||
) -> None:
|
||||
"""Initialize the plan executor.
|
||||
|
||||
@@ -359,6 +374,12 @@ class PlanExecutor:
|
||||
subplan_execution_service: Optional service for executing
|
||||
spawned child plans. When ``None``, child plan
|
||||
execution is skipped even if subplans were spawned.
|
||||
tier_service: Optional context tier service for hydrating
|
||||
project resources during strategize.
|
||||
project_repository: Optional project repository for looking
|
||||
up project links during strategize.
|
||||
resource_registry: Optional resource registry for resolving
|
||||
resource locations during strategize.
|
||||
"""
|
||||
if lifecycle_service is None:
|
||||
raise ValidationError("lifecycle_service must not be None")
|
||||
@@ -373,6 +394,9 @@ class PlanExecutor:
|
||||
self._fix_revalidate_orchestrator = fix_revalidate_orchestrator
|
||||
self._subplan_service = subplan_service
|
||||
self._subplan_execution_service = subplan_execution_service
|
||||
self._tier_service = tier_service
|
||||
self._project_repository = project_repository
|
||||
self._resource_registry = resource_registry
|
||||
self._strategize_actor = strategize_actor or StrategizeStubActor()
|
||||
self._execute_actor = execute_actor or ExecuteStubActor()
|
||||
self._logger = logger.bind(service="plan_executor")
|
||||
@@ -744,6 +768,51 @@ class PlanExecutor:
|
||||
resources = [link.project_name for link in plan.project_links]
|
||||
project_context = ", ".join(resources)
|
||||
|
||||
# Hydrate context tiers from linked project resources
|
||||
# so the StrategyActor has actual project content to work with.
|
||||
if (
|
||||
self._tier_service is not None
|
||||
and self._project_repository is not None
|
||||
and self._resource_registry is not None
|
||||
):
|
||||
# Check if already hydrated (skip if fragments exist)
|
||||
existing_frags = self._tier_service.get_hot_fragments()
|
||||
if existing_frags:
|
||||
self._logger.info(
|
||||
"tier_hydration_skipped_already_hydrated",
|
||||
plan_id=plan_id,
|
||||
fragment_count=len(existing_frags),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
self._logger.info(
|
||||
"starting_tier_hydration",
|
||||
plan_id=plan_id,
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
hydrate_tiers_for_plan(
|
||||
tier_service=self._tier_service,
|
||||
project_names=resources,
|
||||
project_repository=self._project_repository,
|
||||
resource_registry=self._resource_registry,
|
||||
)
|
||||
|
||||
# Check what's in the tier service after hydration
|
||||
hot_frags = self._tier_service.get_hot_fragments()
|
||||
self._logger.info(
|
||||
"tier_hydration_complete",
|
||||
plan_id=plan_id,
|
||||
fragment_count=len(hot_frags) if hot_frags else 0,
|
||||
)
|
||||
except Exception as hydration_exc:
|
||||
self._logger.warning(
|
||||
"strategize_context_hydration_failed",
|
||||
error=str(hydration_exc),
|
||||
plan_id=plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# StrategyActor.execute() accepts resources/project_context;
|
||||
# StrategizeStubActor.execute() ignores unknown kwargs via
|
||||
# its signature — pass them as keyword args so both actors work.
|
||||
|
||||
@@ -33,6 +33,9 @@ from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from ulid import ULID
|
||||
|
||||
from cleveragents.application.services.context_tiers import (
|
||||
ContextTierService,
|
||||
)
|
||||
from cleveragents.application.services.plan_executor import (
|
||||
StrategizeResult,
|
||||
StrategyDecision,
|
||||
@@ -145,6 +148,7 @@ class StrategyActor:
|
||||
provider_registry: ProviderRegistry | None = None,
|
||||
lifecycle_service: LifecycleService | None = None,
|
||||
acms_pipeline: AcmsPipeline | None = None,
|
||||
tier_service: ContextTierService | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Strategy Actor.
|
||||
|
||||
@@ -154,10 +158,13 @@ class StrategyActor:
|
||||
lifecycle_service: Optional lifecycle service for plan/action
|
||||
resolution.
|
||||
acms_pipeline: Optional ACMS pipeline for code analysis context.
|
||||
tier_service: Optional context tier service for accessing
|
||||
hydrated project context during strategize.
|
||||
"""
|
||||
self._registry = provider_registry
|
||||
self._lifecycle = lifecycle_service
|
||||
self._acms_pipeline = acms_pipeline
|
||||
self._tier_service = tier_service
|
||||
self._logger = logger.bind(actor="strategy_actor")
|
||||
|
||||
@property
|
||||
@@ -468,17 +475,71 @@ class StrategyActor:
|
||||
|
||||
llm = self._registry.create_llm(provider_type=provider_type, model_id=model_id)
|
||||
|
||||
# Gather ACMS context if pipeline available
|
||||
# Gather ACMS context if pipeline or tier service available
|
||||
acms_context: str | None = None
|
||||
if self._acms_pipeline is not None:
|
||||
|
||||
# First try: tier_service (most direct access to hydrated context)
|
||||
if self._tier_service is not None:
|
||||
try:
|
||||
all_fragments = self._tier_service.get_hot_fragments()
|
||||
if all_fragments:
|
||||
file_count = len(all_fragments)
|
||||
total_tokens = sum(f.token_count for f in all_fragments)
|
||||
|
||||
languages: dict[str, int] = {}
|
||||
for frag in all_fragments:
|
||||
path = frag.metadata.get("path", "") if frag.metadata else ""
|
||||
if path:
|
||||
ext = path.split(".")[-1] if "." in path else ""
|
||||
languages[ext] = languages.get(ext, 0) + 1
|
||||
|
||||
lang_summary = ", ".join(
|
||||
f"{ext}: {count}" for ext, count in sorted(languages.items())
|
||||
)
|
||||
|
||||
# Build context with actual file content, not just metadata
|
||||
context_parts = [
|
||||
f"Available context: {file_count} files, "
|
||||
f"~{total_tokens} tokens.",
|
||||
f"File types: {lang_summary}",
|
||||
"",
|
||||
"--- Source Files (hot tier) ---",
|
||||
]
|
||||
|
||||
# Include actual file content for key files (limit to prevent
|
||||
# token overflow). Take up to 20 fragments with their content.
|
||||
for frag in all_fragments[:20]:
|
||||
path = (
|
||||
frag.metadata.get("path", "unknown")
|
||||
if frag.metadata
|
||||
else "unknown"
|
||||
)
|
||||
# Truncate long files
|
||||
content = frag.content[:2000] if frag.content else ""
|
||||
if content:
|
||||
context_parts.append(
|
||||
f"\n### {path}\n```\n{content}\n```"
|
||||
)
|
||||
|
||||
if len(all_fragments) > 20:
|
||||
context_parts.append(
|
||||
f"\n... and {len(all_fragments) - 20} more files"
|
||||
)
|
||||
|
||||
acms_context = "\n".join(context_parts)
|
||||
except Exception:
|
||||
self._logger.debug(
|
||||
"Tier service context retrieval failed (non-fatal)",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Second try: acms_pipeline (fallback for backwards compatibility)
|
||||
if acms_context is None and self._acms_pipeline is not None:
|
||||
try:
|
||||
acms_result = self._acms_pipeline.get_context_summary()
|
||||
acms_context = str(acms_result) if acms_result else None
|
||||
except (RuntimeError, ConnectionError, TimeoutError, ValueError, OSError):
|
||||
# ACMS pipeline failures are explicitly non-fatal;
|
||||
# strategy generation proceeds without context enrichment.
|
||||
# Catches transient/environmental errors (network, timeout, resource).
|
||||
# Programming errors (AttributeError, TypeError, etc.) propagate.
|
||||
# ACMS pipeline failures are explicitly non-fatal
|
||||
self._logger.debug(
|
||||
"ACMS context retrieval failed (non-fatal)",
|
||||
exc_info=True,
|
||||
|
||||
@@ -11,6 +11,9 @@ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.application.services.context_tiers import (
|
||||
ContextTierService,
|
||||
)
|
||||
from cleveragents.core.exceptions import PlanError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -134,6 +137,7 @@ def resolve_strategy_actor(
|
||||
provider_registry: ProviderRegistry | None = None,
|
||||
lifecycle_service: LifecycleService | None = None,
|
||||
acms_pipeline: AcmsPipeline | None = None,
|
||||
tier_service: ContextTierService | None = None,
|
||||
config_value: str | None = None,
|
||||
) -> Any: # Returns StrategyActor | None to avoid circular import
|
||||
"""Resolve the strategy actor based on configuration.
|
||||
@@ -150,6 +154,7 @@ def resolve_strategy_actor(
|
||||
provider_registry: Optional provider registry.
|
||||
lifecycle_service: Optional lifecycle service.
|
||||
acms_pipeline: Optional ACMS pipeline.
|
||||
tier_service: Optional context tier service.
|
||||
config_value: Value of the ``actor.default.strategy`` config key.
|
||||
|
||||
Returns:
|
||||
@@ -171,6 +176,7 @@ def resolve_strategy_actor(
|
||||
provider_registry=provider_registry,
|
||||
lifecycle_service=lifecycle_service,
|
||||
acms_pipeline=acms_pipeline,
|
||||
tier_service=tier_service,
|
||||
)
|
||||
|
||||
if config_value is not None:
|
||||
|
||||
@@ -1527,18 +1527,13 @@ def _create_sandbox_for_plan(
|
||||
)
|
||||
)
|
||||
|
||||
if sandboxes:
|
||||
# Always use the first resource's worktree as sandbox_root
|
||||
# (backward compatible — PlanExecutor and LLMExecuteActor
|
||||
# write all FILE: blocks here). For multi-resource plans,
|
||||
# _route_sandbox_files_to_worktrees() redistributes files
|
||||
# to the correct worktrees after execute completes.
|
||||
return sandboxes[0].sandbox_path, sandboxes
|
||||
# Always use local plan-output directory for better discoverability.
|
||||
# This ensures users can find plan output directly in their working directory
|
||||
# rather than in /tmp/ or hidden .cleveragents/ directories.
|
||||
sandbox_base = os.path.join(os.getcwd(), "plan-output", plan_id[:8])
|
||||
os.makedirs(sandbox_base, exist_ok=True)
|
||||
|
||||
# Fallback: flat directory sandbox
|
||||
flat_root = os.path.join(os.getcwd(), ".cleveragents", "sandbox")
|
||||
os.makedirs(flat_root, exist_ok=True)
|
||||
return flat_root, []
|
||||
return sandbox_base, sandboxes
|
||||
|
||||
|
||||
def _apply_sandbox_changes(
|
||||
@@ -1836,6 +1831,7 @@ def _apply_sandbox_changes(
|
||||
|
||||
def _route_sandbox_files_to_worktrees(
|
||||
sandbox_infos: list[_SandboxInfo],
|
||||
plan_output_path: str | None = None,
|
||||
) -> None:
|
||||
"""Route files from the primary sandbox to per-resource worktrees.
|
||||
|
||||
@@ -1845,10 +1841,30 @@ def _route_sandbox_files_to_worktrees(
|
||||
worktrees by matching file paths against each resource's known
|
||||
file list (via ``git ls-files``).
|
||||
|
||||
Also handles the plan-output/ directory - if the LLM wrote files there
|
||||
(via the discoverable sandbox path), this function copies them to the
|
||||
primary worktree so they get committed.
|
||||
|
||||
Per spec §19310: each resource gets its own sandbox.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
# Handle plan-output/ → worktree copying
|
||||
# The LLM writes to the discoverable plan-output/ path, but we need
|
||||
# to copy those files to the worktree for commit (unless there's a
|
||||
# specific worktree sandbox path)
|
||||
if plan_output_path and os.path.isdir(plan_output_path):
|
||||
primary = sandbox_infos[0] if sandbox_infos else None
|
||||
if primary and primary.sandbox_path != plan_output_path:
|
||||
# Copy all files from plan-output/ to primary worktree
|
||||
for dirpath, _dirnames, filenames in os.walk(plan_output_path):
|
||||
for fname in filenames:
|
||||
src = os.path.join(dirpath, fname)
|
||||
rel_path = os.path.relpath(src, plan_output_path)
|
||||
dst = os.path.join(primary.sandbox_path, rel_path)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
if len(sandbox_infos) <= 1:
|
||||
return # Single resource — nothing to route
|
||||
|
||||
@@ -2148,6 +2164,8 @@ def _get_plan_executor(
|
||||
strategize_actor = resolve_strategy_actor(
|
||||
provider_registry=registry,
|
||||
lifecycle_service=lifecycle_service,
|
||||
acms_pipeline=container.acms_pipeline(),
|
||||
tier_service=container.context_tier_service(),
|
||||
config_value=config_value,
|
||||
)
|
||||
|
||||
@@ -2173,6 +2191,9 @@ def _get_plan_executor(
|
||||
execute_actor=execute_actor,
|
||||
sandbox_root=sandbox_root,
|
||||
checkpoint_manager=checkpoint_manager,
|
||||
tier_service=container.context_tier_service(),
|
||||
project_repository=container.namespaced_project_repo(),
|
||||
resource_registry=container.resource_registry_service(),
|
||||
)
|
||||
|
||||
|
||||
@@ -2903,8 +2924,12 @@ def execute_plan(
|
||||
plan = service.get_plan(plan_id)
|
||||
|
||||
# Route files to correct per-resource worktrees (spec §19310)
|
||||
# then commit each worktree branch.
|
||||
_route_sandbox_files_to_worktrees(sandbox_infos)
|
||||
# then commit each worktree branch. Pass sandbox_root
|
||||
# (plan-output path) so it can copy files from the discoverable
|
||||
# location to worktrees.
|
||||
_route_sandbox_files_to_worktrees(
|
||||
sandbox_infos, plan_output_path=sandbox_root
|
||||
)
|
||||
for sinfo in sandbox_infos:
|
||||
_commit_worktree_changes(sinfo.sandbox_path, plan_id)
|
||||
|
||||
|
||||
@@ -388,7 +388,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# Context tier budgets (ACMS #208)
|
||||
context_max_tokens_hot: int = Field(
|
||||
default=16000,
|
||||
default=32000,
|
||||
gt=0,
|
||||
validation_alias=AliasChoices(
|
||||
"CLEVERAGENTS_CTX_HOT_TOKENS",
|
||||
|
||||
Reference in New Issue
Block a user