Skip to content

feat(memory): add optional Mem0 backend for skill iteration and reflection tracking - #118

Open
jrauch713-svg wants to merge 4 commits into
microsoft:mainfrom
jrauch713-svg:feat/mem0-memory-integration
Open

feat(memory): add optional Mem0 backend for skill iteration and reflection tracking#118
jrauch713-svg wants to merge 4 commits into
microsoft:mainfrom
jrauch713-svg:feat/mem0-memory-integration

Conversation

@jrauch713-svg

@jrauch713-svg jrauch713-svg commented Jul 10, 2026

Copy link
Copy Markdown

Summary

Adds an optional, opt-in skillopt/memory backend that persists skill iterations and reflection summaries to Mem0, and reads a small amount of relevant history back into the Reflect stage so the memory participates in training rather than only recording it.

Revised to the reviewable minimum requested in review. The two substantive objections — silent enablement and a write-only design — are both addressed.

What changed since the first version

1. Explicit opt-in. Uploading requires train.mem0_enabled: true. A MEM0_API_KEY present in the environment for another application no longer enables anything; a disabled run does not even read the key. All mem0_* resolution lives in skillopt/memory/settings.py.

2. Redaction before any outbound request. skillopt/memory/redaction.py is a single choke point — no payload reaches the client without passing through redact_for_upload. It strips vendor API keys, bearer/basic tokens, JWTs, private keys, key = value secret assignments, the project root, and /home/<user>-style prefixes. Credentials are scrubbed before paths are collapsed, so a key embedded in a home path cannot survive. Relative paths and filenames are deliberately preserved so stored memories stay useful.

The patterns mirror skillopt_sleep/staging.py rather than importing it, since pyproject.toml keeps that package decoupled with zero research dependency.

3. Stable project-specific namespace. Memories are scoped to skillopt:<env>:<sha256-prefix-of-project-root> — stable across runs of one project, distinct across projects, and the raw path is hashed rather than transmitted. Replaces the previous config-name/env user id that could mix unrelated projects.

4. One bounded retrieval call before reflection. hook_pre_reflect fetches relevant history and appends it to the reflection input, which is what turns the integration from external logging into a memory loop. It is assigned to a separate reflect_context variable so the autonomous learning-rate decision and the rewrite prompt continue to see the unaugmented context.

Also included

  • Every call is wall-clock bounded by mem0_timeout_seconds (default 5s), so an unreachable service costs one bounded pause instead of blocking a training step.
  • Malformed patches (None, strings, non-lists) are filtered rather than raised inside a swallowing try.
  • mem0 optional extra declared, deliberately outside all.
  • mem0_* keys mapped through _FLATTEN_MAP under train.. Without this they were silently dropped for structured configs — which is every config in the repo — leaving the feature inert with no error.
  • configs/features/mem0_memory.yaml, a documented opt-in example following the soft_gate.yaml convention.
  • docs/reference/config.md documents every key and exactly what leaves the machine.

Deliberately not included

Per review guidance: batching, async writes, sophisticated ranking, and a broad benchmark study are left as follow-up work.

Test plan

tests/test_mem0_memory.py — 15 cases, no network. Runs under pytest or directly via python tests/test_mem0_memory.py.

  • A bare MEM0_API_KEY does not enable uploads, and no backend is constructed
  • Hooks are no-ops when memory is disabled
  • Explicit opt-in writes to the backend
  • Secrets and home paths are stripped; useful structure preserved
  • Payload cap applied after redaction
  • A credential embedded in a home path is still redacted
  • Namespace is stable per project, distinct across projects, and path-free
  • Explicit mem0_namespace override wins
  • Structured-config keys survive flattening
  • The shipped example config actually enables the feature
  • Retrieved memory reaches the actual reflection prompt, verified end-to-end by capturing the user message handed to the optimizer
  • Retrieval can be disabled independently while writes continue
  • Service failure degrades gracefully; reflection input unchanged
  • Slow service is bounded by mem0_timeout_seconds
  • Malformed patches are filtered, not raised

Full suite: 485 passed, 6 skipped, 0 failed. ruff check clean on all new files. Rebased onto current main.

Note: all tests are mocked — no call has been made against the live Mem0 service.

@Yif-Yang

Copy link
Copy Markdown
Contributor

