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
42 changes: 42 additions & 0 deletions alembic/versions/0021_repro_code_experience.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""repro_code_experience table for CodeMemory persistence

Revision ID: 0021_repro_code_experience
Revises: 0020_memory_embedding
Create Date: 2026-03-03

Issue #162: Persist CodeMemory experience data so it survives process restarts.
Creates the repro_code_experience table with indexes on paper_id and pack_id.
"""
from __future__ import annotations

import sqlalchemy as sa
from alembic import op

revision = "0021_repro_code_experience"
down_revision = "0020_memory_embedding"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"repro_code_experience",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("pack_id", sa.String(64), nullable=True),
sa.Column("paper_id", sa.String(256), nullable=True),
sa.Column("pattern_type", sa.String(32), nullable=False),
sa.Column("content", sa.Text(), nullable=False, server_default=""),
sa.Column("code_snippet", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_repro_code_experience_paper_id", "repro_code_experience", ["paper_id"])
op.create_index("ix_repro_code_experience_pack_id", "repro_code_experience", ["pack_id"])
op.create_index("ix_repro_code_experience_pattern_type", "repro_code_experience", ["pattern_type"])


def downgrade() -> None:
op.drop_index("ix_repro_code_experience_pattern_type", table_name="repro_code_experience")
op.drop_index("ix_repro_code_experience_pack_id", table_name="repro_code_experience")
op.drop_index("ix_repro_code_experience_paper_id", table_name="repro_code_experience")
op.drop_table("repro_code_experience")
24 changes: 24 additions & 0 deletions src/paperbot/infrastructure/stores/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1258,3 +1258,27 @@ class ReproContextFeedbackModel(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)

pack = relationship("ReproContextPackModel", back_populates="feedback_rows")


# ============================================================================
# Issue #162: CodeMemory Persistence
# ============================================================================


class ReproCodeExperienceModel(Base):
"""Persisted code generation experience from the Paper2Code pipeline.

Stores success patterns, failure reasons, and verified structures so that
CodeMemory can pre-load prior experience when regenerating for the same paper.
"""

__tablename__ = "repro_code_experience"

id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
pack_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
paper_id: Mapped[Optional[str]] = mapped_column(String(256), nullable=True, index=True)
# pattern_type: success_pattern | failure_reason | verified_structure
pattern_type: Mapped[str] = mapped_column(String(32), index=True)
content: Mapped[str] = mapped_column(Text, default="")
code_snippet: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
99 changes: 99 additions & 0 deletions src/paperbot/infrastructure/stores/repro_experience_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""SQLAlchemy store for ReproCodeExperienceModel (issue #162)."""
from __future__ import annotations

from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

from sqlalchemy import select

from paperbot.infrastructure.stores.models import Base, ReproCodeExperienceModel
from paperbot.infrastructure.stores.sqlalchemy_db import SessionProvider, get_db_url

_VALID_TYPES = {"success_pattern", "failure_reason", "verified_structure"}


class ReproExperienceStore:
"""CRUD store for persisted code generation experiences."""

def __init__(self, db_url: Optional[str] = None, *, auto_create_schema: bool = True):
self.db_url = db_url or get_db_url()
self._provider = SessionProvider(self.db_url)
if auto_create_schema:
Base.metadata.create_all(self._provider.engine)

def add(
self,
*,
pattern_type: str,
content: str,
paper_id: Optional[str] = None,
pack_id: Optional[str] = None,
code_snippet: Optional[str] = None,
) -> ReproCodeExperienceModel:
"""Persist one experience record. Returns the saved row."""
if pattern_type not in _VALID_TYPES:
raise ValueError(f"pattern_type must be one of {_VALID_TYPES}")
now = datetime.now(timezone.utc)
row = ReproCodeExperienceModel(
pack_id=pack_id,
paper_id=paper_id,
pattern_type=pattern_type,
content=(content or "").strip(),
code_snippet=code_snippet,
created_at=now,
)
with self._provider.session() as session:
session.add(row)
session.commit()
session.refresh(row)
return row

