Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

## [Unreleased]

## [1.2.4] - 2026-09-18

### Changed
- **Performance**: Scope resolution fast-path for clean single-scope repos
- When deterministic resolution finds exactly 1 scope and 0 orphans, skip expensive ReAct LLM refinement
- New `skip_refinement_when_clean` setting on scope signature (default: `true`)
- Env var: `SCOPE_SKIP_REFINEMENT_WHEN_CLEAN=true|false`
- Improves scope duration from ~45s to <1s for typical single-scope repositories
- Enhanced deterministic scope descriptions to include package name and scope type
- **Performance**: Audit episode save now runs in background
- Moved `hippo.end_episode()` from synchronous to `submit_episode_save()` background thread
- Audit duration drops from ~48s to ~5-15s (just the ChainOfThought LLM call + memory load)
- Stats collection now runs after `join_episode_saves()` to ensure complete distiller/cartographer stats

### Added
- New `SignatureConfig.skip_refinement_when_clean` field (scope-specific optimization)
- New `Settings.get_scope_skip_refinement()` method (returns `True` by default)
- New `_finalize_scopes()` helper in scope resolver for skills attachment

## [1.2.3] - 2026-09-16

### Changed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "codespy-ai"
version = "1.2.3"
version = "1.2.4"
description = "Code review agent powered by DSPy"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/codespy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""codespy - Code review agent powered by DSPy."""

__version__ = "1.2.3"
__version__ = "1.2.4"
43 changes: 27 additions & 16 deletions src/codespy/agents/review/audit/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
from codespy.agents.context_safe import ContextSafe
from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus, inject_context_memory
from codespy.agents.memory.hippocampus.context_memory import Topic
from codespy.agents.memory.hippocampus.episode import submit_episode_save
from codespy.agents.review.models import Issue, ReviewContext
from codespy.agents.review.helpers import deepest_common_folder
from codespy.config import get_settings
from codespy.config_memory import get_episode_store

if TYPE_CHECKING:
from codespy.agents.review.scope.models import ScopeResult
from codespy.agents.memory.hippocampus.episode import EpisodeStore

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -57,8 +59,13 @@ def _call_auditor(
run_id: str | None,
scopes: list["ScopeResult"] | None,
topics: list["ScopeResult"] | None = None,
) -> dspy.Prediction:
"""Execute the auditor predictor (with or without Hippocampus memory)."""
) -> tuple[dspy.Prediction, Hippocampus | None, "EpisodeStore | None", dict[str, str] | None]:
"""Execute the auditor predictor (with or without Hippocampus memory).

Returns:
Tuple of (result, hippo, store, artifacts) where hippo/store/artifacts
are None when memory is disabled.
"""
question = (
f"final audit of {review_context.pr_context.repo_slug}: "
f"pull request {review_context.pr_context.pr_number} "
Expand Down Expand Up @@ -110,25 +117,20 @@ def _call_auditor(
all_issues=all_issues,
)
hippo.observe(result)
# Run episode save synchronously (auditor is the last module)
try:
_artifacts = {
"audit": (
f"## Quality Assessment\n\n{result.quality_assessment}\n\n"
f"## Recommendation\n\n{result.recommendation}\n"
)
}
hippo.end_episode(store, artifacts=_artifacts)
except Exception:
logger.warning("Audit episode save failed", exc_info=True)
_artifacts = {
"audit": (
f"## Quality Assessment\n\n{result.quality_assessment}\n\n"
f"## Recommendation\n\n{result.recommendation}\n"
)
}
return result, hippo, store, _artifacts
else:
result = auditor(
pr_title=review_context.pr_context.pr_title,
summary=review_context.pr_context.summary,
all_issues=all_issues,
)

return result
return result, None, None, None

def forward(
self,
Expand Down Expand Up @@ -168,7 +170,7 @@ def forward(
logger.info("Running audit...")

with SignatureContext("audit", self._cost_tracker):
result = self._call_auditor(
result, hippo, store, _artifacts = self._call_auditor(
auditor,
review_context,
all_issues,
Expand All @@ -177,4 +179,13 @@ def forward(
topics,
)

# Background episode save (after SignatureContext closes)
if hippo is not None and store is not None:
def _persist():
try:
hippo.end_episode(store, artifacts=_artifacts)
except Exception:
logger.warning("Audit episode save failed", exc_info=True)
submit_episode_save(_persist, name="audit-episode-save")

return result.quality_assessment, result.recommendation
66 changes: 57 additions & 9 deletions src/codespy/agents/review/scope/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,10 +625,17 @@ def _resolve(
# Extract package name from manifest
package_name = extract_package_name(manifest_path, repo_path)

# Build deterministic description
description = (
f"{pkg_mgr} package at {subroot}" if subroot != "." else f"{pkg_mgr} package (root)"
)
# Build deterministic description with package name and scope type
parts = []
if package_name:
parts.append(package_name)
parts.append(f"{scope_type.value}")
if subroot != ".":
parts.append(f"at {subroot}")
else:
parts.append("(repository root)")
parts.append(f"managed by {pkg_mgr}")
description = " — ".join([parts[0], " ".join(parts[1:])]) if len(parts) > 1 else " ".join(parts)

scopes[subroot] = ScopeResult(
repo=repo,
Expand Down Expand Up @@ -658,7 +665,9 @@ def _resolve(
indicator_type, indicator_path = self._find_scope_indicator(file.filename)
if indicator_path and indicator_type and indicator_path not in scopes:
# Build deterministic description for indicator-based scope
description = f"{indicator_type.value} scope at {indicator_path}"
parts = [f"{indicator_type.value}", "scope"]
parts.append(f"at {indicator_path}")
description = " ".join(parts)
scopes[indicator_path] = ScopeResult(
repo=repo,
subroot=indicator_path,
Expand Down Expand Up @@ -978,6 +987,33 @@ def _apply_boundaries(

return [s for s in final_boundaries.values() if s.changed_files]

def _finalize_scopes(
self,
scopes: list[ScopeResult],
review_context: ReviewContext,
) -> list[ScopeResult]:
"""Finalize scopes by building topics and attaching hierarchical skills.