Thanks for exploring persistent memory for SkillOpt. Memory can be valuable if retrieval measurably improves training, but the current design is not safe to merge yet.

The primary blocker is consent and data handling: merely having a global MEM0_API_KEY causes the trainer to upload up to 4,000 characters of the full skill plus reflection patch summaries to a third-party hosted service. There is no explicit SkillOpt opt-in, redaction, or user-facing disclosure. A key present for another application must not silently enable data export.

Other required changes:

  • Add an explicit config flag/backend selection in addition to the key, defaulting off.
  • Redact secrets and sensitive paths/content before any upload, and document exactly what leaves the machine.
  • Use a stable project-specific namespace; the current env/config-name user ID can mix unrelated projects.
  • Avoid two synchronous network writes per training step; use bounded timeouts plus batching/async behavior.
  • Add the optional dependency/extra and configuration schema/documentation.
  • Validate non-dict or null patches instead of throwing and then swallowing the error.
  • Add mocked tests for disabled, enabled, failure, redaction, namespace, and patch-validation paths.
  • Integrate retrieval into the training loop and provide evidence that it improves quality or utility. At present the trainer only writes; it never calls the retrieval APIs, so this adds privacy/cost/latency without a training benefit.

The CLA check is also still incomplete. Please rework this in the original PR if you would like to continue; that preserves your authorship while allowing a safe review. We can re-review once explicit consent, privacy controls, tests, and a real retrieval benefit are present.

@Yif-Yang Yif-Yang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes because the current opt-in and data-handling design can upload private skill and patch content without explicit SkillOpt consent, and the integration lacks tests and a retrieval benefit. Please address the detailed maintainer comment above.

@Yif-Yang

Copy link
Copy Markdown
Contributor

Hi @jrauch713-svg — following up with a concise reminder of what is still required before this can be considered:

  • Explicit opt-in: today a global MEM0_API_KEY silently enables upload. Uploading must be selected explicitly, not inferred from a key that may exist for another application.
  • Redaction of sensitive content before it leaves the process.
  • Stable project-specific namespacing for stored data.
  • A real retrieval benefit: the current integration is write-only and never reads memory back into training.
  • Focused tests covering disabled/enabled behavior, redaction, namespacing, malformed patches, and service failures.
  • Completion of the CLA, which is still pending.

The branch is also conflicting. Do you plan to continue with this direction? If so, please let us know; we are glad to help keep the scope reviewable.

@jrauch713-svg

jrauch713-svg commented Jul 25, 2026 via email

Copy link
Copy Markdown
Author

@Yif-Yang

Copy link
Copy Markdown
Contributor

Not at all — we are not asking you to stop, and you did nothing wrong by exploring this direction. We think a Mem0 integration could be valuable for SkillOpt.

Our review only means that the current implementation needs some iteration before it can be merged. The main concerns are that it can upload skill/reflection content without an explicit SkillOpt opt-in, and that it currently writes memories without retrieving and using them to improve training.

If you would like to continue, we would be happy to help break the work into smaller, clearer steps:

  1. Complete the CLA by replying to the policy bot with the option that correctly applies to you.
  2. Add an explicit, disabled-by-default Mem0 configuration and redact sensitive content before upload.
  3. Add project-specific namespacing and focused tests.
  4. Add retrieval back into the training loop so the integration provides a measurable benefit.

There is no pressure to do everything at once. Please let us know if you would like to continue, and we can help review each step.

@jrauch713-svg

jrauch713-svg commented Jul 25, 2026 via email

Copy link
Copy Markdown
Author

@Yif-Yang

Copy link
Copy Markdown
Contributor

Yes — please continue! We would be very happy to work with you on this, and your instinct to narrow the scope is exactly right. We also appreciate that you identified the write-only design as the central issue.

Our preference would be to keep one small retrieval path in this PR, because that is what turns the integration from external logging into a useful memory loop. It does not need to become a large system. A reviewable minimum would be:

  1. An explicit Mem0 setting that is disabled by default — the presence of MEM0_API_KEY alone must not enable uploads.
  2. Redaction before any outbound request, plus a stable project-specific namespace.
  3. One bounded retrieval call before reflection, with the retrieved context actually included in the reflection input.
  4. Mocked tests showing that disabled mode sends nothing, redaction/namespacing work, retrieved context reaches reflection, and service failures degrade gracefully.