def get_by_paper_id(
self,
paper_id: str,
*,
pattern_type: Optional[str] = None,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""Retrieve experiences for a specific paper, newest first."""
with self._provider.session() as session:
stmt = (
select(ReproCodeExperienceModel)
.where(ReproCodeExperienceModel.paper_id == paper_id)
)
if pattern_type:
stmt = stmt.where(ReproCodeExperienceModel.pattern_type == pattern_type)
stmt = stmt.order_by(ReproCodeExperienceModel.created_at.desc()).limit(limit)
rows = session.execute(stmt).scalars().all()
return [self._to_dict(r) for r in rows]

def get_by_pack_id(
self,
pack_id: str,
*,
pattern_type: Optional[str] = None,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""Retrieve experiences for a specific P2C pack, newest first."""
with self._provider.session() as session:
stmt = (
select(ReproCodeExperienceModel)
.where(ReproCodeExperienceModel.pack_id == pack_id)
)
if pattern_type:
stmt = stmt.where(ReproCodeExperienceModel.pattern_type == pattern_type)
stmt = stmt.order_by(ReproCodeExperienceModel.created_at.desc()).limit(limit)
rows = session.execute(stmt).scalars().all()
return [self._to_dict(r) for r in rows]

@staticmethod
def _to_dict(r: ReproCodeExperienceModel) -> Dict[str, Any]:
return {
"id": r.id,
"pack_id": r.pack_id,
"paper_id": r.paper_id,
"pattern_type": r.pattern_type,
"content": r.content,
"code_snippet": r.code_snippet,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
22 changes: 21 additions & 1 deletion src/paperbot/repro/agents/debugging_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,11 @@ class DebuggingAgent(BaseAgent):
"bs4": "beautifulsoup4",
}

def __init__(self, output_dir: Optional[Path] = None, **kwargs):
def __init__(self, output_dir: Optional[Path] = None, experience_store=None, **kwargs):
super().__init__(name="DebuggingAgent", **kwargs)
self.output_dir = output_dir
self.repair_history: List[RepairAttempt] = []
self._experience_store = experience_store

async def execute(self, context: Dict[str, Any]) -> AgentResult:
"""Execute debugging pipeline."""
Expand Down Expand Up @@ -143,6 +144,25 @@ async def execute(self, context: Dict[str, Any]) -> AgentResult:
if repair_result.success:
self.log(f"Repair successful: {repair_result.fix_applied}")
context["last_repair"] = repair_result

# Persist failure reason + fix for future runs (issue #162)
if self._experience_store:
paper_context = context.get("paper_context")
paper_id = (
getattr(paper_context, "paper_id", None)
or getattr(paper_context, "arxiv_id", None)
if paper_context else None
)
try:
self._experience_store.add(
pattern_type="failure_reason",
content=f"[{error_type.value}] fixed: {repair_result.fix_applied}",
paper_id=paper_id,
code_snippet=repair_result.original_error[:1000],
)
except Exception: # noqa: BLE001
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Silently swallowing exceptions with except Exception: pass can hide important issues with the database or persistence logic. It's better to log the exception, even if you don't want it to interrupt the agent's execution. This will make debugging much easier if experiences are not being saved as expected. Consider using self.logger.warning with exc_info=True.

Suggested change
pass
self.logger.warning("Failed to persist code experience for failure reason.", exc_info=True)


return AgentResult.success(
data={
"repair": repair_result,
Expand Down
125 changes: 123 additions & 2 deletions src/paperbot/repro/memory/code_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple

from .symbol_index import SymbolIndex, SymbolInfo

if TYPE_CHECKING:
from paperbot.infrastructure.stores.repro_experience_store import ReproExperienceStore

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -58,17 +61,25 @@ class CodeMemory:
# Approximate tokens per character (conservative estimate)
CHARS_PER_TOKEN = 4

def __init__(self, max_context_tokens: int = 8000):
def __init__(
self,
max_context_tokens: int = 8000,
experience_store: "Optional[ReproExperienceStore]" = None,
):
"""
Initialize CodeMemory.

Args:
max_context_tokens: Maximum tokens for context injection
experience_store: Optional store for persisting/loading experiences
"""
self.max_context_tokens = max_context_tokens
self._files: Dict[str, FileInfo] = {}
self._symbol_index = SymbolIndex()
self._generation_order: List[str] = []
self._experience_store: "Optional[ReproExperienceStore]" = experience_store
# Prior experiences loaded from DB for context injection
self._prior_experiences: List[Dict] = []

def add_file(self, path: str, content: str, purpose: str = "") -> None:
"""
Expand Down Expand Up @@ -198,6 +209,19 @@ def get_relevant_context(
if len(interfaces) < remaining_chars:
context_parts.append(f"\n# === Available Interfaces ===\n{interfaces}")

# 4. Prior experiences from DB (success patterns / verified structures)
if self._prior_experiences and remaining_chars > 200:
exp_lines = []
for exp in self._prior_experiences[:5]:
ptype = exp.get("pattern_type", "")
content = exp.get("content", "")
if ptype in ("success_pattern", "verified_structure") and content:
exp_lines.append(f" [{ptype}] {content}")
if exp_lines:
prior_ctx = "# === Prior Experience (same paper) ===\n" + "\n".join(exp_lines)
if len(prior_ctx) < remaining_chars:
context_parts.append(prior_ctx)
Comment on lines +212 to +223

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The get_relevant_context method retrieves 'prior experiences' from the database and directly formats their content into the LLM prompt context. These experiences are populated in GenerationNode and VerificationNode using filenames and directory names. If an attacker can influence these names (e.g., by providing a malicious paper that tricks the planning agent into using specific filenames), they can inject malicious instructions into the prompt. Since the generated code is subsequently executed by the VerificationNode during import checks, this could lead to arbitrary code execution on the system.


return "\n\n".join(context_parts)

def _predict_dependencies(self, current_file: str) -> List[str]:
Expand Down Expand Up @@ -325,11 +349,108 @@ def get_dependency_graph(self) -> Dict[str, Set[str]]:
"""Get the file dependency graph."""
return {path: info.dependencies for path, info in self._files.items()}

# ------------------------------------------------------------------
# Persistence helpers (issue #162)
# ------------------------------------------------------------------

def load_experiences_from_db(
self,
paper_id: str,
*,
pack_id: Optional[str] = None,
limit: int = 20,
) -> None:
"""Pre-load prior experiences for *paper_id* from the DB into memory.

Loaded records are stored in ``_prior_experiences`` and injected into
``get_relevant_context()`` so the LLM can see what worked before.
"""
if not self._experience_store or not paper_id:
return
try:
rows = self._experience_store.get_by_paper_id(paper_id, limit=limit)
if pack_id:
pack_rows = self._experience_store.get_by_pack_id(pack_id, limit=limit)
seen_ids = {r["id"] for r in rows}
rows += [r for r in pack_rows if r["id"] not in seen_ids]
self._prior_experiences = rows
logger.debug("Loaded %d prior experiences for paper_id=%s", len(rows), paper_id)
except Exception: # noqa: BLE001
logger.debug("Failed to load experiences from DB", exc_info=True)

def record_success_pattern(
self,
*,
paper_id: Optional[str],
pack_id: Optional[str] = None,
filepath: str,
code_snippet: Optional[str] = None,
) -> None:
"""Record that *filepath* was successfully generated (best-effort)."""
if not self._experience_store:
return
try:
self._experience_store.add(
pattern_type="success_pattern",
content=f"Successfully generated {filepath}",
paper_id=paper_id,
pack_id=pack_id,
code_snippet=(code_snippet or "")[:2000] or None,
)
except Exception: # noqa: BLE001
logger.debug("Failed to record success_pattern", exc_info=True)

def record_verified_structure(
self,
*,
paper_id: Optional[str],
pack_id: Optional[str] = None,
description: str,
code_snippet: Optional[str] = None,
) -> None:
"""Record that a code structure passed verification (best-effort)."""
if not self._experience_store:
return
try:
self._experience_store.add(
pattern_type="verified_structure",
content=description,
paper_id=paper_id,
pack_id=pack_id,
code_snippet=(code_snippet or "")[:2000] or None,
)
except Exception: # noqa: BLE001
logger.debug("Failed to record verified_structure", exc_info=True)

def record_failure_reason(
self,
*,
paper_id: Optional[str],
pack_id: Optional[str] = None,
error_type: str,
fix_applied: str,
code_snippet: Optional[str] = None,
) -> None:
"""Record a debugging fix so future runs can avoid the same error (best-effort)."""
if not self._experience_store:
return
try:
self._experience_store.add(
pattern_type="failure_reason",
content=f"[{error_type}] fixed: {fix_applied}",
paper_id=paper_id,
pack_id=pack_id,
code_snippet=(code_snippet or "")[:2000] or None,
)
except Exception: # noqa: BLE001
logger.debug("Failed to record failure_reason", exc_info=True)

def clear(self) -> None:
"""Clear all memory."""
self._files.clear()
self._symbol_index = SymbolIndex()
self._generation_order.clear()
self._prior_experiences.clear()

@property
def files(self) -> Dict[str, str]:
Expand Down
Loading
Loading