Skip to content
Open
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
71 changes: 71 additions & 0 deletions configs/features/mem0_memory.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# ─────────────────────────────────────────────────────────────────────────────
# Feature: optional mem0-backed persistent memory (community-contributed)
# ─────────────────────────────────────────────────────────────────────────────
#
# This is NOT a default SkillOpt setting and was NOT used to produce the
# numbers reported in the paper. It is off unless you turn it on here.
#
# IMPORTANT — this feature sends data to a third-party hosted service.
# Enabling it means skill text and reflection summaries leave your machine.
# Setting MEM0_API_KEY alone does *nothing*; `mem0_enabled: true` below is the
# only thing that turns uploading on, and a key set for some other application
# will never enable it by accident.
#
# What it does:
# - After each evaluation gate, stores the current skill and its score.
# - After each Reflect stage, stores a summary of the patches produced.
# - Before each Reflect stage, makes ONE bounded retrieval call and appends
# the relevant history to that stage's reflection input, so previous runs
# can inform the patches produced now.
#
# When to consider this:
# - You run the same project repeatedly and want later runs to benefit from
# what earlier ones discovered.
# - You are comfortable with the data-handling note above.
#
# When NOT to use this:
# - When reproducing the paper.
# - When the skill text or trajectories are sensitive. Payloads are redacted
# (see docs/reference/config.md → "What leaves the machine"), but redaction
# is a mitigation, not a guarantee about content you know to be private.
#
# Install the optional dependency first:
# pip install 'skillopt[mem0]'
#
# Then export your key and use this file:
# export MEM0_API_KEY=m0-...
# _base_: ../features/mem0_memory.yaml
# or copy the `train:` block below into your config.
# ─────────────────────────────────────────────────────────────────────────────

_base_: ../_base_/default.yaml

train:
# The master switch. Nothing is uploaded while this is false.
mem0_enabled: true

# Optional: set the key in config instead of the MEM0_API_KEY env var.
# Leave empty to use the environment (recommended — keeps keys out of YAML).
mem0_api_key: ""

# Optional: override the namespace. Default is derived per project as
# `skillopt:<env>:<sha256-prefix-of-project-root>`, which is stable across
# runs and distinct across projects. Set this only if you deliberately want
# several checkouts to share one memory pool.
mem0_namespace: ""

# Read memory back into reflection. Setting this false keeps the writes but
# removes the retrieval call — useful for measuring whether retrieval is what
# actually helps, rather than assuming it does.
mem0_retrieval_enabled: true

# Records fetched per retrieval.
mem0_retrieval_limit: 5

# Hard per-call wall-clock bound. On timeout the step continues unchanged, so
# an unreachable service costs one bounded pause rather than a stalled run.
mem0_timeout_seconds: 5.0

# Cap on any single stored payload, applied AFTER redaction so the limit is
# measured on exactly the text that would be transmitted.
mem0_max_chars: 4000
80 changes: 80 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,86 @@ forwarded consistently to both SDK and CLI execution paths.

Benchmark-specific `env` keys are passed through to the adapter.

## Persistent Memory (mem0) — optional, off by default