You do not need to solve batching, async writes, sophisticated ranking, or run a large benchmark study in this iteration. A deterministic end-to-end test and a small opt-in smoke example are enough for us to review the correctness of the first useful loop; broader performance evaluation can be follow-up work.

Please take the time you need, complete the CLA using the option that applies to you, and rebase onto current main when convenient. Feel free to push an incremental version or ask questions before everything is finished — we are glad to help, especially for your first contribution here. Thank you for continuing!

@jrauch713-svg

jrauch713-svg commented Jul 25, 2026 via email

Copy link
Copy Markdown
Author

@jrauch713-svg

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

…ction tracking

Wires SkillMemory into ReflACTTrainer via non-breaking hooks: records each
step's skill/score after the evaluation gate and each step's reflection
patches after the accumulation loop. Degrades to a no-op when MEM0_API_KEY
is unset.
…l path

Addresses the review on microsoft#118. The integration was previously write-only and
enabled by the mere presence of MEM0_API_KEY; both are fixed here.

1. Explicit opt-in. Uploading now requires `mem0_enabled: true`. A key present
   in the environment for another application no longer enables anything — a
   disabled run does not even read the key. New `skillopt/memory/settings.py`
   resolves all `mem0_*` config in one place.

2. Redaction before any outbound request. New `skillopt/memory/redaction.py`
   is the single choke point: no payload reaches the client without passing
   through `redact_for_upload`, which strips vendor keys, bearer/basic tokens,
   JWTs, private keys, `key = value` secret assignments, the project root, and
   `/home/<user>`-style prefixes. Credentials are scrubbed before paths are
   collapsed, so a key embedded in a home path cannot survive. Relative paths
   and filenames are preserved so stored memories remain useful. The patterns
   deliberately mirror `skillopt_sleep/staging.py` rather than importing it —
   pyproject keeps that package decoupled with zero research dependency.

3. Stable project-specific namespace. Memories are scoped to
   `skillopt:<env>:<sha256-prefix-of-project-root>`, which is stable across
   runs of one project and distinct across projects. The raw path is hashed,
   never transmitted. Replaces the previous config-name/env user id that could
   mix unrelated projects.

4. One bounded retrieval call before reflection. `hook_pre_reflect` fetches
   relevant history and appends it to the reflection input, turning the
   integration from external logging into a memory loop. It is deliberately
   assigned to a separate `reflect_context` variable so the autonomous
   learning-rate decision and the rewrite prompt continue to see the
   unaugmented context.

Also in this commit:

- Every call is wall-clock bounded by `mem0_timeout_seconds` (default 5s), so
  an unreachable service costs one bounded pause instead of blocking a step.
- Malformed patches (None, strings, non-lists) are filtered rather than raised
  inside a swallowing try.
- The `mem0` optional extra is declared, deliberately outside `all`.
- The `mem0_*` keys are mapped through `_FLATTEN_MAP` under `train.`. Without
  this they were silently dropped for structured configs — which is every
  config in the repo, since they all inherit `_base_/default.yaml` — leaving
  the feature inert with no error.
- `configs/features/mem0_memory.yaml` is a documented opt-in example following
  the `soft_gate.yaml` convention.
- The config reference documents every key and exactly what leaves the machine.

Tests (`tests/test_mem0_memory.py`, 15 cases, no network) cover: a bare
MEM0_API_KEY not enabling uploads, explicit opt-in writing, redaction of
secrets and home paths, cap applied after redaction, namespace stability and
project separation, structured-config keys surviving flattening, the shipped
example config actually enabling the feature, retrieval reaching the actual
reflection prompt (verified end-to-end by capturing the optimizer's user
message), retrieval being independently disableable, graceful degradation on
service failure, timeout bounding, and malformed-patch filtering.

Not included, per review guidance: batching, async writes, ranking, and
benchmark study are left as follow-up work.
Copilot AI review requested due to automatic review settings July 27, 2026 18:30
@jrauch713-svg
jrauch713-svg force-pushed the feat/mem0-memory-integration branch from 64326a5 to 27d6cd9 Compare July 27, 2026 18:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds an opt-in persistent memory integration for SkillOpt training runs, backing storage/retrieval with Mem0 and wiring it into the ReflACT training loop via hooks that no-op unless explicitly enabled.

