mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* feat(memory): add Jev pre-screening and signal classification over a shared TypeSafe client - refactor(typesafe): extract a shared Jev (TypeSafe) client and unify its configuration: one connection-precedence rule, credential fingerprint, wire-size counting and error taxonomy, shared by the guardrail gate and the memory hooks. - feat(memory): wire `memory.prescreen` and `memory.signal_classification` into DeerMem through one injected judge hook (both off by default, `enforce` gated by the shadow evaluation). - config: add the top-level `typesafe:` block and the two judging slots to `config.example.yaml` and the Helm values, bump `config_version` to 50, and sync the Helm README example with it. - test(memory): add the shadow-evaluation script (`eval_memory_prescreen.py`) with per-stratum gates, censoring and the no-network baseline. Review follow-ups folded in: - `mutations_accepted` counts only facts that survive capacity enforcement and the upload-event scrub that precedes persistence, only removals/reinforcements of ids that existed, and persisted near-duplicate merges (matched by id, or by identity for legacy id-less facts); it is published only after persistence succeeds. - The cached manager hot-reloads its judge when a memory slot or the shared `typesafe` block changes (including a credential rotation); publication is serialized and revalidates the config generation, so a build started before an `enforce` -> `off` rollback cannot land after it. - Cache provenance is per answer: the `cached` flag and the reported model follow the answers a side actually consumed, across partial merges and answerless retries. - An oversized noul integer stays a per-question error instead of raising. - Incremental call savings use the shared scored denominator. - The sensitive-stratum gate requires evidence for each of identity, preference and correction. * fix(typesafe): resolve one credential per layer and refuse unusable connection values Review follow-ups on the shared TypeSafe client (#5906): - A consumer's `api_key_env` is no longer overridden by a literal `api_key` in the shared `typesafe:` block: the first layer that sets either credential setting decides it, and inside one layer the literal key wins (the standalone semantics). A consumer whose endpoint does not accept the block's key no longer sends it on every call, and the missing-credential message keeps naming the variable that layer configured. - `base_url` is validated where it is configured: `http(s)` only, with no query, fragment, embedded credentials, whitespace or control characters, and a malformed port fails at construction instead of on every request (httpx raises `UnsupportedProtocol` / `InvalidURL` per call, and the query would have been swallowed into the appended endpoint path). - A credential that cannot travel as a header value — surrounding whitespace from a secret mounted from a file, a non-printable character — is refused when the connection resolves. It used to construct fine and then put the whole `Bearer <key>` value into h11's `LocalProtocolError` message, which the guardrail middleware's `logger.exception` prints. - The client raises transport failures without chaining the original exception, so that message can no longer carry the credential through the cause chain; the exception type stays in the message for diagnosis. - `TypeSafeConfig` validates in strict mode, agreeing with the consumer `config` path: `max_attempts: true` was silently read as `1`. An integer stays a usable float, so `timeout: 5` keeps working. Tests: 20 cases across `tests/test_typesafe_config.py` and `tests/test_typesafe_client.py`, each red on the previous revision and green now. 367 focused typesafe/memory/config tests, `make test-blocking-io` (167) and the eval-script suites (50) pass; `ruff check` and `ruff format --check` are clean. Docs synced: `backend/docs/GUARDRAILS.md`, `typesafe/AGENTS.md`, `config.example.yaml`. * fix(typesafe,memory): bound the reply, record request failures, gate enforce on the evidence Second review round on #5906: the remaining items from the review thread and from the follow-up pass. Typesafe client (the reply is treated as hostile input too): - Send `Accept-Encoding: identity`, refuse a body that carries a non-identity `Content-Encoding`, and read through a bounded `iter_bytes` that stops past `MAX_RESPONSE_BYTES` (64 KiB). A gzip body that expands to 128 MiB used to be decoded in full and, on the gate path, allowed the tool call unexamined; it is now `invalid_response` (a denial with `fail_closed: true`). - Map `RecursionError` from `json.loads` to `invalid_response`: deep nesting escaped the module's taxonomy, so the memory consumers lost the round's record instead of recording a fallback. Memory judge: - A request-level failure is recorded as `request_failed`, never `no_verdict` (`REASON_REQUEST_FAILED` was defined and never emitted, so an unreachable endpoint was indistinguishable from a provider with no usable answer). The adapters' `decide()` now propagates `TypeSafeError` for the coordinator to record and log; `no_verdict` stays for a question-level failure, and an ineligible side keeps its own reason even when the round's request failed. Shadow evaluation (the enablement gate reads the evidence, not one number): - The miss-rate gate requires the exact one-sided Clopper-Pearson upper bound to meet the 1% target; a sample that only meets the point estimate is `INSUFFICIENT` (0/250 was reported as a pass with a 1.19% bound). - New `baseline_incremental_savings` gate: a baseline that saves as many calls as the model — the shipped all-trivial sample — fails instead of enabling. - Negative counts (`mutations_accepted`, message / batch / token counts) are treated as absent rather than scored: `-3` read as "nothing lost", adding a phantom saving to the numerator and the denominator. Docs: the design documents cited but absent from the repository are replaced with in-repo pointers (`MEMORY_IMPROVEMENTS.md`, `agents/memory/AGENTS.md`, `GUARDRAILS.md`), and the gate and limit wording now matches what the code enforces. Tests: 31 new or updated cases, each red on the previous revision (compressed body refused even when it decodes to a valid envelope, body over the cap refused, body at the cap answered, deep nesting mapped, `request_failed` on each request-level failure, the bound and baseline gates, the negative counter). 454 focused tests, `make test-blocking-io` (167) and the eval suites pass; `ruff check` and `ruff format --check` are clean. * fix(memory-eval): pair the baseline comparison on the same records Review follow-up on the incremental-savings gate added in ed930478 (P2): both sides of the comparison are now the records the no-network baseline can score (`trivial_only` plus a readable signals list). Subtracting that subset's heuristic savings from model savings over *all* scorable skips credited the model with batches the baseline never saw: 300 reviewed skips with a single annotated record computed 300-1=299 "incremental", passed the gate and set `enable_recommended`, while the paired comparison was model=1 against heuristic=1 (incremental 0). - `Evidence` carries the paired numbers (`baseline_paired_skips`, `baseline_model_saved_calls`, `baseline_heuristic_saved_calls`) instead of one heuristic count, filled by `build_report` from the baseline summary. - The gate reports `INSUFFICIENT` when no scored skip carries the annotation and when the annotation covers only a subset of the scored skips (the message carries the paired counts and the `paired of scored` coverage), `FAIL` on a non-positive paired gain, and `PASS` only on a positive gain over the full paired set. - Docs state the pairing/coverage requirement in `backend/docs/MEMORY_IMPROVEMENTS.md` and in the script docstring. Tests: the reviewer's counterexample as a report-level regression (it failed with `assert 'PASS' == 'INSUFFICIENT'` on ed930478 while `enable_recommended` was true), a unit case for the paired-subset `INSUFFICIENT`, and the renamed paired fields. 344 focused tests, `make test-blocking-io` (167) and the eval suites pass; `ruff check` / `ruff format --check` clean. * fix(typesafe): bound the sync body read by the deadline `deadline_seconds` only covered the sync path's headers, the completed body and the parse, so an endpoint that drips bytes just under the read timeout held the caller for one read per piece — the whole of `MAX_RESPONSE_BYTES` past the budget. The read loop now checks the budget before every transport read, which bounds the overshoot to one read. The pieces are the transport's own reads, not re-chunked output: `iter_bytes(chunk_size)` pulls many reads inside a single yielded chunk, so a check at that granularity would never see a drip. `tests/test_typesafe_client.py` pins it with a valid envelope delivered as 24 pieces of 50 ms against a 0.2 s deadline: red before (24 pieces pulled), green now (~5), on both the sync and async paths. Docstring and `typesafe/AGENTS.md` track the new granularity. * fix(memory): read the YAML off spelling, and veto a skip over the whole batch Two review findings on the memory judging path. `memory.prescreen.mode` / `memory.signal_classification.mode` rejected the `mode: off` spelling `config.example.yaml` ships and a rollback types: YAML 1.1 parses the unquoted value as a boolean, so both fields saw `False`. The failed reload left the previous judge running, so both fields now normalize `False` to `off` before the `Literal` runs. `True` stays a validation error (no mode it could mean). The examples quote the value, and the docs record why. The pre-screen's L3 veto read `detect_signals`' 6-message window while the batch it answers for is the whole post-watermark feed, so an explicit correction in an earlier turn of an eight-message batch did not stop the skip that then advanced the watermark past it. `detect_signals` gains a `window` parameter (default unchanged) and the updater scans the full feed for the veto; the extraction hint and the reinforcement gate keep the window. Both regressions are red on the previous head: the config test reproduced the `input_value=False` rejection through `AppConfig.from_file`, and the batch test reproduced the prescreen judging (and skipping) a batch whose first turn carries the correction.
309 lines
15 KiB
Python
309 lines
15 KiB
Python
"""Configuration for the memory mechanism (host-shared fields only).
|
|
DeerMem-private fields live in ``backends/deermem/config.py`` (``DeerMemConfig``),
|
|
reached via ``backend_config`` (a dict the factory passes to the backend's
|
|
``__init__``). This module holds ONLY the host-shared fields every backend /
|
|
call site / factory reads: ``enabled`` / ``injection_enabled`` /
|
|
``shutdown_flush_timeout_seconds`` / ``manager_class`` / ``backend_config``.
|
|
Keeping the shared schema slim is what
|
|
makes backends swappable and portable (DeerMem's knobs do not leak onto the
|
|
shared contract).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Host-shared MemoryConfig fields (read by every backend / call site / factory).
|
|
_SHARED_FIELDS = frozenset({"enabled", "mode", "injection_enabled", "shutdown_flush_timeout_seconds", "manager_class", "backend_config", "prescreen", "signal_classification"})
|
|
|
|
# DeerMem-private fields that used to live at the top level of `memory:` in
|
|
# config.yaml (pre-abstraction). On load they are auto-migrated into
|
|
# `backend_config` so an upgrade does NOT silently revert customized settings
|
|
# to defaults. `model_name` maps to `backend_config.model.model` (the new nested
|
|
# model sub-config); the rest are 1:1.
|
|
_LEGACY_DEERMEM_FIELDS = frozenset(
|
|
{
|
|
"storage_path",
|
|
"storage_class",
|
|
"debounce_seconds",
|
|
"max_facts",
|
|
"fact_confidence_threshold",
|
|
"max_injection_tokens",
|
|
"token_counting",
|
|
"guaranteed_categories",
|
|
"guaranteed_token_budget",
|
|
"staleness_review_enabled",
|
|
"staleness_age_days",
|
|
"staleness_min_candidates",
|
|
"staleness_max_removals_per_cycle",
|
|
"staleness_protected_categories",
|
|
"staleness_max_lifetime_multiplier",
|
|
"staleness_max_extension_days",
|
|
"consolidation_enabled",
|
|
"consolidation_min_facts",
|
|
"consolidation_max_groups_per_cycle",
|
|
"consolidation_max_sources",
|
|
"model_name",
|
|
}
|
|
)
|
|
|
|
|
|
def _yaml_off_is_the_off_mode(value: object) -> object:
|
|
"""Read the YAML ``off`` spelling as the mode name it looks like.
|
|
|
|
``yaml.safe_load`` follows YAML 1.1, where an unquoted ``off`` (like ``no`` and
|
|
``false``) is a boolean, so the ``mode: off`` that ``config.example.yaml`` ships
|
|
and that a rollback types arrives here as ``False``. Rejecting it would fail the
|
|
config *reload* that returns a deployment to off, and a failed reload leaves the
|
|
previous judge (and its judging requests) running. ``True`` has no mode it could
|
|
mean, so it is left to the ``Literal``, which rejects it naming the modes.
|
|
"""
|
|
return "off" if value is False else value
|
|
|
|
|
|
class MemoryPrescreenConfig(BaseModel):
|
|
"""Host-shared pre-screen slot: a cost gate over the extraction call.
|
|
|
|
``off`` (default) resolves no class path, constructs nothing and validates no
|
|
credentials, so an unconfigured deployment behaves exactly as before.
|
|
``shadow`` decides and records without changing anything; ``enforce`` may
|
|
actually skip the extraction call, which requires the measured gate in the
|
|
pre-screening design §5.
|
|
"""
|
|
|
|
mode: Literal["off", "shadow", "enforce"] = Field(default="off", description="off = no request and no behavior change; shadow = decide + record; enforce = a skip actually saves the extraction call")
|
|
use: str | None = Field(default=None, description="Provider class path, e.g. deerflow.agents.memory.prescreen.typesafe:TypeSafeMemoryPrescreen")
|
|
config: dict[str, Any] = Field(default_factory=dict, description="Provider-specific settings passed as kwargs")
|
|
|
|
@field_validator("mode", mode="before")
|
|
@classmethod
|
|
def _read_yaml_off(cls, value: object) -> object:
|
|
"""Accept the unquoted YAML ``off`` (see :func:`_yaml_off_is_the_off_mode`)."""
|
|
return _yaml_off_is_the_off_mode(value)
|
|
|
|
|
|
class MemorySignalClassificationConfig(BaseModel):
|
|
"""Host-shared signal-classification slot: additive hint labels (and one narrow veto).
|
|
|
|
``hints`` merges the model's labels into the extraction hint text and may veto
|
|
a pre-screen skip under ``prescreen.mode: enforce``. It never decides
|
|
extraction on its own and never drives deletion.
|
|
"""
|
|
|
|
mode: Literal["off", "shadow", "hints"] = Field(default="off", description="off = no request; shadow = record only; hints = merge labels into the hint text (and, under pre-screen enforce, allow a veto)")
|
|
use: str | None = Field(default=None, description="Provider class path, e.g. deerflow.agents.memory.signals.typesafe:TypeSafeSignalClassifier")
|
|
combine: Literal["auto", "always", "never"] = Field(
|
|
default="auto", description="Request combination with the pre-screen: auto shares when every effective client setting matches, always requires it, never splits every side into its own request"
|
|
)
|
|
config: dict[str, Any] = Field(default_factory=dict, description="Provider-specific settings passed as kwargs")
|
|
|
|
@field_validator("mode", mode="before")
|
|
@classmethod
|
|
def _read_yaml_off(cls, value: object) -> object:
|
|
"""Accept the unquoted YAML ``off`` (see :func:`_yaml_off_is_the_off_mode`)."""
|
|
return _yaml_off_is_the_off_mode(value)
|
|
|
|
|
|
class MemoryConfig(BaseModel):
|
|
"""Host-shared memory configuration (backend-agnostic)."""
|
|
|
|
enabled: bool = Field(
|
|
default=True,
|
|
description="Whether to enable the memory mechanism (call-site gate).",
|
|
)
|
|
mode: Literal["middleware", "tool"] = Field(
|
|
default="middleware",
|
|
description=(
|
|
"Memory operation mode. 'middleware': passive LLM summarization after each turn (current behavior). 'tool': model calls memory tools (memory_search, memory_add, etc.) directly. Mutually exclusive — only one mode runs at a time."
|
|
),
|
|
)
|
|
injection_enabled: bool = Field(
|
|
default=True,
|
|
description="Whether to inject memory into the system prompt (call-site gate).",
|
|
)
|
|
shutdown_flush_timeout_seconds: float = Field(
|
|
default=30.0,
|
|
ge=1.0,
|
|
le=300.0,
|
|
description=(
|
|
"Hard time budget (seconds) for draining the memory backend's "
|
|
"pending-update buffer during Gateway graceful shutdown. The drain "
|
|
"makes one LLM call per pending item, so large IM batches may need "
|
|
"a higher value. Must fit inside the pod's K8s "
|
|
"terminationGracePeriodSeconds (together with channel/scheduler "
|
|
"stop) or K8s SIGKILLs the drain mid-flight. The drain runs on a "
|
|
"daemon thread, so on timeout the process proceeds to exit and any "
|
|
"unfinished tail is dropped (same failure direction as no flush, "
|
|
"scoped to the tail). Host-shared (not backend-private): the host "
|
|
"owns the lifespan budget and the K8s grace relationship."
|
|
),
|
|
)
|
|
manager_class: str = Field(
|
|
default="deermem",
|
|
description=(
|
|
"Memory backend selector. Either a registered backend name "
|
|
"(matching a `backends/<name>/` folder that exposes `MANAGER_CLASS`, "
|
|
"e.g. `deermem` / `noop`) or a dotted import path to a "
|
|
"`MemoryManager` subclass. The factory resolves this at "
|
|
"`get_memory_manager()` time and raises `ValueError` on failure "
|
|
"(fail-fast: memory is persistent state, so an unresolved "
|
|
"manager_class is not silently substituted with a different "
|
|
"storage backend)."
|
|
),
|
|
)
|
|
backend_config: dict[str, Any] = Field(
|
|
default_factory=dict,
|
|
description=(
|
|
"Backend-private config (a dict), passed verbatim to the backend's "
|
|
"`__init__(backend_config=...)` by the factory. Each backend "
|
|
"self-interprets it (DeerMem parses it into `DeerMemConfig`). Values "
|
|
"live in the host config file (`config.yaml` `memory.backend_config`); "
|
|
"they do not belong on the shared `MemoryConfig` schema."
|
|
),
|
|
)
|
|
|
|
prescreen: MemoryPrescreenConfig = Field(
|
|
default_factory=MemoryPrescreenConfig,
|
|
description="Pre-extraction cost gate (off by default): decide whether a batch is worth an extraction call.",
|
|
)
|
|
signal_classification: MemorySignalClassificationConfig = Field(
|
|
default_factory=MemorySignalClassificationConfig,
|
|
description="Signal-classification hints (off by default): reinforcement/correction labels from the batch text, plus a narrow veto over a pre-screen skip.",
|
|
)
|
|
|
|
@model_validator(mode="after")
|
|
def _check_judging_slots_are_usable(self) -> MemoryConfig:
|
|
"""A configured judging slot must name a provider (fail fast, never silently off).
|
|
|
|
An enabled mode without ``use`` would look configured while doing nothing.
|
|
The provider's own validation (credentials, thresholds, limits) runs when
|
|
the memory backend is built, so a bad deployment fails there rather than
|
|
on the first batch.
|
|
"""
|
|
for name, slot in (("prescreen", self.prescreen), ("signal_classification", self.signal_classification)):
|
|
if slot.mode != "off" and not slot.use:
|
|
raise ValueError(f"memory.{name}.use is required when mode={slot.mode!r}")
|
|
return self
|
|
|
|
|
|
def should_use_memory_tools(config: MemoryConfig) -> bool:
|
|
"""Return True when memory should use model-directed tools."""
|
|
return config.enabled and config.mode == "tool"
|
|
|
|
|
|
# Global configuration instance
|
|
_memory_config: MemoryConfig = MemoryConfig()
|
|
|
|
|
|
def get_memory_config() -> MemoryConfig:
|
|
"""Get the current memory configuration.
|
|
|
|
``_memory_config`` is only refreshed as a side effect of ``get_app_config()``
|
|
reloading (via ``_apply_singleton_configs`` -> ``load_memory_config_from_dict``).
|
|
A reader that reaches memory config without going through ``get_app_config()``
|
|
first -- e.g. the agent factory deciding whether to bind the memory tools --
|
|
would otherwise see a stale ``memory.mode`` after a ``config.yaml`` edit, even
|
|
though ``memory.*`` is documented as hot-reloadable. Trigger the same
|
|
signature-checked reload here so the singleton follows the config file.
|
|
|
|
If ``get_app_config()`` has never been called (``_app_config`` is ``None``),
|
|
there is no stale config to refresh, so we keep the pre-existing behaviour
|
|
of returning the in-memory singleton. This avoids picking up a config file
|
|
as a side effect of the first access to ``get_memory_config()``, which would
|
|
break callers that expect module-level defaults (e.g. unit tests).
|
|
"""
|
|
# Lazy import: app_config imports this module, so a top-level import cycles.
|
|
from .app_config import _app_config, get_app_config
|
|
|
|
if _app_config is not None:
|
|
try:
|
|
get_app_config()
|
|
except Exception:
|
|
# If the config file is transiently broken (invalid YAML, schema
|
|
# violation, missing env var, etc.), keep the last-good singleton
|
|
# so an in-flight turn completes normally instead of crashing.
|
|
logger.warning(
|
|
"Failed to reload app config from get_memory_config(); falling back to cached memory config.",
|
|
exc_info=True,
|
|
)
|
|
return _memory_config
|
|
|
|
|
|
def set_memory_config(config: MemoryConfig) -> None:
|
|
"""Set the memory configuration."""
|
|
global _memory_config
|
|
_memory_config = config
|
|
|
|
|
|
def load_memory_config_from_dict(config_dict: dict) -> None:
|
|
"""Load memory configuration from a dictionary.
|
|
|
|
Host-shared fields (``enabled`` / ``mode`` / ``injection_enabled`` /
|
|
``manager_class`` / ``backend_config``) are read directly. DeerMem-private
|
|
fields that used to live at the top level of ``memory:`` in config.yaml
|
|
(pre-abstraction: ``storage_path``, ``max_facts``, ``debounce_seconds``,
|
|
``model_name``, ``token_counting``, ``staleness_*``, ``consolidation_*``,
|
|
...) are **auto-migrated into ``backend_config``** with a warning, so an
|
|
upgrade from a pre-abstraction config does NOT silently revert customized
|
|
settings to defaults. Unknown top-level keys (likely typos) are warned and
|
|
ignored.
|
|
"""
|
|
global _memory_config
|
|
config_dict = dict(config_dict or {})
|
|
backend_config = dict(config_dict.get("backend_config") or {})
|
|
migrated: list[str] = []
|
|
for key in list(config_dict.keys()):
|
|
if key in _SHARED_FIELDS:
|
|
continue
|
|
if key in _LEGACY_DEERMEM_FIELDS:
|
|
value = config_dict.pop(key)
|
|
if value is None or value == "":
|
|
continue # default / empty value, no migration needed
|
|
if key == "model_name":
|
|
# old top-level model_name -> backend_config.model.model
|
|
model_cfg = dict(backend_config.get("model") or {})
|
|
if "model" not in model_cfg:
|
|
model_cfg["model"] = value
|
|
backend_config["model"] = model_cfg
|
|
migrated.append(f"{key} -> backend_config.model.model")
|
|
elif key == "storage_path" and str(value).endswith(".json"):
|
|
# Pre-abstraction storage_path was a FILE path (absolute = shared
|
|
# file opting out of per-user; a relative value like the old default
|
|
# "memory.json" was ignored for per-user). DeerMem now treats it as a
|
|
# root DIRECTORY. Carrying a file-style value verbatim would be
|
|
# resolved as a dir and either orphan per-user memory or hit
|
|
# NotADirectoryError on save. Drop it so the factory's zero-config
|
|
# runtime_home kicks in (per-user location unchanged:
|
|
# {base_dir}/users/{uid}/memory.json) and warn the operator.
|
|
logger.warning(
|
|
"Legacy memory.storage_path=%r looks like a file path; DeerMem now "
|
|
"treats storage_path as a root DIRECTORY (per-user memory under "
|
|
"{storage_path}/users/{uid}/memory.json). Dropped -- memory now "
|
|
"lands under the default root (runtime_home). Set "
|
|
"memory.backend_config.storage_path to a directory if you want a "
|
|
"custom location.",
|
|
value,
|
|
)
|
|
elif key not in backend_config:
|
|
# don't override an explicit backend_config value
|
|
backend_config[key] = value
|
|
migrated.append(f"{key} -> backend_config.{key}")
|
|
else:
|
|
logger.warning(
|
|
"Unknown memory config key %r at top level (not a shared field %s nor a known legacy DeerMem field); ignored.",
|
|
key,
|
|
sorted(_SHARED_FIELDS),
|
|
)
|
|
if migrated:
|
|
logger.warning(
|
|
"Migrated legacy top-level memory fields into backend_config; move them under memory.backend_config in config.yaml to silence this: %s",
|
|
", ".join(migrated),
|
|
)
|
|
config_dict["backend_config"] = backend_config
|
|
_memory_config = MemoryConfig(**config_dict)
|