Stores skill iterations and reflection summaries in [mem0](https://mem0.ai) and reads a
small amount of relevant history back into the Reflect stage.

**Disabled unless `mem0_enabled` is explicitly true.** A `MEM0_API_KEY` present in the
environment for another application does *not* enable it. Install with
`pip install 'skillopt[mem0]'`.

Set these under `train:` in a structured config (every shipped config is structured), at
the top level of a flat config, or via `--cfg-options mem0_enabled=true`. A ready-made
example ships at `configs/features/mem0_memory.yaml`.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `train.mem0_enabled` | bool | `false` | Master switch. Nothing is sent unless this is true |
| `train.mem0_api_key` | str | empty | Falls back to `MEM0_API_KEY`, read only when enabled. Redacted from `config.json` and the run summary; prefer the env var |
| `train.mem0_namespace` | str | derived | Override the namespace; default is derived per project |
| `train.mem0_retrieval_enabled` | bool | `true` | Read memory back into reflection; writes continue if false |
| `train.mem0_retrieval_limit` | int | `5` | Max records fetched per retrieval (sent as `top_k`) |
| `train.mem0_timeout_seconds` | float | `5.0` | Hard per-call bound, enforced on the HTTP client. After 3 consecutive failures memory disables itself for the run |
| `train.mem0_max_chars` | int | `4000` | Cap on any single stored payload, applied after redaction |

### Supported Mem0 versions

The extra pins `mem0ai>=2.0.0,<3`, and the integration is written against the Mem0 2.x
client contract, verified against 2.0.14:

- `add(messages, user_id=<namespace>, metadata=...)` — top-level `user_id`.
- `search(query, filters={"user_id": <namespace>}, top_k=<n>)` — entity scoping goes in
`filters`, and the count is `top_k`.

The two calls differ on purpose. Mem0 2.x raises `ValueError` for a top-level `user_id`
in `search()` *before* issuing a request, and because failures degrade to an empty
result, an incorrect call shape is silent — writes keep succeeding while retrieval
returns nothing. `tests/test_mem0_memory.py` therefore exercises retrieval against a
signature-strict client double, not a permissive mock.

0.1.x is not supported: it rejects the injected `httpx` client used to enforce timeouts
(added in 0.1.98) and uses the older `user_id`/`limit` search shape.

### What leaves the machine

When enabled, exactly two record types are sent, both redacted first:

1. **Skill iteration** — epoch, step, score, skill hash and length, the `env` and model
names, and the skill text (capped at `mem0_max_chars`).
2. **Reflection summary** — epoch, step, patch count, rollout scores, and a JSON summary
of up to 10 patches with any `skill_text` field removed.

Retrieval sends the first 600 characters of the current skill as a similarity query.

Before transmission every payload passes through
`skillopt.memory.redaction.redact_for_upload`, which strips vendor API keys,
bearer/basic tokens, JWTs, private keys, `key = value` secret assignments, the project
root, and `/home/<user>`-style prefixes. Relative paths and filenames are deliberately
preserved so stored memories stay useful. Text *retrieved* from mem0 is redacted again
before it enters the reflection prompt, since the store is external and its contents are
forwarded to the optimizer's model provider.

### Namespacing

Memories are scoped to `skillopt:<env>:<digest>`, where the digest is a SHA-256 prefix of
a **stable project identity** — the enclosing git repository root, or the working
directory when there is no repository. Stable across runs of one project, distinct across
projects, and the raw path is never transmitted.

Identity is deliberately *not* derived from `out_root`. The train/eval CLIs default that
to `outputs/skillopt_<env>_<model>_<timestamp>`, so deriving from it would mint a new
namespace on every run and cross-run retrieval would never return anything. `out_root`
still anchors path redaction, which is what it is right for.

### Failure behaviour

A call exceeding `mem0_timeout_seconds` releases the training step on time. The timeout is
enforced on the injected `httpx.Client`, so the request aborts rather than merely being
abandoned, and calls run on daemon threads so a stuck request can never delay interpreter
exit. After three consecutive failures the backend stops calling out for the remainder of
the run and logs once.

## Credential Environment Variables

### Azure-family backend
Expand Down
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ docs = ["mkdocs-material>=9.5.0", "mkdocstrings[python]>=0.24.0"]
# is 5.50.0: earlier gradio 5.x (5.0-5.49) still import HfFolder or don't apply
# the Blocks theme the same way, so they are not valid supported versions.
webui = ["gradio>=5.50.0,<7"]
# Optional mem0-backed persistent memory. Deliberately excluded from `all`:
# installing it is harmless (uploads still require mem0_enabled: true), but
# a data-export integration should be an explicit choice at install time too.
# Pinned to 2.x: the retrieval path targets the Mem0 2.x search contract
# (filters={"user_id": ...}, top_k=...), which 0.1.x does not accept, and the
# injected httpx client used to enforce timeouts predates 0.1.98.
mem0 = ["mem0ai>=2.0.0,<3"]
# Development tools
dev = ["ruff>=0.4.0", "pytest>=8.0.0"]
# All optional dependencies (except docs/dev/webui)
Expand Down
8 changes: 8 additions & 0 deletions skillopt/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@
"train.batch_size": "batch_size",
"train.accumulation": "accumulation",
"train.seed": "seed",
# Optional mem0 memory (off unless train.mem0_enabled is true).
"train.mem0_enabled": "mem0_enabled",
"train.mem0_api_key": "mem0_api_key",
"train.mem0_namespace": "mem0_namespace",
"train.mem0_retrieval_enabled": "mem0_retrieval_enabled",
"train.mem0_retrieval_limit": "mem0_retrieval_limit",
"train.mem0_timeout_seconds": "mem0_timeout_seconds",
"train.mem0_max_chars": "mem0_max_chars",
"gradient.minibatch_size": "minibatch_size",
"gradient.merge_batch_size": "merge_batch_size",
"gradient.analyst_workers": "analyst_workers",
Expand Down
32 changes: 31 additions & 1 deletion skillopt/engine/trainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@
)
from skillopt.model.common import normalize_backend_name
from skillopt.utils import compute_score, skill_hash
from skillopt.memory.trainer_hooks import (
maybe_init_mem0,
hook_pre_reflect,
hook_post_evaluate,
hook_post_reflect,
)