Changes:

  • Introduces a new skillopt.memory package (settings resolution, redaction, Mem0 backend, and trainer hooks) to store skill iterations and reflection summaries and optionally retrieve relevant context for reflection.
  • Wires memory hooks into the main trainer loop and extends config flattening plus docs/example config to expose train.mem0_* parameters.
  • Adds an end-to-end mocked test suite validating opt-in safety, redaction, namespacing, retrieval plumbing, graceful failure, time bounds, and malformed patch handling.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_mem0_memory.py Adds comprehensive mocked coverage for opt-in behavior, redaction, retrieval, and failure/timeout handling.
skillopt/memory/trainer_hooks.py Implements maybe_init_mem0 plus pre/post hooks to read/write memory around reflect/evaluate.
skillopt/memory/settings.py Adds strict opt-in settings resolution (never consult env key unless enabled) and namespacing.
skillopt/memory/redaction.py Centralizes outbound redaction for secrets and machine-identifying paths.
skillopt/memory/mem0_backend.py Implements the Mem0 client wrapper with redaction, truncation, bounded calls, and persistence APIs.
skillopt/memory/init.py Exposes the new memory package public API.
skillopt/engine/trainer.py Calls the memory hooks around reflect/evaluate and initializes the backend once per run.
skillopt/config.py Maps structured train.mem0_* config keys into the flattened trainer config.
pyproject.toml Adds a mem0 optional dependency extra (mem0ai).
docs/reference/config.md Documents mem0 memory configuration, safety properties, and what data leaves the machine.
configs/features/mem0_memory.yaml Adds a shipped example config that enables mem0 memory explicitly under train:.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 1048 to +1052
# ── Training loop ────────────────────────────────────────────────
t_loop_start = time.time()

memory = maybe_init_mem0(cfg)

Comment thread skillopt/memory/mem0_backend.py Outdated
Comment on lines +103 to +105
except _FuturesTimeout:
print(f" [mem0] call exceeded {self.settings.timeout_seconds}s — continuing without it")
return None
Comment thread skillopt/memory/mem0_backend.py Outdated
Comment on lines +245 to +248
text = rec.get("memory") or rec.get("text") or ""
if not isinstance(text, str) or not text.strip():
continue
lines.append(f"- {text.strip()}")
@jrauch713-svg

Copy link
Copy Markdown
Author

Hi @Yif-Yang — pushed. The branch is rebased onto current main and the four items from your reviewable minimum are in.

1. Explicit opt-in. Uploading now requires train.mem0_enabled: true. A MEM0_API_KEY present for another application no longer enables anything — a disabled run doesn't even read the key. All mem0_* resolution is in skillopt/memory/settings.py.

2. Redaction + namespacing. skillopt/memory/redaction.py is a single choke point; no payload reaches the client without passing through it. It strips vendor keys, bearer/basic tokens, JWTs, private keys, key = value secret assignments, the project root, and /home/<user> prefixes. Credentials are scrubbed before paths are collapsed, so a key embedded in a home path can't survive. Relative paths and filenames are preserved so stored memories stay useful.

Namespaces are skillopt:<env>:<sha256-prefix-of-project-root> — stable across runs of one project, distinct across projects, and the raw path is hashed rather than sent.

I mirrored the secret patterns from skillopt_sleep/staging.py rather than importing them, since pyproject.toml keeps that package decoupled with zero research dependency. Happy to change that if you'd rather share the code.

3. One bounded retrieval call before reflection. hook_pre_reflect fetches relevant history and appends it to the reflection input. I deliberately assigned it to a separate reflect_context variable rather than reusing step_buffer_context, because that same variable also feeds the autonomous learning-rate decision and the rewrite prompt — those keep seeing the unaugmented context.

4. Mocked teststests/test_mem0_memory.py, 15 cases, no network. Covers: a bare MEM0_API_KEY not enabling uploads, explicit opt-in writing, redaction of secrets and home paths, the cap applied after redaction, namespace stability and project separation, retrieval reaching the actual reflection prompt (verified end-to-end by capturing the user message handed to the optimizer), retrieval being independently disableable, graceful degradation on service failure, timeout bounding, and malformed-patch filtering.

