diff --git a/CHANGELOG.md b/CHANGELOG.md index 573b35c..4abef75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 5c6220d..d219a2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/codespy/__init__.py b/src/codespy/__init__.py index 2e4c32f..a2b6989 100644 --- a/src/codespy/__init__.py +++ b/src/codespy/__init__.py @@ -1,3 +1,3 @@ """codespy - Code review agent powered by DSPy.""" -__version__ = "1.2.3" +__version__ = "1.2.4" diff --git a/src/codespy/agents/review/audit/agent.py b/src/codespy/agents/review/audit/agent.py index 0e12b81..96cc8f8 100644 --- a/src/codespy/agents/review/audit/agent.py +++ b/src/codespy/agents/review/audit/agent.py @@ -9,6 +9,7 @@ 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 @@ -16,6 +17,7 @@ if TYPE_CHECKING: from codespy.agents.review.scope.models import ScopeResult + from codespy.agents.memory.hippocampus.episode import EpisodeStore logger = logging.getLogger(__name__) @@ -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} " @@ -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, @@ -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, @@ -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 diff --git a/src/codespy/agents/review/scope/agent.py b/src/codespy/agents/review/scope/agent.py index 5c4bb28..93939ab 100644 --- a/src/codespy/agents/review/scope/agent.py +++ b/src/codespy/agents/review/scope/agent.py @@ -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, @@ -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, @@ -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], @@ -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) @@ -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" diff --git a/src/codespy/config.py b/src/codespy/config.py index 6386e40..a9d2d91 100644 --- a/src/codespy/config.py +++ b/src/codespy/config.py @@ -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. diff --git a/src/codespy/config_dspy.py b/src/codespy/config_dspy.py index 512a4b3..daadb79 100644 --- a/src/codespy/config_dspy.py +++ b/src/codespy/config_dspy.py @@ -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) diff --git a/src/codespy/workflows/review/pipeline.py b/src/codespy/workflows/review/pipeline.py index 3de83a2..a3f2304 100644 --- a/src/codespy/workflows/review/pipeline.py +++ b/src/codespy/workflows/review/pipeline.py @@ -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,