From 50b71c271fd8d5e23d1cf8d964223c10242664cd Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Wed, 16 Sep 2026 23:41:34 +0200 Subject: [PATCH 1/2] refactor --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/codespy/__init__.py | 2 +- src/codespy/agents/memory/hippocampus/__init__.py | 4 ++-- .../hippocampus/{modules => }/cartographer.py | 0 .../memory/hippocampus/{modules => }/distiller.py | 0 .../agents/memory/hippocampus/hippocampus.py | 4 ++-- .../agents/memory/hippocampus/modules/__init__.py | 13 ------------- 8 files changed, 15 insertions(+), 19 deletions(-) rename src/codespy/agents/memory/hippocampus/{modules => }/cartographer.py (100%) rename src/codespy/agents/memory/hippocampus/{modules => }/distiller.py (100%) delete mode 100644 src/codespy/agents/memory/hippocampus/modules/__init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d6985..4c325f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +## [1.2.3] - 2026-09-16 + +### Changed +- **Internal**: Flattened `hippocampus/modules/` directory structure + - Moved `distiller.py` and `cartographer.py` from `hippocampus/modules/` to `hippocampus/` package root + - Removed `hippocampus/modules/__init__.py` and deleted `modules/` subdirectory + - Updated imports in `hippocampus.py` and `hippocampus/__init__.py` to reference new locations + - No external API changes — imports through `hippocampus/__init__.py` remain unchanged + ## [1.2.2] - 2026-09-16 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 623be61..5c6220d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "codespy-ai" -version = "1.2.2" +version = "1.2.3" 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 c6c0813..2e4c32f 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.2" +__version__ = "1.2.3" diff --git a/src/codespy/agents/memory/hippocampus/__init__.py b/src/codespy/agents/memory/hippocampus/__init__.py index 421c093..63b8e1e 100644 --- a/src/codespy/agents/memory/hippocampus/__init__.py +++ b/src/codespy/agents/memory/hippocampus/__init__.py @@ -18,8 +18,8 @@ submit_episode_save, ) from codespy.agents.memory.hippocampus.hippocampus import Hippocampus -from codespy.agents.memory.hippocampus.modules.cartographer import Cartographer, CartographerSig -from codespy.agents.memory.hippocampus.modules.distiller import Distiller, DistillerSig +from codespy.agents.memory.hippocampus.cartographer import Cartographer, CartographerSig +from codespy.agents.memory.hippocampus.distiller import Distiller, DistillerSig __all__ = [ "CacheCandidate", diff --git a/src/codespy/agents/memory/hippocampus/modules/cartographer.py b/src/codespy/agents/memory/hippocampus/cartographer.py similarity index 100% rename from src/codespy/agents/memory/hippocampus/modules/cartographer.py rename to src/codespy/agents/memory/hippocampus/cartographer.py diff --git a/src/codespy/agents/memory/hippocampus/modules/distiller.py b/src/codespy/agents/memory/hippocampus/distiller.py similarity index 100% rename from src/codespy/agents/memory/hippocampus/modules/distiller.py rename to src/codespy/agents/memory/hippocampus/distiller.py diff --git a/src/codespy/agents/memory/hippocampus/hippocampus.py b/src/codespy/agents/memory/hippocampus/hippocampus.py index bd40ce9..18238ee 100644 --- a/src/codespy/agents/memory/hippocampus/hippocampus.py +++ b/src/codespy/agents/memory/hippocampus/hippocampus.py @@ -29,8 +29,8 @@ _PREFIX_TO_SECTION, ) from codespy.agents.memory.hippocampus.episode import Episode -from codespy.agents.memory.hippocampus.modules.distiller import Distiller -from codespy.agents.memory.hippocampus.modules.cartographer import Cartographer +from codespy.agents.memory.hippocampus.distiller import Distiller +from codespy.agents.memory.hippocampus.cartographer import Cartographer if TYPE_CHECKING: from codespy.agents.memory.postgres import EpisodeStore diff --git a/src/codespy/agents/memory/hippocampus/modules/__init__.py b/src/codespy/agents/memory/hippocampus/modules/__init__.py deleted file mode 100644 index 1099e60..0000000 --- a/src/codespy/agents/memory/hippocampus/modules/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""DSPy modules for the hippocampus agent.""" - -from codespy.agents.memory.hippocampus.context_memory import Mutation -from codespy.agents.memory.hippocampus.modules.cartographer import Cartographer, CartographerSig -from codespy.agents.memory.hippocampus.modules.distiller import Distiller, DistillerSig - -__all__ = [ - "Cartographer", - "CartographerSig", - "Distiller", - "DistillerSig", - "Mutation", -] From b02923a584e5348370ad42835bc98efdd3407197 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Thu, 17 Sep 2026 14:23:33 +0200 Subject: [PATCH 2/2] refactor --- .env.example | 10 +- CHANGELOG.md | 8 + action.yml | 7 - codespy.yaml | 9 +- docs/configuration.md | 1 - docs/memory.md | 6 +- src/codespy/agents/context_safe.py | 6 +- .../agents/memory/hippocampus/__init__.py | 6 +- .../agents/memory/hippocampus/hippocampus.py | 257 ++++++++---------- src/codespy/agents/review/audit/agent.py | 16 +- .../agents/review/code_review/agent.py | 19 +- src/codespy/agents/review/doc/agent.py | 19 +- src/codespy/agents/review/scope/agent.py | 24 +- src/codespy/agents/review/summary/agent.py | 17 +- .../agents/review/supply_chain/agent.py | 19 +- src/codespy/config.py | 13 +- src/codespy/config_dspy.py | 1 - src/codespy/config_memory.py | 6 +- tests/test_hippocampus.py | 20 +- 19 files changed, 201 insertions(+), 263 deletions(-) diff --git a/.env.example b/.env.example index 6a98f25..9dab44b 100644 --- a/.env.example +++ b/.env.example @@ -210,10 +210,8 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # MEMORY_PG0_PORT=5432 # MEMORY_PG0_DATA_DIR=~/.cache/codespy/pg0 # default; persists on Docker-mounted codespy-cache volume -# Reflection defaults — overridable per-signature via _MEMORY_* +# Memory default — overridable per-signature via _MEMORY_* # MEMORY_DEFAULT_ENABLED=false -# 0 = reflect once at end_episode -# MEMORY_DEFAULT_MAX_REFLECTS=0 # Whether to compact trajectories (head+tail bounding) before distillation. # When false, the full trajectory is sent to the Distiller and ContextSafe @@ -299,7 +297,6 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # - TEMPERATURE (float) - Must be 1 while reasoning is enabled # - MAX_TOKENS (integer) - Output token budget (unset -> DEFAULT_MAX_TOKENS) # - MEMORY_ENABLED (true/false) - Hippocampus memory (unset -> MEMORY_DEFAULT_ENABLED) -# - MEMORY_MAX_REFLECTS (integer) - Max mid-run reflections (0 = reflect once at end) # Examples: # CODE_REVIEW_ENABLED=true @@ -310,33 +307,28 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # CODE_REVIEW_MAX_TOKENS=32000 # CODE_REVIEW_MEMORY_ENABLED=true -# CODE_REVIEW_MEMORY_MAX_REFLECTS=1 # SUPPLY_CHAIN_ENABLED=true # When true: scans ALL artifacts (Dockerfiles, etc.) and manifests # When false (default): only scans artifacts/manifests modified in the MR # SUPPLY_CHAIN_SCAN_UNCHANGED=false # SUPPLY_CHAIN_MEMORY_ENABLED=true -# SUPPLY_CHAIN_MEMORY_MAX_REFLECTS=1 # DOC_ENABLED=true # DOC_MAX_ITERS=1 # DOC_MAX_LLM_CALLS=2 # DOC_MODEL=anthropic/claude-sonnet-4-5-20250929 # DOC_MEMORY_ENABLED=true -# DOC_MEMORY_MAX_REFLECTS=1 # SCOPE_ENABLED=true # SCOPE_MAX_ITERS=4 # SCOPE_REASONING_EFFORT=low # SCOPE_MAX_TOKENS=32000 # SCOPE_MEMORY_ENABLED=true -# SCOPE_MEMORY_MAX_REFLECTS=1 # SUMMARY_ENABLED=true # SUMMARY_MAX_ITERS=1 # SUMMARY_MAX_LLM_CALLS=2 # SUMMARY_MODEL=anthropic/claude-haiku-4-5-20251001 # SUMMARY_MEMORY_ENABLED=true -# SUMMARY_MEMORY_MAX_REFLECTS=1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c325f1..573b35c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ ## [1.2.3] - 2026-09-16 ### Changed +- **BREAKING**: `Hippocampus` refactored from wrapper to composable component + - `Hippocampus` no longer inherits from `dspy.Module` or accepts a `module` parameter + - Removed `forward()`, `aforward()` — agents call their own modules and pass `hippo.context_memory` + - Added `observe(result)` / `aobserve(result)` to feed agent results back for reflection + - Added `context_memory` read-only property + - Added `bind_topics(topics, topic_ids)` public method (replaces direct `_topic_ids` access) + - Added `inject_context_memory(module)` standalone utility for signature injection + - All 6 review agents updated to composable pattern - **Internal**: Flattened `hippocampus/modules/` directory structure - Moved `distiller.py` and `cartographer.py` from `hippocampus/modules/` to `hippocampus/` package root - Removed `hippocampus/modules/__init__.py` and deleted `modules/` subdirectory diff --git a/action.yml b/action.yml index 94a608d..4796d8d 100644 --- a/action.yml +++ b/action.yml @@ -340,11 +340,6 @@ inputs: description: 'Bank ID scoping all memory data (e.g., project name or agent name). Defaults to "codespy".' required: false - memory-max-reflects: - description: 'Max online reflections per agent call (0 = batch-only, default: 0)' - required: false - default: '0' - memory-distiller-model: description: 'Model for the Distiller reflection module (smaller model recommended)' required: false @@ -553,7 +548,6 @@ runs: MEMORY_POSTGRES_DATABASE: ${{ inputs.memory-postgres-database }} MEMORY_POSTGRES_SCHEMA: ${{ inputs.memory-postgres-schema }} MEMORY_BANK_ID: ${{ inputs.memory-bank-id }} - MEMORY_DEFAULT_MAX_REFLECTS: ${{ inputs.memory-max-reflects }} MEMORY_DISTILLER_MODEL: ${{ inputs.memory-distiller-model }} MEMORY_DISTILLER_REASONING_EFFORT: ${{ inputs.memory-distiller-reasoning-effort }} MEMORY_DISTILLER_TEMPERATURE: ${{ inputs.memory-distiller-temperature }} @@ -663,7 +657,6 @@ runs: [ -n "$MEMORY_POSTGRES_DATABASE" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_POSTGRES_DATABASE" [ -n "$MEMORY_POSTGRES_SCHEMA" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_POSTGRES_SCHEMA" [ -n "$MEMORY_BANK_ID" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_BANK_ID" - [ -n "$MEMORY_DEFAULT_MAX_REFLECTS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_DEFAULT_MAX_REFLECTS" [ -n "$MEMORY_DISTILLER_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_DISTILLER_MODEL" [ -n "$MEMORY_DISTILLER_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_DISTILLER_REASONING_EFFORT" [ -n "$MEMORY_DISTILLER_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_DISTILLER_TEMPERATURE" diff --git a/codespy.yaml b/codespy.yaml index d355beb..eafe6e0 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -98,9 +98,8 @@ memory: # or agent name. When unset, auto-generated from hostname. bank_id: null # MEMORY_BANK_ID - # Reflection defaults — overridable per-signature via signatures..memory + # Memory default — overridable per-signature via signatures..memory default_enabled: false # MEMORY_DEFAULT_ENABLED - default_max_reflects: 0 # MEMORY_DEFAULT_MAX_REFLECTS (0 = reflect once at end_episode) # Four independent token budgets, from most to least cost-sensitive: # @@ -250,7 +249,6 @@ signatures: # When false (default): only scans artifacts/manifests that were modified in the MR memory: enabled: null # SUPPLY_CHAIN_MEMORY_ENABLED (null -> memory.default_enabled) - max_reflects: null # SUPPLY_CHAIN_MEMORY_MAX_REFLECTS # Code Reviewer signature (bugs, security, removed defensive code, code smells) # Unified code review: bugs, security vulnerabilities, and code smells in a single agent pass per scope @@ -264,7 +262,6 @@ signatures: max_tokens: null # CODE_REVIEW_MAX_TOKENS memory: enabled: null # CODE_REVIEW_MEMORY_ENABLED (null -> memory.default_enabled) - max_reflects: null # CODE_REVIEW_MEMORY_MAX_REFLECTS # Documentation Reviewer signature (compares patches against extracted documentation) # Note: doc extraction is now deterministic (no LLM) — see doc_extractor.py @@ -278,7 +275,6 @@ signatures: max_tokens: null # DOC_MAX_TOKENS memory: enabled: null # DOC_MEMORY_ENABLED (null -> memory.default_enabled) - max_reflects: null # DOC_MEMORY_MAX_REFLECTS # Scope Identifier signature scope: @@ -291,7 +287,6 @@ signatures: max_tokens: null # SCOPE_MAX_TOKENS memory: enabled: null # SCOPE_MEMORY_ENABLED (null -> memory.default_enabled) - max_reflects: null # SCOPE_MEMORY_MAX_REFLECTS # Summarizer signature summary: @@ -304,7 +299,6 @@ signatures: max_tokens: null # SUMMARY_MAX_TOKENS memory: enabled: true # SUMMARY_MEMORY_ENABLED (null -> memory.default_enabled) - max_reflects: null # SUMMARY_MEMORY_MAX_REFLECTS # Auditor signature (quality assessment + recommendation after all reviews) audit: @@ -317,7 +311,6 @@ signatures: max_tokens: null # AUDIT_MAX_TOKENS memory: enabled: null # AUDIT_MEMORY_ENABLED (null -> memory.default_enabled) - max_reflects: null # AUDIT_MEMORY_MAX_REFLECTS # ============================================================================ # OUTPUT diff --git a/docs/configuration.md b/docs/configuration.md index 3745d30..7bf818e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -183,7 +183,6 @@ Brief overview: | pg0 port | `MEMORY_PG0_PORT` | auto | pg0-embedded port (local dev) | | pg0 data dir | `MEMORY_PG0_DATA_DIR` | — | pg0-embedded data directory (local dev) | | Default enabled | `MEMORY_DEFAULT_ENABLED` | `false` | Enable memory globally | -| Max reflects | `MEMORY_DEFAULT_MAX_REFLECTS` | `0` | Reflection iterations (0 = once at end) | | Context memory tokens | `MEMORY_MAX_CONTEXT_MEMORY_TOKENS` | `16384` | Ceiling on persisted context memory | | Observation tokens | `MEMORY_MAX_CONTEXT_ITEM_TOKENS` | `512` | Soft per-observation token limit | | Trajectory tokens | `MEMORY_MAX_TRAJECTORY_TOKENS` | `16384` | Cap on trajectory fed to Distiller | diff --git a/docs/memory.md b/docs/memory.md index 92b43b5..5fca36f 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -185,8 +185,6 @@ The Distiller also tags each existing context memory observation with an `Observ These tags inform the Cartographer's edit decisions. -Reflection iterates `max_reflects` times (0 = reflect once at end_episode). - ## Token Budgets | Budget | Env Var | Default | Purpose | @@ -215,7 +213,6 @@ Observation capacity ≈ context_memory_tokens / observation_tokens (16384/512 = | `MEMORY_PG0_DATA_DIR` | `memory.pg0.data_dir` | — | Custom data directory for pg0-embedded | | `MEMORY_BANK_ID` | `memory.bank_id` | `codespy` | Scopes all memory data | | `MEMORY_DEFAULT_ENABLED` | `memory.default_enabled` | `false` | Enable memory globally | -| `MEMORY_DEFAULT_MAX_REFLECTS` | `memory.default_max_reflects` | `0` | Reflection iterations | | `MEMORY_COMPACT_TRAJECTORY` | `memory.compact_trajectory` | `true` | Apply head+tail trajectory bounding before distillation | ### Reflection Module LLM Overrides @@ -232,9 +229,8 @@ Each signature's `memory:` block in YAML (or `_MEMORY_*` env vars): | Setting | Env Var Suffix | Description | |---------|---------------|-------------| | enabled | `_MEMORY_ENABLED` | Enable/disable memory for this signature | -| max_reflects | `_MEMORY_MAX_REFLECTS` | Override reflection count | -Example: `CODE_REVIEW_MEMORY_ENABLED=true` or `SUMMARY_MEMORY_MAX_REFLECTS=2` +Example: `CODE_REVIEW_MEMORY_ENABLED=true` See [Configuration](configuration.md#recommended-model-strategy) for recommended reflection models. diff --git a/src/codespy/agents/context_safe.py b/src/codespy/agents/context_safe.py index 66799ac..8bf0209 100644 --- a/src/codespy/agents/context_safe.py +++ b/src/codespy/agents/context_safe.py @@ -60,7 +60,7 @@ class ContextSafe(dspy.Module): - Try/catch: if the inner module raises a context overflow error (unknown models where pre-flight can't estimate), catches it and retries with RLM. - Transparent to Hippocampus — delegates .signature to inner module. + Transparent to inject_context_memory — delegates .signature to inner module. """ def __init__( @@ -116,7 +116,7 @@ def forward(self, **kwargs) -> dspy.Prediction: return self._create_rlm_fallback()(**kwargs) async def aforward(self, **kwargs) -> dspy.Prediction: - """Async path — used by code_review, scope, supply_chain via Hippocampus.aforward.""" + """Async path — used by code_review, scope, supply_chain, doc via agent.acall().""" should_fallback, reason = self._should_use_rlm(kwargs) if should_fallback: logger.warning( @@ -186,7 +186,7 @@ def _should_use_rlm(self, kwargs: dict) -> tuple[bool, str]: return False, "" def _get_current_signature(self): - """Get current signature (may include context_memory if Hippocampus modified it).""" + """Get current signature (may include context_memory if inject_context_memory was called).""" sig = getattr(self.module, "signature", None) if sig is not None: return sig diff --git a/src/codespy/agents/memory/hippocampus/__init__.py b/src/codespy/agents/memory/hippocampus/__init__.py index 63b8e1e..bcdfbfa 100644 --- a/src/codespy/agents/memory/hippocampus/__init__.py +++ b/src/codespy/agents/memory/hippocampus/__init__.py @@ -17,7 +17,10 @@ join_episode_saves, submit_episode_save, ) -from codespy.agents.memory.hippocampus.hippocampus import Hippocampus +from codespy.agents.memory.hippocampus.hippocampus import ( + Hippocampus, + inject_context_memory, +) from codespy.agents.memory.hippocampus.cartographer import Cartographer, CartographerSig from codespy.agents.memory.hippocampus.distiller import Distiller, DistillerSig @@ -39,6 +42,7 @@ "SectionName", "Topic", "compute_common_ancestor_topic_id", + "inject_context_memory", "join_episode_saves", "make_topic_id", "submit_episode_save", diff --git a/src/codespy/agents/memory/hippocampus/hippocampus.py b/src/codespy/agents/memory/hippocampus/hippocampus.py index 18238ee..0cdd599 100644 --- a/src/codespy/agents/memory/hippocampus/hippocampus.py +++ b/src/codespy/agents/memory/hippocampus/hippocampus.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import copy import logging import uuid from datetime import UTC, datetime @@ -16,7 +15,6 @@ _head_tail_text, count_tokens, evict, - format_inputs, format_trajectory, ) from codespy.agents.memory.hippocampus.context_memory import ( @@ -49,65 +47,76 @@ def prepend_context_memory(sig): ) -class Hippocampus(dspy.Module): - """Wraps a dspy.Module with a context memory that evolves via LLM-driven reflection. +def inject_context_memory(module: dspy.Module) -> dspy.Module: + """Prepend context_memory input field to a dspy.Module's signatures. - The context memory is prepended to every agent call so the agent starts each run + Idempotent — skips predictors that already have context_memory. + Mutates the module in place and returns it for chaining. + Works through ContextSafe's signature delegation. + """ + top_sig = getattr(module, "signature", None) + if top_sig is not None: + module_inputs = set(top_sig.input_fields) + if "context_memory" not in top_sig.input_fields: + module.signature = prepend_context_memory(top_sig) + for _, pred in module.named_predictors(): + if ( + set(pred.signature.input_fields) & module_inputs + and "context_memory" not in pred.signature.input_fields + ): + pred.signature = prepend_context_memory(pred.signature) + else: + for _, pred in module.named_predictors(): + if "context_memory" not in pred.signature.input_fields: + pred.signature = prepend_context_memory(pred.signature) + return module + + +class Hippocampus: + """Memory component that evolves via LLM-driven reflection. + + The context memory is provided to agents so they start each run with accumulated orientation knowledge (structure, entities, constants) about the external context. After calls, the Distiller extracts transferable understanding and the Cartographer edits the memory — "caching understanding, not answers." - ## Two independent controls - - Reflection behaviour is governed by two orthogonal knobs: + ## Lifecycle - 1. **``max_reflects``** — maximum number of forward() calls that also reflect *online* - (in-episode warm-up). ``None`` (default) = no limit, reflect after every call. - ``0`` = never reflect online. ``N`` = reflect for the first N calls, buffer-only - thereafter. + Typical usage pattern:: - 2. **Calling ``end_episode()``** (or not) — whether to consolidate the buffered - episode into the memory at the end. Every call is *always* buffered so - ``end_episode()`` is available regardless of the online setting. + # Construct Hippocampus with task name and optional memory budget + hippo = Hippocampus(task_name="...") - Common patterns:: + # Inject context memory into agent + inject_context_memory(agent) - # Classic per-call (default) — reflect after every call, no end consolidation - mem = Hippocampus(agent) - pred = mem(task="…") + # Run agent with context_memory input + pred = agent(context_memory=hippo.context_memory, task="…") - # Pure batch — no online reflection, one holistic pass at the end - mem = Hippocampus(agent, max_reflects=0) - for task in tasks: - pred = mem(task=task) - mem.end_episode() + # Observe the result (buffers trajectory) + hippo.observe(pred) - # Hybrid — warm up online for the first 3 calls, then holistic consolidation - mem = Hippocampus(agent, max_reflects=3) - for task in tasks: - pred = mem(task=task) - mem.end_episode() + # Optional: bind topics before end_episode + hippo.bind_topics(topics, topic_ids) - # Read-only (memory never changes) — pure inference - mem = Hippocampus(agent, max_reflects=0) - pred = mem(task="…") # no end_episode() call + # End episode (consolidates and persists) + hippo.end_episode(store, artifacts={"key": "value"}) - # Async variants (for callers running inside an event loop, e.g. - # reviewer modules using `await agent.acall(...)`) - mem = Hippocampus(agent, max_reflects=0) - pred = await mem.acall(task="…") - await mem.aend_episode(store, dir) + # Async variant (for callers running inside an event loop) + pred = await agent.acall(context_memory=hippo.context_memory, task="…") + await hippo.aobserve(pred) + await hippo.aend_episode(store, artifacts={"key": "value"}) ## Trajectory bounding (two-stage) When ``budget.compact_trajectory`` is ``True`` and ``budget.max_trajectory_tokens`` is set: - - **Stage 1** (per call) — each trajectory is head+tail bounded at ``format_trajectory`` + - **Stage 1** (per observation) — each trajectory is head+tail bounded at ``format_trajectory`` time. This keeps the buffer lightweight. - **Stage 2** (``end_episode``) — the joined episode is head+tail bounded again, so - the combined result is guaranteed to fit the budget even if many calls are buffered. + the combined result is guaranteed to fit the budget even if many observations are buffered. With ``budget.compact_trajectory=False`` both stages are skipped and the Distiller receives the full trajectory. The ContextSafe wrapper on the @@ -125,41 +134,27 @@ class Hippocampus(dspy.Module): def __init__( self, - module: dspy.Module, + task_name: str, budget: MemoryBudget | None = None, - max_reflects: int | None = None, question: str | None = None, - task_name: str | None = None, run_id: str | None = None, initial_memory: ContextMemory | None = None, topics: list[Topic] | None = None, ): """ Args: - module: Any dspy.Module (ReAct, RLM, Predict, …) to wrap. budget: The four token budgets bounding memory, as a :class:`MemoryBudget`. Defaults to ``MemoryBudget()`` — see that class for per-field guidance. Resolve one from configuration with ``Settings.get_memory_budget(signature_name)``. - max_reflects: Maximum number of forward() calls that also reflect online. - None (default): no limit — reflect after every call (classic online learning). - 0: never reflect online — pure buffering until end_episode(). - N: reflect online for the first N calls, buffer-only afterwards. - Every call is always buffered regardless of this setting, so - end_episode() is always available. question: Pre-computed question string for the reflection "question". If set, this string is used directly as the Distiller question. - If None, all input fields are serialized (bounded by - ``budget.max_question_tokens``). - Set this when one field cleanly captures user intent. + If None, uses empty string (callers typically pass question at init). task_name: Identity recorded in ``Episode.task`` and used in the episode filename. Pass the signature's snake_case name (``"doc"``, ``"code_review"``, …) — the same key that drives config, LM selection and cost attribution — so the episode path lines up with - the rest of the system. Inference is a last resort: only - ``dspy.RLM``/``dspy.ReAct``-style modules expose ``.signature``, - ``dspy.ChainOfThought`` does not, so the fallback would yield a - meaningless (and collision-prone) ``"ChainOfThought"``. + the rest of the system. Required parameter. run_id: Identifier of the pipeline run this agent belongs to. Passed down by the orchestrating ``ReviewPipeline`` so every module invoked within the same review run shares the same identifier, @@ -173,31 +168,9 @@ class for per-field guidance. Resolve one from configuration with Topic IDs are auto-assigned to all new observations created during this episode. Used for scope-aware memory organization. """ - super().__init__() - - module = copy.deepcopy(module) - - # Prepend context_memory only to predictors that receive the module's own - # input fields. - top_sig = getattr(module, "signature", None) - if top_sig is not None: - module_inputs = set(top_sig.input_fields) - module.signature = prepend_context_memory(top_sig) - for _, pred in module.named_predictors(): - if ( - set(pred.signature.input_fields) & module_inputs - and "context_memory" not in pred.signature.input_fields - ): - pred.signature = prepend_context_memory(pred.signature) - else: - for _, pred in module.named_predictors(): - pred.signature = prepend_context_memory(pred.signature) - - self.agent = module self.distill = Distiller() self.cartograph = Cartographer() self.budget = budget or MemoryBudget() - self.max_reflects = max_reflects self.question = question self.cmem = initial_memory.model_copy(deep=True) if initial_memory else ContextMemory() self._topic_ids: list[str] = [] @@ -209,33 +182,14 @@ class for per-field guidance. Resolve one from configuration with self.cmem.topics.append(topic) existing_ids.add(topic.id) self.scores: dict[str, int] = {} - # Buffer of per-call bounded trajectory strings, cleared after end_episode(). + # Buffer of per-observation bounded trajectory strings, cleared after end_episode(). self._episode_trajectories: list[str] = [] - # Count of buffered trajectories already distilled online (via max_reflects). - # Used to skip a redundant consolidation distill in the single-call case, - # and to detect "everything was reflected online" so end_episode() still - # persists a snapshot even when nothing remains to consolidate. - self._reflected_count: int = 0 - # Question derived from the latest buffered call; used as Distiller consolidation input. + # Question derived from the latest buffered observation; used as Distiller consolidation input. self._episode_question: str | None = None - # Identity of the wrapped module/signature for Episode metadata. An explicit - # task_name wins: inference only works for modules exposing .signature. - self._task_name: str - if task_name: - self._task_name = task_name - elif top_sig is not None: - self._task_name = top_sig.__name__ - else: - fallback = type(module).__name__ - logger.warning( - "Hippocampus: task_name not provided and module %r has no .signature; " - "using collision-prone fallback %r. Pass task_name explicitly.", - module, - fallback, - ) - self._task_name = fallback - self._module_name: str = type(module).__name__ + # Identity for Episode metadata. + self._task_name = task_name + self._module_name = task_name # Default to task_name (more meaningful than "ContextSafe") # Identifier of the pipeline run this agent belongs to (see run_id arg # above). Falls back to a random UUID for standalone usage where no # orchestrator provides one. @@ -247,45 +201,58 @@ class for per-field guidance. Resolve one from configuration with # Step counter incremented per _distill() call for mutation grouping. self._distill_step: int = 0 - def forward(self, **kwargs) -> dspy.Prediction: - pred = self.agent(context_memory=self.cmem, **kwargs) - self._buffer_and_distill(pred, kwargs) - return pred - - async def aforward(self, **kwargs) -> dspy.Prediction: - """Async counterpart of :meth:`forward`. + @property + def context_memory(self) -> ContextMemory: + """Read-only access to the current context memory. - Awaits the wrapped agent's ``acall`` instead of invoking it - synchronously — required when the caller is already inside a running - event loop (e.g. reviewer modules using ``await agent.acall(...)``). - The Distiller/Cartographer reflection pass is still synchronous under - the hood but is offloaded to a thread so it never blocks the loop. + Returns the live reference (mutations through it like ``bind_topics`` work). + Assignment is blocked (read-only property). """ - pred = await self.agent.acall(context_memory=self.cmem, **kwargs) - await asyncio.to_thread(self._buffer_and_distill, pred, kwargs) - return pred + return self.cmem - def _buffer_and_distill(self, pred: dspy.Prediction, kwargs: dict) -> None: - """Shared post-call work for both ``forward`` and ``aforward``. + def observe(self, result: dspy.Prediction | str, *, question: str | None = None) -> None: + """Feed an observation (trajectory) to Hippocampus for buffering. - Buffers the (stage-1 bounded) trajectory and, depending on - ``max_reflects``, runs an online distill+apply pass immediately. + The trajectory is buffered and will be consolidated at ``end_episode()``. + + Args: + result: Either a ``dspy.Prediction`` (stage-1 trajectory bounding applies) + or a raw ``str`` (no bounding, caller controls input). + question: Optional per-observation question override. If not provided, + uses ``self.question`` or empty string. """ - max_traj = self.budget.max_trajectory_tokens if self.budget.compact_trajectory else None - traj = format_trajectory(pred, max_traj) + # Extract trajectory + if isinstance(result, str): + traj = result # raw string: no stage-1 bounding (caller controls input) + else: + max_traj = self.budget.max_trajectory_tokens if self.budget.compact_trajectory else None + traj = format_trajectory(result, max_traj) # stage-1 bounding for Predictions + + # Buffer self._episode_trajectories.append(traj) - self._episode_question = self._make_question(kwargs) - # Online reflection: None = no limit (always); N = for the first N calls. - if self.max_reflects is None or len(self._episode_trajectories) <= self.max_reflects: - try: - self._distill(traj, self._episode_question) - self._reflected_count += 1 - except Exception: - logger.warning( - "Online reflection failed for %s; trajectory buffered for end_episode().", - self._task_name, - exc_info=True, - ) + self._episode_question = question or self.question or "" + + async def aobserve(self, result: dspy.Prediction | str, *, question: str | None = None) -> None: + """Async counterpart of :meth:`observe`. + + Offloads the observation processing to a thread so it never blocks + the caller's event loop. + """ + await asyncio.to_thread(self.observe, result, question=question) + + def bind_topics(self, topics: list[Topic], topic_ids: list[str]) -> None: + """Bind topics to the context memory for episode persistence. + + Used by the scope agent post-call to bind scope-specific topics. + Replaces the previous pattern of direct ``_topic_ids`` access and + ``cmem.bind_topics()`` calls. + + Args: + topics: List of Topic objects to register. + topic_ids: List of topic IDs to stamp on new observations. + """ + self._topic_ids = topic_ids + self.cmem.bind_topics(topics, topic_ids) def _consolidate(self) -> str | None: """Join buffered trajectories (stage-2 bounded) and distill+apply once. @@ -293,8 +260,7 @@ def _consolidate(self) -> str | None: Returns the combined trajectory text used for consolidation, or ``None`` if the buffer is empty (no-op). """ - skip_double_distill = len(self._episode_trajectories) == 1 and self._reflected_count > 0 - if not self._episode_trajectories or skip_double_distill: + if not self._episode_trajectories: return None combined = "\n\n".join( f"=== Call {i + 1} ===\n{t}" for i, t in enumerate(self._episode_trajectories) @@ -332,7 +298,6 @@ def _finalize_episode(self, artifacts: dict[str, str] | None = None) -> None: ) self._episode_trajectories.clear() self._episode_question = None - self._reflected_count = 0 self._mutations.clear() self._distill_step = 0 @@ -345,9 +310,9 @@ def end_episode( A single Distiller pass sees all buffered trajectories joined with ``=== Call k ===`` headers. If ``budget.max_trajectory_tokens`` is set, the - combined text is head+tail bounded (stage 2) after per-call bounding + combined text is head+tail bounded (stage 2) after per-observation bounding (stage 1) already applied at append time. The question is derived from - the first buffered call. No-op if the buffer is empty. + the first buffered observation. No-op if the buffer is empty. After consolidation ``self.episode`` is set to a new :class:`Episode` containing the task/module identity and a deep-copy snapshot of the @@ -366,8 +331,7 @@ def end_episode( OSError: If persistence is requested and the write fails. """ combined = self._consolidate() - has_content = combined is not None or self._reflected_count > 0 - if not has_content: + if combined is None: return self._finalize_episode(artifacts) if store is not None: @@ -390,8 +354,7 @@ async def aend_episode( episode (e.g. ``{"review": ""}``). """ combined = await asyncio.to_thread(self._consolidate) - has_content = combined is not None or self._reflected_count > 0 - if not has_content: + if combined is None: return await asyncio.to_thread(self._finalize_episode, artifacts) if store is not None: @@ -401,7 +364,6 @@ def reset_episode(self) -> None: """Discard the buffered trajectories without reflecting.""" self._episode_trajectories.clear() self._episode_question = None - self._reflected_count = 0 self._mutations.clear() self._distill_step = 0 @@ -409,11 +371,6 @@ def reset_episode(self) -> None: # Internals # ------------------------------------------------------------------ - def _make_question(self, inputs: dict) -> str: - if self.question is not None: - return self.question - return format_inputs(inputs, self.budget.max_question_tokens) - def _record_mutations( self, ops: list[Operation], new_ids: list[str], pre_memory: ContextMemory ) -> list[Mutation]: diff --git a/src/codespy/agents/review/audit/agent.py b/src/codespy/agents/review/audit/agent.py index 2f1d0b6..0e12b81 100644 --- a/src/codespy/agents/review/audit/agent.py +++ b/src/codespy/agents/review/audit/agent.py @@ -7,7 +7,8 @@ from codespy.agents import SignatureContext, get_cost_tracker from codespy.agents.context_safe import ContextSafe -from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus +from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus, inject_context_memory +from codespy.agents.memory.hippocampus.context_memory import Topic from codespy.agents.review.models import Issue, ReviewContext from codespy.agents.review.helpers import deepest_common_folder from codespy.config import get_settings @@ -93,21 +94,22 @@ def _call_auditor( if scope_topic: scope_topics.append(scope_topic) - mem = Hippocampus( - auditor, + inject_context_memory(auditor) + hippo = Hippocampus( + task_name="audit", budget=self._settings.get_memory_budget("audit"), - max_reflects=self._settings.get_memory_max_reflects("audit"), question=question, - task_name="audit", run_id=run_id, initial_memory=initial_memory, topics=scope_topics if scope_topics else None, ) - result = mem( + result = auditor( + context_memory=hippo.context_memory, pr_title=review_context.pr_context.pr_title, summary=review_context.pr_context.summary, all_issues=all_issues, ) + hippo.observe(result) # Run episode save synchronously (auditor is the last module) try: _artifacts = { @@ -116,7 +118,7 @@ def _call_auditor( f"## Recommendation\n\n{result.recommendation}\n" ) } - mem.end_episode(store, artifacts=_artifacts) + hippo.end_episode(store, artifacts=_artifacts) except Exception: logger.warning("Audit episode save failed", exc_info=True) else: diff --git a/src/codespy/agents/review/code_review/agent.py b/src/codespy/agents/review/code_review/agent.py index e98611a..f672f85 100644 --- a/src/codespy/agents/review/code_review/agent.py +++ b/src/codespy/agents/review/code_review/agent.py @@ -11,7 +11,7 @@ from codespy.agents import SignatureContext, get_cost_tracker from codespy.agents.context_safe import ContextSafe -from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus +from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus, inject_context_memory from codespy.agents.memory.hippocampus.episode import submit_episode_save from codespy.agents.review.models import Issue, IssueCategory, ReviewContext from codespy.agents.review.scope.models import ScopeResult @@ -289,7 +289,7 @@ async def _review_scope( logger.info( f" Code review: scope {scope.subroot} ({len(scope.changed_files)} files)" ) - mem: Hippocampus | None = None + hippo: Hippocampus | None = None async with SignatureContext("code_review", self._cost_tracker): # Load own prior "code_review" episode for this scope scope_initial_memory: ContextMemory | None = None @@ -315,20 +315,21 @@ async def _review_scope( ) pr_ctx = review_context.pr_context topics = [scope.topic(pr.repo_full_name), pr_ctx.to_topic()] if pr else [] - mem = Hippocampus( - agent, + inject_context_memory(agent) + hippo = Hippocampus( + task_name="code_review", budget=self._settings.get_memory_budget("code_review"), - max_reflects=self._settings.get_memory_max_reflects("code_review"), question=question, - task_name="code_review", run_id=run_id, initial_memory=scope_initial_memory, topics=topics, ) - result = await mem.aforward( + result = await agent.acall( + context_memory=hippo.context_memory, scope=scoped, categories=categories, ) + await hippo.aobserve(result) issues = [ issue for issue in (result.issues or []) @@ -336,9 +337,9 @@ async def _review_scope( ] # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} - def _persist(m=mem, s=store, a=_artifacts): + def _persist(h=hippo, s=store, a=_artifacts): try: - m.end_episode(s, artifacts=a) + h.end_episode(s, artifacts=a) except Exception: logger.warning("Background code_review episode save failed", exc_info=True) submit_episode_save(_persist, name="code-review-episode-save") diff --git a/src/codespy/agents/review/doc/agent.py b/src/codespy/agents/review/doc/agent.py index 5e12d8a..039f2b4 100644 --- a/src/codespy/agents/review/doc/agent.py +++ b/src/codespy/agents/review/doc/agent.py @@ -11,7 +11,7 @@ from codespy.agents import SignatureContext, get_cost_tracker from codespy.agents.context_safe import ContextSafe -from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus +from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus, inject_context_memory from codespy.agents.memory.hippocampus.episode import submit_episode_save from codespy.agents.review.models import Issue, IssueCategory, ReviewContext from codespy.agents.review.scope.models import ScopeResult @@ -211,7 +211,7 @@ async def _review_scope( logger.info( f" Doc review: scope {scope.subroot} ({len(scope.changed_files)} files)" ) - mem: Hippocampus | None = None + hippo: Hippocampus | None = None async with SignatureContext("doc", self._cost_tracker): # Load own prior "doc" episode for this scope scope_initial_memory: ContextMemory | None = None @@ -237,21 +237,22 @@ async def _review_scope( ) pr_ctx = review_context.pr_context topics = [scope.topic(pr.repo_full_name), pr_ctx.to_topic()] if pr else [] - mem = Hippocampus( - reviewer, + inject_context_memory(reviewer) + hippo = Hippocampus( + task_name="doc", budget=self._settings.get_memory_budget("doc"), - max_reflects=self._settings.get_memory_max_reflects("doc"), question=question, - task_name="doc", run_id=run_id, initial_memory=scope_initial_memory, topics=topics, ) - result = await mem.aforward( + result = await reviewer.aforward( + context_memory=hippo.context_memory, patches=patches, documentation=documentation, categories=[IssueCategory.DOCUMENTATION], ) + await hippo.aobserve(result) issues = [ issue for issue in (result.issues or []) @@ -259,9 +260,9 @@ async def _review_scope( ] # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} - def _persist(m=mem, s=store, a=_artifacts): + def _persist(h=hippo, s=store, a=_artifacts): try: - m.end_episode(s, artifacts=a) + h.end_episode(s, artifacts=a) except Exception: logger.warning("Background doc episode save failed", exc_info=True) submit_episode_save(_persist, name="doc-episode-save") diff --git a/src/codespy/agents/review/scope/agent.py b/src/codespy/agents/review/scope/agent.py index a8c42d9..5c4bb28 100644 --- a/src/codespy/agents/review/scope/agent.py +++ b/src/codespy/agents/review/scope/agent.py @@ -19,7 +19,7 @@ from codespy.agents import SignatureContext, get_cost_tracker from codespy.agents.context_safe import ContextSafe -from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus +from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus, inject_context_memory from codespy.agents.memory.hippocampus.episode import submit_episode_save from codespy.agents.review.models import ReviewContext from codespy.agents.review.scope.manifest_parser import extract_package_name @@ -1028,7 +1028,7 @@ async def _refine_scopes( max_llm_calls=self._settings.get_max_llm_calls("scope"), rlm_threshold=self._settings.get_rlm_threshold("react"), ) - mem: Hippocampus | None = None + hippo: Hippocampus | None = None async with SignatureContext("scope", self._cost_tracker): if self._settings.get_memory_enabled("scope"): @@ -1053,22 +1053,23 @@ async def _refine_scopes( logger.info("Loaded prior scope episode for %s", repo_topic_id) else: logger.info("No prior scope episode for %s", repo_topic_id) - mem = Hippocampus( - agent, + inject_context_memory(agent) + hippo = Hippocampus( + task_name="scope", budget=self._settings.get_memory_budget("scope"), - max_reflects=self._settings.get_memory_max_reflects("scope"), question=question, - task_name="scope", run_id=run_id, initial_memory=scope_initial_memory, ) - result = await mem.aforward( + result = await agent.acall( + context_memory=hippo.context_memory, candidates=candidates_str, orphan_files=[f.filename for f in orphans], pr_title=pr.title or "No title", pr_description=pr.body or "No description", project_instructions=project_instructions, ) + await hippo.aobserve(result) else: result = await agent.acall( candidates=candidates_str, @@ -1123,20 +1124,19 @@ async def _refine_scopes( for scope in final_scopes: scope.skills = collect_skills(repo_path, scope.subroot) # Bind topics to hippocampus cmem for episode persistence - if mem is not None and stamp_topic_ids: + if hippo is not None and stamp_topic_ids: stamp_topic_ids.append(pr_ctx.pr_url) - mem._topic_ids = stamp_topic_ids - mem.cmem.bind_topics(scope_topics, stamp_topic_ids) + hippo.bind_topics(scope_topics, stamp_topic_ids) # Fire-and-forget background episode save - if mem is not None and store is not None: + if hippo is not None and store is not None: scope_desc = "\n".join( f"- {s.subroot} ({s.scope_type.value}): {len(s.changed_files)} files" for s in final_scopes ) def _persist(): try: - mem.end_episode(store, artifacts={"scopes": scope_desc}) + hippo.end_episode(store, artifacts={"scopes": scope_desc}) except Exception: logger.warning("Background scope episode save failed", exc_info=True) submit_episode_save(_persist, name="scope-episode-save") diff --git a/src/codespy/agents/review/summary/agent.py b/src/codespy/agents/review/summary/agent.py index 1bfd75a..0318645 100644 --- a/src/codespy/agents/review/summary/agent.py +++ b/src/codespy/agents/review/summary/agent.py @@ -7,7 +7,7 @@ from codespy.agents import SignatureContext, get_cost_tracker from codespy.agents.context_safe import ContextSafe -from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus +from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus, inject_context_memory from codespy.agents.memory.hippocampus.episode import submit_episode_save from codespy.agents.memory.hippocampus.context_memory import Topic from codespy.agents.review.helpers import deepest_common_folder @@ -107,7 +107,7 @@ def forward( question = f"summarize {pr_context.repo_slug}: pull request {pr_context.pr_number} {pr_context.pr_title}" - mem: Hippocampus | None = None + hippo: Hippocampus | None = None with SignatureContext("summary", self._cost_tracker): if self._settings.get_memory_enabled("summary") and store is not None: # Build topics list for Hippocampus @@ -117,27 +117,28 @@ def forward( if scope_topic: scope_topics.append(scope_topic) - mem = Hippocampus( - summarizer, + inject_context_memory(summarizer) + hippo = Hippocampus( + task_name="summary", budget=self._settings.get_memory_budget("summary"), - max_reflects=self._settings.get_memory_max_reflects("summary"), question=question, - task_name="summary", run_id=run_id, initial_memory=initial_memory, topics=scope_topics if scope_topics else topics, ) - result = mem( + result = summarizer( + context_memory=hippo.context_memory, pr_title=pr_context.pr_title, pr_description=pr_context.pr_description, changed_file_paths=changed_file_paths, patches=patches, ) + hippo.observe(result) # Fire-and-forget episode save _summary_text = result.summary def _persist(): try: - mem.end_episode(store, artifacts={"summary": _summary_text}) + hippo.end_episode(store, artifacts={"summary": _summary_text}) except Exception: logger.warning("Background summary episode save failed", exc_info=True) submit_episode_save(_persist, name="summary-episode-save") diff --git a/src/codespy/agents/review/supply_chain/agent.py b/src/codespy/agents/review/supply_chain/agent.py index 54da8e8..2f14fb1 100644 --- a/src/codespy/agents/review/supply_chain/agent.py +++ b/src/codespy/agents/review/supply_chain/agent.py @@ -11,7 +11,7 @@ from codespy.agents import SignatureContext, get_cost_tracker from codespy.agents.context_safe import ContextSafe -from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus +from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus, inject_context_memory from codespy.agents.memory.hippocampus.episode import submit_episode_save from codespy.agents.review.models import Issue, IssueCategory, ReviewContext from codespy.agents.review.scope.models import ScopeResult @@ -389,7 +389,7 @@ async def _review_scope( f"manifest={bool(manifest_path)}" ) # Track supply_chain signature costs separately - mem: Hippocampus | None = None + hippo: Hippocampus | None = None async with SignatureContext("supply_chain", self._cost_tracker): # Load own prior "supply_chain" episode for this scope scope_initial_memory: ContextMemory | None = None @@ -415,22 +415,23 @@ async def _review_scope( ) pr_ctx = review_context.pr_context topics = [scope.topic(pr.repo_full_name), pr_ctx.to_topic()] if pr else [] - mem = Hippocampus( - supply_chain_agent, + inject_context_memory(supply_chain_agent) + hippo = Hippocampus( + task_name="supply_chain", budget=self._settings.get_memory_budget("supply_chain"), - max_reflects=self._settings.get_memory_max_reflects("supply_chain"), question=question, - task_name="supply_chain", run_id=run_id, initial_memory=scope_initial_memory, topics=topics, ) - result = await mem.aforward( + result = await supply_chain_agent.acall( + context_memory=hippo.context_memory, manifest_path=manifest_path, lock_file_path=lock_file_path, package_manager=package_manager, category=IssueCategory.SECURITY, ) + await hippo.aobserve(result) issues = [ issue for issue in result.issues @@ -438,9 +439,9 @@ async def _review_scope( ] # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} - def _persist(m=mem, s=store, a=_artifacts): + def _persist(h=hippo, s=store, a=_artifacts): try: - m.end_episode(s, artifacts=a) + h.end_episode(s, artifacts=a) except Exception: logger.warning("Background supply_chain episode save failed", exc_info=True) submit_episode_save(_persist, name="supply-chain-episode-save") diff --git a/src/codespy/config.py b/src/codespy/config.py index 3529b08..6386e40 100644 --- a/src/codespy/config.py +++ b/src/codespy/config.py @@ -265,20 +265,11 @@ def get_memory_enabled(self, signature_name: str) -> bool: config = self.get_signature_config(signature_name).memory return config.enabled if config.enabled is not None else self.memory.default_enabled - def get_memory_max_reflects(self, signature_name: str) -> int | None: - """Get max_reflects for a signature's memory (signature-specific or default).""" - config = self.get_signature_config(signature_name).memory - return ( - config.max_reflects - if config.max_reflects is not None - else self.memory.default_max_reflects - ) - def get_memory_budget(self, signature_name: str) -> "MemoryBudget": """Resolve the ``MemoryBudget`` for a signature. - Token budgets are global (``memory.default_*``); only ``enabled`` and - ``max_reflects`` support per-signature overrides. + Token budgets are global (``memory.default_*``); only ``enabled`` + supports per-signature overrides. """ from codespy.agents.memory.hippocampus.budget import MemoryBudget diff --git a/src/codespy/config_dspy.py b/src/codespy/config_dspy.py index fbdbb5a..512a4b3 100644 --- a/src/codespy/config_dspy.py +++ b/src/codespy/config_dspy.py @@ -38,7 +38,6 @@ class MemorySignatureConfig(BaseModel): """ enabled: bool | None = None # _MEMORY_ENABLED - max_reflects: int | None = None # _MEMORY_MAX_REFLECTS class SignatureConfig(BaseModel): diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 9414cf3..2268a71 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -91,7 +91,7 @@ class Pg0Config(BaseModel): class MemoryConfig(BaseModel): """Global memory (Hippocampus) configuration. - Controls where episodes are persisted and the default reflection knobs + Controls where episodes are persisted and the default memory knob applied to every agent. Per-signature ``memory:`` blocks override the ``default_*`` values. """ @@ -101,9 +101,8 @@ class MemoryConfig(BaseModel): pg0: Pg0Config = Field(default_factory=Pg0Config) bank_id: str | None = None # MEMORY_BANK_ID (defaults to "codespy") - # Reflection defaults — overridable per-signature + # Memory default — overridable per-signature default_enabled: bool = False # MEMORY_DEFAULT_ENABLED - default_max_reflects: int = Field(default=0) # MEMORY_DEFAULT_MAX_REFLECTS # Whether to apply head+tail trajectory bounding before distillation. # When false, the full trajectory goes to the Distiller and ContextSafe @@ -173,7 +172,6 @@ class MemoryConfig(BaseModel): MEMORY_ENV_SETTINGS = { "BANK_ID": "bank_id", "DEFAULT_ENABLED": "default_enabled", - "DEFAULT_MAX_REFLECTS": "default_max_reflects", "COMPACT_TRAJECTORY": "compact_trajectory", "MAX_CONTEXT_MEMORY_TOKENS": "max_context_memory_tokens", "MAX_CONTEXT_ITEM_TOKENS": "max_context_item_tokens", diff --git a/tests/test_hippocampus.py b/tests/test_hippocampus.py index 854e2ad..ddb8a62 100644 --- a/tests/test_hippocampus.py +++ b/tests/test_hippocampus.py @@ -19,6 +19,7 @@ Operation, OpType, Topic, + inject_context_memory, ) from codespy.agents.memory.hippocampus.episode import Episode from codespy.agents.memory.postgres import EpisodeStore @@ -328,24 +329,25 @@ class MockAgent(dspy.Module): def forward(self, **kwargs): return dspy.Prediction(result="test") - # Create Hippocampus with the mock agent - mem = Hippocampus( - MockAgent(), + # Create agent and hippocampus (composable pattern) + agent = MockAgent() + hippo = Hippocampus( task_name="test_task", run_id="test-run", topics=[Topic(id="test/topic", type="project_scope", description="Test topic")], ) - # Make a call - mem.forward() + # Make a call (MockAgent accepts any kwargs, so no inject needed for this test) + result = agent(context_memory=hippo.context_memory) + hippo.observe(result) # End episode with the store - mem.end_episode(store=episode_store, artifacts={"test": "value"}) + hippo.end_episode(store=episode_store, artifacts={"test": "value"}) # Episode should be set - assert mem.episode is not None - assert mem.episode.task == "test_task" - assert mem.episode.run_id == "test-run" + assert hippo.episode is not None + assert hippo.episode.task == "test_task" + assert hippo.episode.run_id == "test-run" def test_load_context_returns_latest_episode(self, episode_store): """load_context should return the context from the latest episode."""