Also in this push:

  • Every call is wall-clock bounded by mem0_timeout_seconds (default 5s), so an unreachable service costs one bounded pause instead of blocking a step.
  • Malformed patches (None, strings, non-lists) are filtered rather than raised inside a swallowing try.
  • mem0 optional extra declared, deliberately outside all.
  • configs/features/mem0_memory.yaml as a documented opt-in example, following the soft_gate.yaml convention.
  • docs/reference/config.md documents every key plus exactly what leaves the machine.

Two things I hit while rebasing that are worth flagging:

  • The original diff showed ~20 changed lines in trainer.py that weren't real changes — a LF→CRLF conversion of the configure_qwen_chat block. That's also what caused the conflict with the newer configure_cursor_exec block. The actual integration is 12 lines; it's cleaned up now.
  • The mem0_* keys needed mapping into _FLATTEN_MAP under train.. As flat top-level keys they were silently dropped for structured configs — which is every config in the repo — so the feature would have appeared inert with no error. Regression test added for that.

Full suite: 485 passed, 6 skipped, 0 failed. ruff check clean on all new files.

One honest caveat: all tests are mocked — nothing has been run against the live Mem0 service yet. If you'd like a real smoke run before merging, say so and I'll do it.

Left out per your guidance: batching, async writes, ranking, and benchmark evaluation.

Thanks again for the detailed review and for breaking it into steps — it made this much easier to approach.

…ilure, cleanup

Three findings from the Copilot review pass on the previous commit. All are real.

1. Redact retrieved text before it enters the reflection prompt. Outbound
   redaction alone was insufficient: mem0 is an external store that may hold
   records written by an older version of this code, another tool, or a shared
   namespace, and anything it returns is forwarded to the optimizer's model
   provider. Retrieved records are now scrubbed on the way in as well as out,
   so untrusted store contents cannot leak to a second third party.

2. Bound the cost of a wedged service properly. With a single worker, a call
   stuck on a socket read left the executor occupied, so every later call
   queued behind it and still paid the full timeout — the cost recurred on
   every remaining step and the queue grew without bound. On timeout the
   future is cancelled and the executor retired, and after three consecutive
   failures the backend disables itself for the rest of the run. A
   persistently unreachable service now costs a bounded total rather than a
   bounded amount per step. A successful call resets the counter.

3. Guarantee the worker thread is released. SkillMemory owned a
   ThreadPoolExecutor whose non-daemon worker could keep the interpreter alive
   at exit if wedged. `close()` is now idempotent and cancels pending futures,
   an atexit hook covers aborted runs, and `train()` closes the backend on the
   normal path.

Five tests added (20 total in tests/test_mem0_memory.py): retrieved secrets and
home paths are stripped before reaching the prompt, repeated timeouts disable
the backend within a bounded wall-clock budget, the executor is retired after a
timeout, close() is idempotent and refuses later calls, and a success resets the
failure counter.

Full suite: 490 passed, 6 skipped. ruff clean on all new files.
Copilot AI review requested due to automatic review settings July 29, 2026 02:02
@jrauch713-svg

Copy link
Copy Markdown
Author

Pushed db905bd addressing the three points from the automated review pass. All three were valid:

  1. Retrieved text is now redacted on the way in, not just on the way out. This was the important one. mem0 is an external store that may hold records written by an older version of this code, another tool, or a shared namespace — and anything it returns goes straight into the reflection prompt and on to the optimizer's model provider. Scrubbing only outbound meant untrusted store contents could reach a second third party. Retrieved records now get the same redaction pass as anything leaving the process.

  2. The timeout is now genuinely bounded across a run, not just per call. With a single worker, a request stuck on a socket read left the executor occupied, so every later call queued behind it and still paid the full timeout — the cost recurred every step and the queue grew without bound. On timeout the future is cancelled and the executor retired, and after three consecutive failures the backend disables itself for the remainder of the run. A successful call resets the counter. My earlier description of this as "bounded" was too generous; it's accurate now.

  3. The worker thread is released. close() is idempotent and cancels pending futures, an atexit hook covers aborted runs, and train() closes the backend on the normal path.

Five tests added (20 total, still no network): retrieved secrets and home paths are stripped before reaching the prompt, repeated timeouts disable the backend inside a bounded wall-clock budget, the executor is retired after a timeout, close() is idempotent and refuses later calls, and a success resets the failure counter.