# ── Skill-aware reflection: appendix flush ───────────────────────────────────
Expand Down Expand Up @@ -335,6 +341,7 @@ def _build_longitudinal_pairs(
"azure_api_key",
"api_key",
"openai_api_key",
"mem0_api_key",
}


Expand Down Expand Up @@ -1125,6 +1132,8 @@ def _persist_runtime_state(last_completed_step: int) -> None:
# ── Training loop ────────────────────────────────────────────────
t_loop_start = time.time()

memory = maybe_init_mem0(cfg)

if resume_from > total_steps:
print(f"\n [skip] all {total_steps} steps complete — jumping to evaluation")

Expand Down Expand Up @@ -1241,11 +1250,18 @@ def _persist_runtime_state(last_completed_step: int) -> None:
# Build step context from buffer
step_buffer_context = _format_step_buffer(step_buffer)

# Memory read: relevant history for THIS reflection only.
# Kept in a separate name so the LR decision and rewrite
# prompts below continue to see the unaugmented context.
reflect_context = hook_pre_reflect(
memory, current_skill, step_buffer_context,
)

raw_patches = adapter.reflect(
rollout_results, current_skill, batch_dir,
prediction_dir=pred_dir, patches_dir=patches_dir,
random_seed=batch_seed,
step_buffer_context=step_buffer_context,
step_buffer_context=reflect_context,
meta_skill_context=active_meta_skill,
)
failure_patches, success_patches = _normalise_patches(
Expand Down Expand Up @@ -1287,6 +1303,11 @@ def _persist_runtime_state(last_completed_step: int) -> None:
step_rec["timing"]["rollout_s"] = round(total_rollout_time, 1)
step_rec["timing"]["reflect_s"] = round(total_reflect_time, 1)

hook_post_reflect(
memory, epoch, global_step, all_raw_patches,
scores={"hard": agg_hard, "soft": agg_soft},
)

n_total_patches = len(all_failure_patches) + len(all_success_patches)
step_rec["n_patches"] = n_total_patches
step_rec["n_failure_patches"] = len(all_failure_patches)
Expand Down Expand Up @@ -1605,6 +1626,10 @@ def _persist_runtime_state(last_completed_step: int) -> None:
):
best_origin = current_origin

hook_post_evaluate(
memory, epoch, global_step, current_skill, current_score, cfg,
)

if use_skill_aware:
current_skill = _flush_skill_aware_appendix(
current_skill, all_raw_patches, step_rec, step_dir, cfg,
Expand Down Expand Up @@ -2496,4 +2521,9 @@ def _persist_runtime_state(last_completed_step: int) -> None:
f"calls={t['calls']})"
)

# Release the memory worker. SkillMemory calls run on daemon threads,
# so this is tidiness rather than a shutdown requirement.
if memory is not None:
memory.close()

return summary
17 changes: 17 additions & 0 deletions skillopt/memory/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""skillopt.memory — optional, opt-in mem0-backed persistent memory.

Disabled unless a config sets ``mem0_enabled: true``; see
:mod:`skillopt.memory.settings` for the resolution rules and
:mod:`skillopt.memory.redaction` for what is stripped before anything is sent.
"""
from skillopt.memory.mem0_backend import SkillMemory, mem0_available
from skillopt.memory.redaction import redact_for_upload
from skillopt.memory.settings import Mem0Settings, resolve_settings

__all__ = [
"Mem0Settings",
"SkillMemory",
"mem0_available",
"redact_for_upload",
"resolve_settings",
]
Loading