This helper is called both by the fast-path (deterministic resolution only)
and by _refine_scopes after LLM refinement. It does NOT handle Hippocampus
binding or episode persistence — those are handled separately.

Args:
scopes: List of ScopeResult to finalize
review_context: Review context with PR identity and metadata

Returns:
Finalized list of ScopeResult with skills attached
"""
pr = review_context.metadata.pr
repo_path = review_context.metadata.repo_path

# Attach hierarchical skills to each scope
for scope in scopes:
scope.skills = collect_skills(repo_path, scope.subroot)

return scopes

async def _refine_scopes(
self,
scopes: list[ScopeResult],
Expand Down Expand Up @@ -1120,9 +1156,8 @@ async def _refine_scopes(
# Add PR URL topic (provides description; not a scope)
pr_ctx = review_context.pr_context
scope_topics.append(pr_ctx.to_topic())
# Attach hierarchical skills to each produced scope
for scope in final_scopes:
scope.skills = collect_skills(repo_path, scope.subroot)
# Finalize scopes with hierarchical skills
self._finalize_scopes(final_scopes, review_context)
# Bind topics to hippocampus cmem for episode persistence
if hippo is not None and stamp_topic_ids:
stamp_topic_ids.append(pr_ctx.pr_url)
Expand Down Expand Up @@ -1204,7 +1239,20 @@ async def aforward(
)
if orphans:
logger.info("Deterministic identification produced %d orphan(s)", len(orphans))
scopes = await self._refine_scopes(scopes, orphans, review_context)

# Fast-path: skip LLM refinement when clean single-scope resolution
if (
len(scopes) == 1
and not orphans
and self._settings.get_scope_skip_refinement()
):
logger.info(
"Deterministic resolution sufficient (1 scope, 0 orphans); "
"skipping LLM refinement"
)
scopes = self._finalize_scopes(scopes, review_context)
else:
scopes = await self._refine_scopes(scopes, orphans, review_context)
# Log final scopes for visibility
scope_summary = "\n".join(
f" - {s.subroot} ({s.scope_type.value}): {len(s.changed_files)} files"
Expand Down
12 changes: 12 additions & 0 deletions src/codespy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,18 @@ def get_memory_budget(self, signature_name: str) -> "MemoryBudget":
compact_trajectory=self.memory.compact_trajectory,
)

def get_scope_skip_refinement(self) -> bool:
"""Whether to skip LLM refinement when deterministic resolution is clean.

Returns True (skip) when:
- There's 1 scope and 0 orphans (single scope, all files assigned)
- The setting skip_refinement_when_clean is True or not explicitly set

Per-signature ``skip_refinement_when_clean`` overrides default (True).
"""
val = self.get_signature_config("scope").skip_refinement_when_clean
return val if val is not None else True # default: skip

def get_rlm_threshold(self, module_type: str) -> float:
"""Resolve RLM fallback threshold for a module type.

Expand Down
1 change: 1 addition & 0 deletions src/codespy/config_dspy.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class SignatureConfig(BaseModel):
temperature: float | None = None
max_tokens: int | None = None # Output token budget (reasoning tokens included)
scan_unchanged: bool | None = None # For supply_chain: scan unmodified artifacts/manifests
skip_refinement_when_clean: bool | None = None # For scope: skip LLM refinement when 1 scope, 0 orphans

memory: MemorySignatureConfig = Field(default_factory=MemorySignatureConfig)

Expand Down
7 changes: 4 additions & 3 deletions src/codespy/workflows/review/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,10 +249,11 @@ def forward(self, config: ReviewConfig) -> ReviewResult:
scopes=scopes,
topics=all_scope_topics,
)
# Collect per-signature statistics
signature_stats_list = self._collect_signature_stats()
# Ensure all background episode saves complete before returning
# Ensure all background episode saves complete before stats collection
# (audit's episode save runs in background and contains distiller/cartographer calls)
join_episode_saves()
# Collect per-signature statistics (after all saves complete)
signature_stats_list = self._collect_signature_stats()
return ReviewResult(
pr_number=pr.number,
pr_title=pr.title,
Expand Down
Loading