Full suite: 490 passed, 6 skipped. ruff check clean on all new files.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

skillopt/engine/trainer.py:2444

  • memory.close() is only called on the normal return path. If train() raises mid-run and the caller catches the exception (e.g., in a long-lived process), the mem0 ThreadPoolExecutor thread can remain alive until process exit. Consider wrapping the training body in a try/finally so memory.close() always runs once memory has been initialised.
        # Release the memory worker thread. SkillMemory also registers an
        # atexit hook, so an aborted run is covered too; this is the clean
        # path so a long-lived process does not hold the thread until exit.
        if memory is not None:
            memory.close()

skillopt/memory/settings.py:113

  • resolve_settings derives the namespace digest from out_root, but scripts/train.py auto-generates out_root with a timestamp per run (scripts/train.py:573-580). That makes the namespace (and project_root used for redaction) change every run by default, defeating the stated goal of stable, project-specific memory across runs.
    project_root = os.path.abspath(str(cfg.get("out_root") or os.getcwd()))
    namespace = str(cfg.get("mem0_namespace") or "").strip()
    if not namespace:
        namespace = project_namespace(project_root, str(cfg.get("env") or ""))

skillopt/memory/mem0_backend.py:168

  • _payload() treats mem0_max_chars <= 0 as a 0-length limit but still appends the truncation marker, producing a payload that is just a newline + "[...truncated]". If users set mem0_max_chars: 0 to avoid uploading text, this still uploads content (and may violate upstream expectations for non-empty text).
        limit = max(0, int(self.settings.max_chars))
        if len(redacted) > limit:
            return redacted[:limit] + "\n[...truncated]"
        return redacted

@jrauch713-svg
jrauch713-svg requested a review from Yif-Yang July 29, 2026 03:02
@jrauch713-svg

Copy link
Copy Markdown
Author

Sorry still dont know what im doing lol.

@Yif-Yang Yif-Yang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you for the substantial update. You addressed the original review well: the feature is explicitly opt-in, outbound and inbound content is redacted, retrieval now reaches reflection, the CLA is complete, and the focused suite passes. The Mem0 direction remains valuable.

Our latest review found three remaining correctness/safety issues:

  1. mem0_api_key is not included in skillopt.engine.trainer._SECRET_KEYS. When supplied in YAML/config rather than only through the environment, the resolved key is currently written into config.json in plaintext. Please add it to the central config-artifact redaction path and test both config.json and any summary/artifact serialization.
  2. The default namespace is derived from cfg["out_root"]. In normal usage that identifies an output/run directory, not the project, so changing the run directory produces a different namespace and defeats cross-run retrieval. Please derive it from a stable project/repository identity (or require an explicit namespace) and add a test showing two output directories for the same project resolve to the same namespace.
  3. ThreadPoolExecutor.shutdown(wait=False) cannot stop a request already blocked in a worker, and its non-daemon thread can keep Python alive after the apparent timeout. Retiring the executor can leave up to three stuck threads before the circuit breaker opens. The unit test reports quickly because it measures the caller, but a process-level check still waits for the worker to finish. Please enforce the timeout at the Mem0/HTTP client layer, or isolate calls in a terminable process, and add a subprocess test proving a permanently blocked request cannot prevent interpreter exit.

This is no longer a request for a redesign or a benchmark—these are focused fixes to make the implementation safe in real runs. A live service smoke test can wait until after these deterministic issues are resolved. We appreciate the care you have already put into this first contribution and will re-review promptly.

@jrauch713-svg

jrauch713-svg commented Jul 29, 2026 via email

Copy link
Copy Markdown
Author

…eout

Addresses the three issues from the second maintainer review. All three were
confirmed against the code before fixing.

1. `mem0_api_key` was absent from `trainer._SECRET_KEYS`, so a key supplied in
   YAML rather than the environment was written in plaintext to both
   `config.json` and `summary["config"]` — both serialise through
   `_redact_cfg`. Added to the central redaction set. Doubly worth fixing
   because `configs/features/mem0_memory.yaml` offers that field.

2. The default namespace was derived from `cfg["out_root"]`, which
   `scripts/train.py` defaults to
   `outputs/skillopt_<env>_<model>_<timestamp>`. That minted a fresh namespace
   on every run, so cross-run retrieval could never return anything — the
   feature was not merely degraded but inert. Identity now comes from the
   enclosing git repository root, falling back to the working directory, via
   the new `project_identity()`. `out_root` still anchors path redaction, which
   is what it is correct for.

3. The timeout was not enforced where the work happens. `ThreadPoolExecutor`
   workers are non-daemon and `concurrent.futures` installs an atexit hook that
   joins them, so a request blocked in a socket read held the interpreter open
   at shutdown regardless of `shutdown(wait=False)`, and up to three threads
   could wedge before the circuit breaker opened. Two changes:
   - The timeout is pushed into the HTTP layer: `MemoryClient` accepts an
     injected `httpx.Client`, so it is constructed with
     `timeout=mem0_timeout_seconds` instead of mem0's default of 300s. The
     request now aborts rather than being abandoned.
   - Calls run on daemon threads, so even a pathologically stuck request cannot
     delay interpreter exit. The executor and its atexit hook are gone.

Tests: 22 total (was 20), still no network.
  - `test_mem0_api_key_is_redacted_from_config_artifacts` covers `_redact_cfg`
    (both artifacts) and asserts the live config is not mutated.
  - `test_namespace_is_stable_across_different_out_roots` proves two runs with
    different timestamped output directories resolve to one namespace.
  - `test_blocked_request_cannot_prevent_interpreter_exit` spawns a real
    interpreter, wedges a request, and requires the process to exit on its own.
    The previous caller-side assertion passed while the process hung, so the
    check had to move to process level.

Full suite: 492 passed, 6 skipped. ruff clean on all new files.
Copilot AI review requested due to automatic review settings July 30, 2026 01:16
@jrauch713-svg

Copy link
Copy Markdown
Author

Thanks — all three were real, and I confirmed each against the code before changing anything. Pushed a16783e.

1. mem0_api_key redaction. Added to trainer._SECRET_KEYS. Both config.json and summary["config"] serialise through _redact_cfg, so that one addition covers both artifacts; the test asserts the key is absent from the serialised output and that the live config dict is not mutated. This one stung a little — configs/features/mem0_memory.yaml offers mem0_api_key as a field and I had written a comment recommending the env var "to keep keys out of YAML", so I had noticed the hazard and documented around it instead of closing it.

2. Namespace identity. You were right, and it was worse than inert-on-changed-directory: scripts/train.py defaults out_root to outputs/skillopt_<env>_<model>_<timestamp>, so the namespace changed on every run and cross-run retrieval could never return anything at all. Identity now comes from project_identity() — the enclosing git repository root, falling back to the working directory — and out_root is used only to anchor path redaction, which is what it is right for. Test: two runs with different timestamped output directories resolve to one namespace, and the run directory does not appear in it.

3. Timeout enforcement. Also correct, and my earlier test was measuring the wrong thing. Two changes:

  • The timeout now lives at the HTTP layer. MemoryClient accepts an injected httpx.Client, so it is built with timeout=mem0_timeout_seconds rather than mem0's default of 300s. The request aborts instead of merely being abandoned.
  • Calls run on daemon threads and the ThreadPoolExecutor is gone entirely. That was the root of the shutdown hang: its workers are non-daemon and concurrent.futures installs an atexit hook that joins them, so shutdown(wait=False) could never help.

The test moved to process level as you asked — it spawns a real interpreter, wedges a request permanently, and requires the process to exit on its own. Worth recording that before the fix the caller returned in 0.20s (which my old assertion was happy with) while the process never exited at all; that gap is exactly what you identified.

Suite is 22 tests, still no network. Full run: 492 passed, 6 skipped; ruff check clean on all new files.

Happy to do the live service smoke test whenever you'd like it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (7)

skillopt/memory/mem0_backend.py:103

  • from_settings() currently returns a SkillMemory instance when client is provided even if the run did not opt in (settings.usable is false). That contradicts the documented behavior (“None unless opted in”) and makes it easier for callers to accidentally bypass the feature flag.
        if not settings.usable and client is None:
            return None
        if client is None and not _MEM0_AVAILABLE:
            return None
        return cls(settings, client=client)

docs/reference/config.md:187

  • The failure-behavior docs say a timed-out call “retires the worker”, but there is no long-lived worker to retire; the backend abandons a stuck call (daemon thread) and future calls don’t queue behind it. Please adjust this wording to reflect the actual implementation.
A call that exceeds `mem0_timeout_seconds` releases the training step on time and
retires the worker, so the next step does not queue behind a wedged request. After
three consecutive failures the backend stops calling out for the remainder of the run

skillopt/memory/mem0_backend.py:73

  • SkillMemory can currently be constructed with a custom client even when settings.usable is false, which bypasses the opt-in requirement described in the module safety contract and could allow accidental outbound calls from a disabled config. Enforce settings.usable regardless of whether a client is injected.

This issue also appears on line 99 of the same file.

    def __init__(self, settings: Mem0Settings, client: Any | None = None) -> None:
        if client is None:
            if not _MEM0_AVAILABLE:
                raise ImportError("mem0ai is not installed. Run: pip install 'skillopt[mem0]'")
            if not settings.usable:
                raise ValueError("SkillMemory requires mem0_enabled=true and an API key")
            # Enforce the timeout where the request actually happens. mem0's own

skillopt/memory/mem0_backend.py:172

  • mem0_max_chars is documented as a cap on the outbound payload, but _payload() returns redacted[:limit] + "\n[...truncated]", which can exceed the configured limit. If the cap is meant to be strict, the truncation marker should be included within mem0_max_chars.
        limit = max(0, int(self.settings.max_chars))
        if len(redacted) > limit:
            return redacted[:limit] + "\n[...truncated]"
        return redacted

tests/test_mem0_memory.py:190

  • This test currently allows the payload to exceed mem0_max_chars by the length of the truncation marker. If mem0_max_chars is intended as a strict cap on the actual transmitted payload, the assertion should enforce that.
    sent = client.adds[0][0][0]["content"]
    assert len(sent) <= 120 + len("\n[...truncated]")

skillopt/engine/trainer.py:2443

  • The comment says SkillMemory “registers an atexit hook” and refers to a “memory worker thread”, but SkillMemory doesn’t register an atexit hook and it spawns per-call daemon threads instead of running a long-lived worker. This comment should be updated to match the actual behavior.
        # Release the memory worker thread. SkillMemory also registers an
        # atexit hook, so an aborted run is covered too; this is the clean
        # path so a long-lived process does not hold the thread until exit.

docs/reference/config.md:162

  • This table row mentions “the executor is retired”, but the implementation doesn’t use an executor; each outbound call runs in a daemon thread and is time-bounded via Thread.join(timeout). Update the description to avoid implying a thread pool/executor lifecycle that doesn’t exist.

This issue also appears on line 185 of the same file.

| `train.mem0_timeout_seconds` | float | `5.0` | Hard per-call bound. On timeout the executor is retired and training continues; after 3 consecutive failures memory disables itself for the run |

@Yif-Yang

Yif-Yang commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Hi @jrauch713-svg — thank you for the careful update. We rechecked a16783e, and the three issues from our previous review are now properly addressed: config-artifact redaction, stable project identity, and process-safe timeout behavior. The focused and full test suites also pass.

We found one remaining Mem0 API-contract issue before merge. The optional dependency currently declares mem0ai>=0.1.0, while the implementation constructs MemoryClient(..., client=...) and calls:

client.search(query, user_id=namespace, limit=limit)

That range is incompatible at both ends:

  • The injected client= constructor argument is not accepted by early 0.1.x releases; in the official wheels we checked, it first appears in 0.1.98.
  • Current Mem0 2.x rejects a top-level user_id in search(). It requires filters={"user_id": namespace}, and the result-count option is top_k, not limit.

We reproduced the second case locally without making a network request using Mem0 2.0.11, and confirmed the same contract in 2.0.15. It raises ValueError; because _bounded() correctly converts service failures into graceful degradation, retrieval then silently becomes []. Writes can therefore appear to work while the core retrieval path never returns memory. The current tests miss this because FakeClient.search() accepts arbitrary keyword arguments.

Could you please choose and declare one supported Mem0 API generation and add a small contract-level test for its actual search call? The simplest current path would be to target Mem0 2.x, tighten the dependency range accordingly, and call:

search(query, filters={"user_id": namespace}, top_k=limit)

A live-service test is not required for this fix; a deterministic test against the supported client contract is enough. This is the only merge blocker we found in the latest update, and we would be happy to re-review promptly. We really appreciate the care you have put into addressing the earlier feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants