Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
218bcf779b | ||
|
|
7e4ee0d8ea | ||
|
|
b0e429b9ae | ||
|
|
01faf28942 | ||
|
|
fb1b1373ab | ||
|
|
2823bd743d | ||
|
|
837c084be2 | ||
|
|
bf52724461 | ||
|
|
964d8610f3 | ||
|
|
e1933d3307 | ||
|
|
5fdaf6c280 | ||
|
|
58f2fa884a | ||
|
|
c0910621c1 | ||
|
|
dd13c551c4 | ||
|
|
e7d82ee5e8 | ||
|
|
63e009bf6c | ||
|
|
d4cfd1bb74 | ||
|
|
2822cbf368 | ||
|
|
8d23347e1a | ||
|
|
8ae0d1889e | ||
|
|
2479de22eb | ||
|
|
265a22d612 | ||
|
|
93d0c64490 | ||
|
|
1589ae31bc | ||
|
|
38ad2292d1 | ||
|
|
413b9033f9 | ||
|
|
0953c5af35 | ||
|
|
470ea4459a | ||
|
|
b58e95fdae | ||
|
|
42b577f6f8 | ||
|
|
2554fb3b96 | ||
|
|
e32dc987c2 | ||
|
|
598e83525e | ||
|
|
01ace26d08 | ||
|
|
ba2f647a30 | ||
|
|
82d9514d62 | ||
|
|
369351719d | ||
|
|
1429ba2d19 | ||
|
|
4e7d752980 | ||
|
|
fb13a6fda2 | ||
|
|
6ff8b3a3b4 | ||
|
|
17441d368c | ||
|
|
89d29c9c41 | ||
|
|
5271bddce3 | ||
|
|
eac795b2d2 | ||
|
|
d938f1d5b2 | ||
|
|
8cee64c813 | ||
|
|
1ffcc9eb57 | ||
|
|
23b74f91bc | ||
|
|
b36452d110 | ||
|
|
a8b92d21e1 | ||
|
|
e58a7c0d14 | ||
|
|
1663c9424e | ||
|
|
fba9d8cf26 | ||
|
|
f754f8bc0f | ||
|
|
2155c4c773 | ||
|
|
18e2fca34d | ||
|
|
88feb0adca | ||
|
|
d2e9cdaf06 | ||
|
|
8277aaee51 | ||
|
|
65550621e0 | ||
|
|
1a67427bf3 | ||
|
|
58f1477680 | ||
|
|
76c459e39a | ||
|
|
b864e0bda6 | ||
|
|
6c235ef093 | ||
|
|
2483412475 | ||
|
|
f9ee983790 | ||
|
|
4746681efe | ||
|
|
a218a92d4e | ||
|
|
6845adf083 | ||
|
|
76098cf060 | ||
|
|
850c728c9d | ||
|
|
ce9801b449 | ||
|
|
576d9bf408 | ||
|
|
34f406e96d | ||
|
|
a653bd4065 | ||
|
|
07b426ba7f | ||
|
|
4b83b79b2b | ||
|
|
675cc30e5a | ||
|
|
3511153f9f | ||
|
|
23facf75d5 | ||
|
|
71b8f9be24 | ||
|
|
d257188d09 | ||
|
|
bde0220373 | ||
|
|
a88d227cc0 | ||
|
|
0d0cefb25e | ||
|
|
be8cf028ff | ||
|
|
d0a4fd60f2 | ||
|
|
aae877b880 | ||
|
|
ef5ff06590 | ||
|
|
7eb38aa6ad | ||
|
|
2624228f6f |
No files matched your search
+182
-7
@@ -4,16 +4,149 @@ Hy Memory and MemHub are versioned and released independently.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
## MemHub 1.0.6 — 2026-11-02
|
## Hy Memory 2.0.14 — 2026-11-04
|
||||||
|
|
||||||
Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64
|
- `search()` / `async_search()` take an optional `statuses` for explicit
|
||||||
|
L1_RAW search (`layers=["l1_raw"]`, or any search in lite mode), e.g.
|
||||||
|
`statuses=["active", "shadow"]`, so raw turns a pro extraction already
|
||||||
|
shadowed can still be searched. Default stays active-only. Raw hits now
|
||||||
|
report their real `status`. Passing `statuses` to a non-raw search or to
|
||||||
|
`scene="all"` raises `ValueError` instead of being ignored.
|
||||||
|
|
||||||
|
## Hy Memory 2.0.13 — 2026-11-02
|
||||||
|
|
||||||
|
- A model failure during a write now reports failure and stores
|
||||||
|
nothing. **Behaviour change for direct SDK users:** writes that used to
|
||||||
|
return `success: true` in a degraded state now return `success: false`;
|
||||||
|
retry them with a new `request_id` once the model is back.
|
||||||
|
- Reconcile (pro / ultra `add` and `add_extracted`): when the reconcile model
|
||||||
|
call fails, returns HTTP 200 with empty content, or returns a reply that
|
||||||
|
cannot be parsed, or reconcile raises before anything is stored, the write
|
||||||
|
used to fall back to adding every extracted memory as-is (no dedup or
|
||||||
|
evolution) and report success. That fallback is removed, with its
|
||||||
|
`degraded` / `degraded_reason` result fields. The write now returns
|
||||||
|
`success: false`, `error_code: 502`,
|
||||||
|
`agent_error_code: "RECONCILE_FAILED"`, and the cause in the new
|
||||||
|
`reconcile_error_code` (`LLM_UNAVAILABLE`, `EMPTY_RESPONSE`,
|
||||||
|
`JSON_PARSE_FAILED`, `RECONCILE_EXCEPTION`). None of the new memories are
|
||||||
|
stored, the episode counter does not advance, and `add` rolls back its
|
||||||
|
raw turn the same way as an extraction failure
|
||||||
|
(`l1_rollback.retry_safe: true`). An empty 200 reply was previously
|
||||||
|
counted as "no changes" and reported success with 0 memories; only a
|
||||||
|
literal `[]` reply still means that. `RECONCILE_ENABLED=false` is a
|
||||||
|
configuration choice and still writes directly.
|
||||||
|
- Working / coding path: an extraction model failure used to return
|
||||||
|
success with 0 operations. It now returns `success: false` with
|
||||||
|
`agent_error_code` `LLM_ERROR`, `EMPTY_RESPONSE` or `JSON_PARSE_FAILED`.
|
||||||
|
A model that answers with nothing worth remembering is still a success.
|
||||||
|
An embedding failure used to store the experience or fact without a
|
||||||
|
vector, where search never finds it, and report success; it now returns
|
||||||
|
`EMBEDDING_FAILED` and the node is not stored. A fact embedding is checked
|
||||||
|
before any older fact is superseded. A failed write no longer appends its
|
||||||
|
turns to the recent trace and records no `chain_rounds`.
|
||||||
|
With `MEMORY_CODING_VERBATIM_FALLBACK_ENABLED` turned on, extraction
|
||||||
|
failures keep the verbatim fallback.
|
||||||
|
- Working / coding reconcile: when the experience reconcile or the fact
|
||||||
|
reconcile model call fails, returns empty content, or returns a reply that
|
||||||
|
is not a JSON object with an `ops` list, the write returns
|
||||||
|
`success: false`, `agent_error_code: "RECONCILE_FAILED"` and the cause in
|
||||||
|
`reconcile_error_code` (`LLM_UNAVAILABLE`, `EMPTY_RESPONSE`,
|
||||||
|
`JSON_PARSE_FAILED`), the same as chat. Before, experience reconcile fell
|
||||||
|
back to adding every draft, and fact reconcile inserted the new fact
|
||||||
|
without superseding older ones. Both model decisions now run before
|
||||||
|
anything is stored, so a failure in either leaves no experience or fact
|
||||||
|
from the write and supersedes no older fact. An experience reply of
|
||||||
|
`{"ops": []}` is a valid "no changes" decision: the write succeeds and no
|
||||||
|
experience is stored. A non-empty `ops` list with no recognizable op is a
|
||||||
|
parse failure. A fact reply of `{"ops": []}` inserts the new fact without
|
||||||
|
superseding anything. Fact reconcile still makes no model call when there
|
||||||
|
are no candidates.
|
||||||
|
|
||||||
|
## Hy Memory 2.0.12 — 2026-11-02
|
||||||
|
|
||||||
|
- Intention dedup is now configurable per client
|
||||||
|
(`MemoryConfig.recall.intention_dedup_enabled`, default `true`;
|
||||||
|
`recall.intention_dedup_threshold`, default `MEMORY_DEDUP_THRESHOLD` or
|
||||||
|
`0.95`). Env defaults: `MEMORY_INTENTION_DEDUP_ENABLED`,
|
||||||
|
`MEMORY_INTENTION_DEDUP_THRESHOLD`. When on, behaviour is unchanged
|
||||||
|
(duplicates found at read time are still deleted); when off, no intention
|
||||||
|
dedup runs, at read time or within a write batch, and nothing is hidden,
|
||||||
|
dropped or deleted. The threshold applies to read-time dedup only. Refs #241.
|
||||||
|
|
||||||
|
- MemHub pins `hy-memory==2.0.12` and exposes that intention dedup switch in
|
||||||
|
its memory-features setting (`GET`/`PUT /api/v1/settings/memory-features`,
|
||||||
|
`memhub settings features`): the feature `intention_dedup` (default on) and
|
||||||
|
the numeric `intention_dedup_threshold` (default 0.95, accepted 0.8–1.0,
|
||||||
|
anything else is 422 or a CLI usage error). Unset, both keep the SDK
|
||||||
|
default, so nothing changes for the App; a change rebuilds the SDK clients
|
||||||
|
like the other feature switches. The API answer gains `parameters`, and
|
||||||
|
`pipeline.features` can now carry the threshold as a number. Refs #241.
|
||||||
|
|
||||||
|
## MemHub 1.0.6 — 2026-11-04
|
||||||
|
|
||||||
|
Build 41, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
|
||||||
App and Runtime.
|
App and Runtime.
|
||||||
|
|
||||||
|
- MemHub App now exposes the complete memory-mode lifecycle. New installs
|
||||||
|
explicitly choose lite or pro during onboarding; pro completes only after a
|
||||||
|
generation model is active, while ultra remains an advanced Settings choice.
|
||||||
|
Settings shows mode effects, graph backfill, the default 03:00 evolve
|
||||||
|
schedule and manual evolve. The memory page shows original conversations in
|
||||||
|
lite and advanced memories in pro/ultra. Existing installs remain pro unless
|
||||||
|
the user changes them, and embedding maintenance blocks mode/evolve changes.
|
||||||
|
- MemHub pins `hy-memory==2.0.14`. Lite search is plain RAG over raw: the
|
||||||
|
App's lite search, CLI context search, `/api/v1/recall` and the Host Bridge
|
||||||
|
(`MemHub_recall`, context search) return every vector hit above the floor,
|
||||||
|
ranked by score, over active and shadow raws, so history pro already
|
||||||
|
processed is still found after switching to lite. The turn view no longer
|
||||||
|
drops a hit whose turns do not literally contain the query; it shows the
|
||||||
|
hit's closest turn instead. Pro and ultra raw search stays active-only.
|
||||||
|
Fixes every raw search failing in packaged builds since #316, which passed
|
||||||
|
`statuses` to an SDK (2.0.13) that did not accept it. The lite raw-turn
|
||||||
|
page also stops re-listing the whole raw layer on each poll when a live
|
||||||
|
session only adds new raws.
|
||||||
|
- MemHub Core no longer stalls writes or freezes the App for tens of seconds
|
||||||
|
on a machine with a long live-sync history. Finished live-sync import jobs
|
||||||
|
are pruned to the newest 20 per source (at startup, in bounded batches, and
|
||||||
|
every 10 minutes); history imports and every unfinished job are kept. Job
|
||||||
|
lookups are filtered in SQL with new indexes (schema migration 63) instead
|
||||||
|
of reading the whole table. The durable write queue runs its claims,
|
||||||
|
settles and lease renewals on its own threads, so import work can no longer
|
||||||
|
starve a lease. Import abandon, resume and enqueue, and the file watcher's
|
||||||
|
path attribution, no longer touch the database or the disk on the event
|
||||||
|
loop, and the WAL cadence checkpoint runs on its own thread instead of on
|
||||||
|
the writer's. The write drain now restarts itself after an unexpected exit,
|
||||||
|
reports the failure with its stack, and shows `write_drain` /
|
||||||
|
`write_drain_error` in memory-processing and `memhub status`.
|
||||||
|
- Model Center (#293, #295): Settings splits into 界面语言 / 记忆模式 /
|
||||||
|
模型配置 pages, with separate 记忆整理 (generation) and 记忆搜索
|
||||||
|
(embedding) lanes. Changing one lane sends only that lane's patch. An
|
||||||
|
embedding change shows the switch plan, confirmation, progress, cancel and
|
||||||
|
rollback, and the search model only changes after the rebuilt index is
|
||||||
|
verified. Model panels open inside the lane that launched them and close
|
||||||
|
only after Core confirms the profile is active (#310).
|
||||||
|
- Lite keeps raw conversations (#297): lite raw writes are no longer stopped
|
||||||
|
by the "pause model sedimentation" switch, the memory page lists every
|
||||||
|
imported raw conversation by role (by turn since #316), and lite adds a
|
||||||
|
manual raw input (source `one-memory-manual`) that never calls a
|
||||||
|
generation model.
|
||||||
|
- The home page follows the memory mode (#298): lite counts raw
|
||||||
|
conversations and previews the latest one; pro and ultra read memories.
|
||||||
|
Pro and ultra now require an active generation model, in both the mode
|
||||||
|
preview and the switch.
|
||||||
|
- Onboarding (#304) asks for lite or pro and resumes after a restart (schema
|
||||||
|
migration 62); pro finishes only once a generation model is active.
|
||||||
|
- Smaller App fixes: compact home status copy (#307), clearer mode-switch
|
||||||
|
effects (#312), Ultra topic nodes are hover-only (#315), and settings
|
||||||
|
controls use the content width (#317).
|
||||||
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
|
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
|
||||||
Japanese, Spanish, German, Portuguese, French, Korean, Russian and
|
Japanese, Spanish, German, Portuguese, French, Korean, Russian and
|
||||||
Indonesian. Choosing any language other than Simplified Chinese also sets
|
Indonesian. Every interface language, Simplified Chinese included, sets the
|
||||||
the SDK prompt language; Simplified Chinese keeps the automatic zh/en
|
SDK prompt language (#270). **User-visible:** zh-Hans users now get Chinese
|
||||||
detection.
|
prompts and Chinese memory text for every conversation, English ones
|
||||||
|
included; the App sends the language it displays, also when it follows the
|
||||||
|
system. Only an unset language (CLI/API installs, `memhub settings language
|
||||||
|
system`) keeps the SDK's zh/en detection.
|
||||||
- Memory modes: lite (no LLM, keeps raw conversations), pro (LLM extraction)
|
- Memory modes: lite (no LLM, keeps raw conversations), pro (LLM extraction)
|
||||||
and ultra (pro plus the knowledge graph and a nightly evolve run). Set with
|
and ultra (pro plus the knowledge graph and a nightly evolve run). Set with
|
||||||
`GET`/`PUT /api/v1/settings/memory-mode` or `memhub settings mode`. Raw
|
`GET`/`PUT /api/v1/settings/memory-mode` or `memhub settings mode`. Raw
|
||||||
@@ -26,10 +159,52 @@ App and Runtime.
|
|||||||
embedding service is unavailable, search returns `embedding_unavailable`.
|
embedding service is unavailable, search returns `embedding_unavailable`.
|
||||||
- Multi-user Runtime: memory APIs and the CLI take `user_id` (default
|
- Multi-user Runtime: memory APIs and the CLI take `user_id` (default
|
||||||
`local`); records are isolated per user; Hermes profiles can be connected as
|
`local`); records are isolated per user; Hermes profiles can be connected as
|
||||||
separate users. The App keeps using `local`.
|
separate users. The App keeps using `local`. Hardening (#280): read
|
||||||
|
responses echo `user_id`; deleting another user's memory, or one that does
|
||||||
|
not exist, is 404 `memory_not_found` (the App and `memhub memories delete`
|
||||||
|
treat it as already gone); Hermes never reads a `profiles/` directory, and
|
||||||
|
a root that is the `profiles` container or overlaps another user's grant is
|
||||||
|
refused (`hermes_root_is_profiles_container`,
|
||||||
|
`hermes_root_overlaps_other_user`).
|
||||||
- Configurable SDK features through the CLI and API (`memhub settings
|
- Configurable SDK features through the CLI and API (`memhub settings
|
||||||
features`): graph rerank, episode recall, session episodes, episode
|
features`): graph rerank, episode recall, session episodes, episode
|
||||||
summaries, intention and few-shot. Defaults are unchanged.
|
summaries, intention and few-shot, and the intention dedup switch with its
|
||||||
|
threshold (`intention_dedup`, `intention_dedup_threshold`, #277). Defaults
|
||||||
|
are unchanged.
|
||||||
|
- Memory writes retry themselves (#278), each time under a new SDK request
|
||||||
|
id after checking whether the previous one landed; interrupted writes
|
||||||
|
re-run automatically. A model provider failure (401/403, 404, 408, 429,
|
||||||
|
5xx, timeout, unreachable) keeps the job pending in place without spending
|
||||||
|
a retry, and a per-profile circuit breaker paces the queue (5 s doubling to
|
||||||
|
180 s, one probe job at a time). Any other failure is retried after 30, 60
|
||||||
|
and 120 s, then `failed`. With `hy-memory==2.0.13` a model failure is no
|
||||||
|
longer a silent success, and a failed job's `error_code` names the SDK's
|
||||||
|
cause (`hy_sdk_reconcile_empty_response`, `hy_sdk_llm_error`, ...) rather
|
||||||
|
than `hy_sdk_write_failed`. **User-visible:** the manual 重新整理 button,
|
||||||
|
the "整理结果不确定" display and
|
||||||
|
`POST /api/v1/memory-processing/retry-unknown` are gone; a banner
|
||||||
|
("模型配置有问题……记忆暂停处理,已有 N 条在排队") with a button to the model
|
||||||
|
settings shows while a model problem pauses processing, and clears on
|
||||||
|
recovery.
|
||||||
|
- Queue, WAL and recall (#278): the WAL stays bounded (64 MiB journal limit,
|
||||||
|
idle `TRUNCATE` checkpoints); a disabled or revoked source's jobs are
|
||||||
|
`suspended` and resume when it is turned back on; SDK writes and recall
|
||||||
|
reads use separate thread pools, and recall has a total budget (504
|
||||||
|
`recall_timeout`, `broker_timeout` in clients) instead of starving behind
|
||||||
|
stuck writes.
|
||||||
|
- One Runtime per data dir (#273). App quit asks the service manager at quit
|
||||||
|
time: an enabled background service is never stopped, a disabled one has
|
||||||
|
every Runtime of the data dir stopped (only after an authenticated identity
|
||||||
|
check), and an unknown state stops nothing. At start the App adopts a
|
||||||
|
Runtime of the same build, replaces an older one, and silently adopts a
|
||||||
|
newer one. The tray quit label follows the current state.
|
||||||
|
- DeepSeek Harness: the app page's one switch installs and grants recall for
|
||||||
|
every detected profile, and keeps new profiles granted without a restart
|
||||||
|
(#230); versioned session files (`session.v3.jsonl.zstd`,
|
||||||
|
`session.v4.jsonl.zstd`) are imported, and v4 tool output goes through the
|
||||||
|
same clipping and scrubbing (#175).
|
||||||
|
- The packaged Runtime accepts only a full 40-character commit, lowercased; a
|
||||||
|
malformed `ONE_MEMORY_COMMIT` fails the build (#235, #236).
|
||||||
- Search and recall pass through the SDK's search parameters with server-side
|
- Search and recall pass through the SDK's search parameters with server-side
|
||||||
score floors; the memory library filters by layer; `/api/v2/raw` returns
|
score floors; the memory library filters by layer; `/api/v2/raw` returns
|
||||||
data again; v2 source and import routes; profile version history.
|
data again; v2 source and import routes; profile version history.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ English | [中文](README.zh.md)
|
|||||||
## Latest News
|
## Latest News
|
||||||
|
|
||||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
|
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
|
||||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, and more reliable upgrades, on **`hy-memory==2.0.11`**. See the release notes for details.
|
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, memory writes that retry themselves and pause while the model provider is down, and more reliable upgrades, on **`hy-memory==2.0.14`**. See the release notes for details.
|
||||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details.
|
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details.
|
||||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
|
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
|
||||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
|
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@
|
|||||||
## 最新动态
|
## 最新动态
|
||||||
|
|
||||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型!
|
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型!
|
||||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,升级更可靠;底层使用 **`hy-memory==2.0.11`**。详见发布说明。
|
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,记忆写入自动重试、模型服务异常时暂停排队,升级更可靠;底层使用 **`hy-memory==2.0.14`**。详见发布说明。
|
||||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。
|
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。
|
||||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
|
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
|
||||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
|
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
|
||||||
|
|||||||
@@ -182,9 +182,29 @@ SDK 不设置 Rerank Search 并发上限。开启后模型在 `HyMemoryClient`
|
|||||||
| `MEMORY_RECALL_STRENGTH_TAU` | `180` | 衰减时间常数,天 |
|
| `MEMORY_RECALL_STRENGTH_TAU` | `180` | 衰减时间常数,天 |
|
||||||
| `MEMORY_RECALL_ACCESS_TRACKING` | `true` | Strength 开启时是否回写访问信息 |
|
| `MEMORY_RECALL_ACCESS_TRACKING` | `true` | Strength 开启时是否回写访问信息 |
|
||||||
| `MEMORY_ENTITY_STORE_ENABLED` | `false` | 是否维护独立 entity collection;当前 Reader 不直接读取 |
|
| `MEMORY_ENTITY_STORE_ENABLED` | `false` | 是否维护独立 entity collection;当前 Reader 不直接读取 |
|
||||||
|
| `MEMORY_INTENTION_DEDUP_ENABLED` | `true` | 是否对 L7 Intention 判重(读侧和写入侧批内);见下文 |
|
||||||
|
| `MEMORY_INTENTION_DEDUP_THRESHOLD` | `MEMORY_DEDUP_THRESHOLD`,再缺省为 `0.95` | Intention 读侧判重的 cosine 阈值 |
|
||||||
|
|
||||||
旧数据可调用当前公开方法 `client.build_entity_store(user_id)` 补建 Entity Store。
|
旧数据可调用当前公开方法 `client.build_entity_store(user_id)` 补建 Entity Store。
|
||||||
|
|
||||||
|
Intention 判重:开启时(默认,与以往行为一致),Search 召回的 L7 Intention 中,
|
||||||
|
同 `user_id` + 同 `agent_id` + `memory_at` 同一天 + `valid_until` 相同、且 cosine ≥ 阈值的
|
||||||
|
条目视为复本,只返回最早写入的一条,其余在后台从向量库**删除**。判重不区分内容中的项目、
|
||||||
|
对象等语义差异,内容高度相似但对象不同的意图可能被误删;写入时同一批抽取结果也会做一次
|
||||||
|
批内判重。关闭后这两处都不判重,不隐藏、不丢弃、也不删除意图。两项对应
|
||||||
|
`MemoryConfig.recall.intention_dedup_enabled` /
|
||||||
|
`recall.intention_dedup_threshold`,按 client 生效,环境变量只提供默认值:
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = HyMemoryClient.from_config({
|
||||||
|
"recall": {"intention_dedup_enabled": False},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`MEMORY_DEDUP_THRESHOLD` 同时被写入侧批内意图判重和 dedup 内核使用;
|
||||||
|
`MEMORY_INTENTION_DEDUP_THRESHOLD` 只影响读侧 Intention 判重,写入侧批内判重的阈值
|
||||||
|
仍取 `MEMORY_DEDUP_THRESHOLD`。
|
||||||
|
|
||||||
## 日志、Trace 与历史
|
## 日志、Trace 与历史
|
||||||
|
|
||||||
| 变量 | 默认值 | 说明 |
|
| 变量 | 默认值 | 说明 |
|
||||||
|
|||||||
@@ -107,5 +107,5 @@ L1_RAW 保存的内容使用上述渲染格式。历史回读时:
|
|||||||
```bash
|
```bash
|
||||||
python -m pytest -q \
|
python -m pytest -q \
|
||||||
tests/test_multi_speaker_format.py \
|
tests/test_multi_speaker_format.py \
|
||||||
tests/test_reconcile_fallback.py
|
tests/test_reconcile_failure.py
|
||||||
```
|
```
|
||||||
@@ -150,7 +150,23 @@ working/coding 写入返回双层结果:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`scene="coding"` 还会返回 `chain_rounds`。出于历史兼容,该字段当前统计本次识别并处理
|
抽取 LLM 调用失败、返回空 content 或回复解析不出 JSON,以及经验 / 事实向量化失败时,
|
||||||
|
写入返回 `success=False`、`error_code=502`,`agent_error_code` 依次为 `LLM_ERROR`、
|
||||||
|
`EMPTY_RESPONSE`、`JSON_PARSE_FAILED`、`EMBEDDING_FAILED`,`error_message` 以
|
||||||
|
`[CODE]` 开头;没有向量的经验 / 事实不会落库,失败的写入也不追加对话 trace,换一个
|
||||||
|
`request_id` 重跑即可。模型正常作答但没有值得记的内容仍是 `success=True`、0 条。
|
||||||
|
显式开启 `MEMORY_CODING_VERBATIM_FALLBACK_ENABLED` 时抽取失败沿用原文兜底。
|
||||||
|
|
||||||
|
经验层 / 事实层 reconcile 的模型决策同样不再兜底:LLM 调用失败、返回空 content 或回复
|
||||||
|
不是带 `ops` 列表的 JSON 对象时,写入返回 `success=False`、
|
||||||
|
`agent_error_code="RECONCILE_FAILED"`,原因在 `reconcile_error_code`(`LLM_UNAVAILABLE` /
|
||||||
|
`EMPTY_RESPONSE` / `JSON_PARSE_FAILED`,与 chat 链路一致)。两层的模型决策都在落库之前
|
||||||
|
完成,任何一层失败时本次一条经验 / 事实都不存,旧事实也不会被覆盖。经验层回复
|
||||||
|
`{"ops": []}` 表示「无需任何操作」,写入成功、经验层不存;`ops` 非空却没有一条可识别的
|
||||||
|
op 视为解析失败。事实层回复 `{"ops": []}` 表示都不覆盖、照常插入新事实;没有候选时不
|
||||||
|
调用 LLM。
|
||||||
|
|
||||||
|
`scene="coding"` 还会返回 `chain_rounds`(写入失败时不记录演进链,也不返回该字段)。出于历史兼容,该字段当前统计本次识别并处理
|
||||||
的 diff hunk 数,而不保证等于最终合并后的唯一演进 round 数。`0` 表示没有识别到可记录
|
的 diff hunk 数,而不保证等于最终合并后的唯一演进 round 数。`0` 表示没有识别到可记录
|
||||||
的代码修改,不代表双层 memory 写入失败。
|
的代码修改,不代表双层 memory 写入失败。
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,14 @@ result = client.add(
|
|||||||
双层 reconcile 操作、`experience_ids`、`fact_ids` 等字段,完整结构见
|
双层 reconcile 操作、`experience_ids`、`fact_ids` 等字段,完整结构见
|
||||||
[scene_coding_usage.md](scene_coding_usage.md#4-返回结构)。
|
[scene_coding_usage.md](scene_coding_usage.md#4-返回结构)。
|
||||||
|
|
||||||
|
写入失败时 `success=False`,`error_code` 为 HTTP 风格的整数,`agent_error_code` 给出稳定
|
||||||
|
失败码。pro / ultra 的 reconcile(新事实与已有记忆判重、合并)模型调用失败、返回空
|
||||||
|
content 或回复解析不出时,`add` 与 `add_extracted` 都报 `agent_error_code="RECONCILE_FAILED"`,
|
||||||
|
具体原因在 `reconcile_error_code`(`LLM_UNAVAILABLE` / `EMPTY_RESPONSE` /
|
||||||
|
`JSON_PARSE_FAILED` / `RECONCILE_EXCEPTION`)。此时本次一条新记忆都不落库,`add` 已写入
|
||||||
|
的原始对话按抽取失败同样回滚(`l1_rollback.retry_safe=True`);模型恢复后换一个
|
||||||
|
`request_id` 重跑即可。`RECONCILE_ENABLED=false` 时不调用 reconcile,直接写入。
|
||||||
|
|
||||||
`mode="lite"` 不执行 LLM 抽取,写入内容直接以 `L1_RAW` 形式保存。lite 的 Search 是
|
`mode="lite"` 不执行 LLM 抽取,写入内容直接以 `L1_RAW` 形式保存。lite 的 Search 是
|
||||||
严格的 raw-only 向量检索:无论调用方传入什么 `layers`、`scene` 或 `search_mode`,都只
|
严格的 raw-only 向量检索:无论调用方传入什么 `layers`、`scene` 或 `search_mode`,都只
|
||||||
召回 `L1_RAW`,不会进入 working/coding、Schema 或 Intention 链路,也不会调用场景判断
|
召回 `L1_RAW`,不会进入 working/coding、Schema 或 Intention 链路,也不会调用场景判断
|
||||||
|
|||||||
@@ -136,10 +136,10 @@ class ReconcileResult:
|
|||||||
ops: List[ReconcileOp] = field(default_factory=list)
|
ops: List[ReconcileOp] = field(default_factory=list)
|
||||||
success: bool = True
|
success: bool = True
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
# True = reconcile 失败后走了「全部 ADD」兜底(见 _fallback_add_ops)。
|
# success=False 时的稳定失败码:LLM_UNAVAILABLE / EMPTY_RESPONSE /
|
||||||
# 结果仍然可用(success=True),但没有做过去重/演化判定,可能引入重复。
|
# JSON_PARSE_FAILED / RECONCILE_EXCEPTION。失败时 ops 恒为空:writer 什么都
|
||||||
degraded: bool = False
|
# 不落库,整次写入报失败,调用方换个 request_id 重跑即可(reconcile 会判重)。
|
||||||
degraded_reason: Optional[str] = None
|
error_code: Optional[str] = None
|
||||||
# reconcile 链路 LLM token 消耗(search-query + reconcile 两次 LLM 调用之和)
|
# reconcile 链路 LLM token 消耗(search-query + reconcile 两次 LLM 调用之和)
|
||||||
prompt_tokens: int = 0
|
prompt_tokens: int = 0
|
||||||
completion_tokens: int = 0
|
completion_tokens: int = 0
|
||||||
@@ -151,7 +151,7 @@ class ReconcileResult:
|
|||||||
# 用 content 作 key 而不是 node_id:新记忆此刻还没入库、没有 id。
|
# 用 content 作 key 而不是 node_id:新记忆此刻还没入库、没有 id。
|
||||||
candidate_scores: Dict[str, List[Tuple[str, float]]] = field(default_factory=dict)
|
candidate_scores: Dict[str, List[Tuple[str, float]]] = field(default_factory=dict)
|
||||||
# reconcile 候选检索已经为每条新 memory 算过 embedding。把原始文本对应向量交回
|
# reconcile 候选检索已经为每条新 memory 算过 embedding。把原始文本对应向量交回
|
||||||
# writer,ADD-all / degraded ADD 可直接落库,避免同一事实再次请求 embedding。
|
# writer,原文不变的 ADD 可直接落库,避免同一事实再次请求 embedding。
|
||||||
# 仅按完全相同 content 复用;LLM 改写后的 UPDATE/SUPERSEDE 会重新计算,防止错配。
|
# 仅按完全相同 content 复用;LLM 改写后的 UPDATE/SUPERSEDE 会重新计算,防止错配。
|
||||||
new_memory_embeddings: Dict[str, List[float]] = field(default_factory=dict)
|
new_memory_embeddings: Dict[str, List[float]] = field(default_factory=dict)
|
||||||
|
|
||||||
@@ -394,49 +394,6 @@ class MemoryReconciler:
|
|||||||
|
|
||||||
return [], None
|
return [], None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _fallback_add_ops(
|
|
||||||
new_memories: List[str],
|
|
||||||
new_memories_with_meta: Optional[List[Dict]] = None,
|
|
||||||
) -> List[ReconcileOp]:
|
|
||||||
"""reconcile 彻底失败时的兜底:把抽取结果**原样全部 ADD**。
|
|
||||||
|
|
||||||
为什么必须有这个兜底:reconcile 只是决定「新事实和旧记忆怎么合」。它挂了(网关 500、
|
|
||||||
JSON 解析不出)不代表抽取结果没价值 —— 此前的行为是把一整批完好的事实**全部丢弃**,
|
|
||||||
线上实测因此丢掉过整块内容(含评测题的正确答案)。宁可留重复也不要丢内容:
|
|
||||||
重复还能被后续 dedup / reconcile 收拾,丢了就永远找不回来。
|
|
||||||
"""
|
|
||||||
metas = new_memories_with_meta or []
|
|
||||||
by_content = {
|
|
||||||
(m.get("content") or ""): m for m in metas if isinstance(m, dict)
|
|
||||||
}
|
|
||||||
ops: List[ReconcileOp] = []
|
|
||||||
for text in new_memories:
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
meta = by_content.get(text) or {}
|
|
||||||
ops.append(ReconcileOp(
|
|
||||||
op="ADD",
|
|
||||||
content=text,
|
|
||||||
layer=meta.get("layer"),
|
|
||||||
tags=meta.get("tags") or [],
|
|
||||||
owner=meta.get("owner"),
|
|
||||||
topic_paths=meta.get("topic_paths") or [],
|
|
||||||
event_facet=meta.get("event_facet") or {},
|
|
||||||
topic_assignments=meta.get("topic_assignments") or [],
|
|
||||||
topic_paths_provided=bool(
|
|
||||||
meta.get("topic_paths_provided", "topic_paths" in meta)
|
|
||||||
),
|
|
||||||
event_facet_provided=bool(
|
|
||||||
meta.get("event_facet_provided", "event_facet" in meta)
|
|
||||||
),
|
|
||||||
topic_assignments_provided=bool(meta.get(
|
|
||||||
"topic_assignments_provided", "topic_assignments" in meta,
|
|
||||||
)),
|
|
||||||
reason="reconcile unavailable → stored as-is (degraded)",
|
|
||||||
))
|
|
||||||
return ops
|
|
||||||
|
|
||||||
async def reconcile(
|
async def reconcile(
|
||||||
self,
|
self,
|
||||||
new_memories: List[str],
|
new_memories: List[str],
|
||||||
@@ -1088,8 +1045,8 @@ class MemoryReconciler:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# LLM 彻底不可用(provider 已按 max_retries 重试过,网关 500 /
|
# LLM 彻底不可用(provider 已按 max_retries 重试过,网关 500 /
|
||||||
# 连接错误等)。**不能让它冒泡** —— 冒泡会让整批抽取结果连带丢弃。
|
# 连接错误等)。不冒泡:下面统一记 RECONCILE 失败日志,再返回
|
||||||
# 交给下面的 ADD-all 兜底。
|
# success=False —— writer 不落任何新记忆,整次写入报失败。
|
||||||
_llm_error = f"{type(e).__name__}: {e}"
|
_llm_error = f"{type(e).__name__}: {e}"
|
||||||
logger.warning(f"[reconciler] RECONCILE LLM unavailable: {_llm_error}")
|
logger.warning(f"[reconciler] RECONCILE LLM unavailable: {_llm_error}")
|
||||||
break
|
break
|
||||||
@@ -1097,7 +1054,7 @@ class MemoryReconciler:
|
|||||||
_tok_pt += response.prompt_tokens
|
_tok_pt += response.prompt_tokens
|
||||||
_tok_ct += response.completion_tokens
|
_tok_ct += response.completion_tokens
|
||||||
_tok_total += response.tokens_used
|
_tok_total += response.tokens_used
|
||||||
raw_response = response.content
|
raw_response = response.content or ""
|
||||||
ops = self._parse_ops(raw_response)
|
ops = self._parse_ops(raw_response)
|
||||||
ops = enforce_time_order(ops, current_time, candidate_map)
|
ops = enforce_time_order(ops, current_time, candidate_map)
|
||||||
ops = self._inherit_exact_input_structure(
|
ops = self._inherit_exact_input_structure(
|
||||||
@@ -1106,7 +1063,9 @@ class MemoryReconciler:
|
|||||||
if _structure_metadata_on:
|
if _structure_metadata_on:
|
||||||
ops = self._resolve_structure_references(ops, candidate_map)
|
ops = self._resolve_structure_references(ops, candidate_map)
|
||||||
|
|
||||||
_is_empty_array = self._strip_code_fence(raw_response) in ("[]", "")
|
# 只有字面量 "[]" 才是「模型判定无需任何操作」。HTTP 200 但 content
|
||||||
|
# 为空不是决策,是失败:按 EMPTY_RESPONSE 处理,不能当成 0 条成功。
|
||||||
|
_is_empty_array = self._strip_code_fence(raw_response) == "[]"
|
||||||
if ops or _is_empty_array:
|
if ops or _is_empty_array:
|
||||||
_parse_ok = True
|
_parse_ok = True
|
||||||
break
|
break
|
||||||
@@ -1136,7 +1095,7 @@ class MemoryReconciler:
|
|||||||
error_msg = (
|
error_msg = (
|
||||||
f"RECONCILE LLM returned empty response after {_n_attempts} attempts"
|
f"RECONCILE LLM returned empty response after {_n_attempts} attempts"
|
||||||
)
|
)
|
||||||
error_code = "EMPTY_RESPONSE"
|
error_code = "EMPTY_RESPONSE"
|
||||||
logger.warning(f"[reconciler] {error_msg}")
|
logger.warning(f"[reconciler] {error_msg}")
|
||||||
|
|
||||||
if cache and request_id:
|
if cache and request_id:
|
||||||
@@ -1153,14 +1112,13 @@ class MemoryReconciler:
|
|||||||
{
|
{
|
||||||
"error": error_code,
|
"error": error_code,
|
||||||
"message": error_msg,
|
"message": error_msg,
|
||||||
"fallback": "ADD_ALL",
|
"stored": 0,
|
||||||
"fallback_count": len(new_memories or []),
|
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
elapsed_ms=recon_elapsed,
|
elapsed_ms=recon_elapsed,
|
||||||
# LLM 彻底不可用时 response 是 None —— 这行本身会抛
|
# LLM 彻底不可用时 response 是 None —— 直接取属性会抛
|
||||||
# AttributeError,而它正好在兜底路径上,会把兜底也一起废掉
|
# AttributeError,把失败日志本身也废掉
|
||||||
prompt_tokens=getattr(response, "prompt_tokens", 0) or 0,
|
prompt_tokens=getattr(response, "prompt_tokens", 0) or 0,
|
||||||
completion_tokens=getattr(response, "completion_tokens", 0) or 0,
|
completion_tokens=getattr(response, "completion_tokens", 0) or 0,
|
||||||
total_tokens=getattr(response, "tokens_used", 0) or 0,
|
total_tokens=getattr(response, "tokens_used", 0) or 0,
|
||||||
@@ -1170,22 +1128,16 @@ class MemoryReconciler:
|
|||||||
f"[reconciler] store failed RECONCILE log failed: {e}"
|
f"[reconciler] store failed RECONCILE log failed: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 兜底:JSON 解析不出来不等于抽取结果没价值 —— 全部 ADD,别丢内容。
|
# 不做 ADD-all 兜底:没经过判重 / 演化的整批 ADD 会在重跑时产生重复,
|
||||||
_fb = self._fallback_add_ops(new_memories, new_memories_with_meta)
|
# 而且调用方看到 success 就不会重试。这里报失败、不落库,由调用方重跑。
|
||||||
logger.warning(
|
|
||||||
f"[reconciler] falling back to ADD-all: {len(_fb)} memories "
|
|
||||||
f"({error_code})"
|
|
||||||
)
|
|
||||||
return ReconcileResult(
|
return ReconcileResult(
|
||||||
ops=_fb,
|
ops=[],
|
||||||
success=bool(_fb),
|
success=False,
|
||||||
error=None if _fb else error_msg,
|
error=error_msg,
|
||||||
degraded=bool(_fb),
|
error_code=error_code,
|
||||||
degraded_reason=error_code,
|
|
||||||
prompt_tokens=_tok_pt,
|
prompt_tokens=_tok_pt,
|
||||||
completion_tokens=_tok_ct,
|
completion_tokens=_tok_ct,
|
||||||
total_tokens=_tok_total,
|
total_tokens=_tok_total,
|
||||||
new_memory_embeddings=new_memory_embeddings,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 写 RECONCILE pipeline log
|
# 写 RECONCILE pipeline log
|
||||||
@@ -1250,20 +1202,14 @@ class MemoryReconciler:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[reconciler] failed: {e}", exc_info=True)
|
logger.error(f"[reconciler] failed: {e}", exc_info=True)
|
||||||
# 兜底同样适用于「reconcile 链路任意环节炸了」(候选检索、embed、
|
# reconcile 链路任意环节炸了(候选检索、embed、prompt 组装…):此时还
|
||||||
# prompt 组装…)。此前这里返回 ops=[] → writer 直接丢弃整批抽取结果。
|
# 没有任何新记忆落库,报失败让整次写入可以干净地重跑。
|
||||||
_fb = self._fallback_add_ops(new_memories, new_memories_with_meta)
|
return ReconcileResult(
|
||||||
if _fb:
|
ops=[],
|
||||||
logger.warning(
|
success=False,
|
||||||
f"[reconciler] falling back to ADD-all after unexpected error: "
|
error=f"{type(e).__name__}: {e}",
|
||||||
f"{len(_fb)} memories"
|
error_code="RECONCILE_EXCEPTION",
|
||||||
)
|
)
|
||||||
return ReconcileResult(
|
|
||||||
ops=_fb, success=True,
|
|
||||||
degraded=True, degraded_reason=f"EXCEPTION:{type(e).__name__}",
|
|
||||||
new_memory_embeddings=dict(new_memory_embeddings or {}),
|
|
||||||
)
|
|
||||||
return ReconcileResult(ops=[], success=False, error=str(e))
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _inherit_exact_input_structure(
|
def _inherit_exact_input_structure(
|
||||||
|
|||||||
+28
-6
@@ -134,6 +134,7 @@ _WRITE_DIAGNOSTIC_KEYS = (
|
|||||||
"agent_status",
|
"agent_status",
|
||||||
"agent_error_code",
|
"agent_error_code",
|
||||||
"agent_error",
|
"agent_error",
|
||||||
|
"reconcile_error_code",
|
||||||
"l1_rollback",
|
"l1_rollback",
|
||||||
"l1_rollback_failed",
|
"l1_rollback_failed",
|
||||||
"partial_agent_persist",
|
"partial_agent_persist",
|
||||||
@@ -1863,6 +1864,7 @@ class HyMemoryClient:
|
|||||||
branch: Optional[str] = None,
|
branch: Optional[str] = None,
|
||||||
request_id: Optional[str] = None,
|
request_id: Optional[str] = None,
|
||||||
layers: Optional[List[Any]] = None,
|
layers: Optional[List[Any]] = None,
|
||||||
|
statuses: Optional[List[Any]] = None,
|
||||||
embed_override: Optional[Dict[str, Any]] = None,
|
embed_override: Optional[Dict[str, Any]] = None,
|
||||||
query_embedding: Optional[List[float]] = None,
|
query_embedding: Optional[List[float]] = None,
|
||||||
rerank: bool = True,
|
rerank: bool = True,
|
||||||
@@ -1955,6 +1957,8 @@ class HyMemoryClient:
|
|||||||
layers: 可选。pro/ultra 下只召回指定层级(字符串或
|
layers: 可选。pro/ultra 下只召回指定层级(字符串或
|
||||||
MemoryLayer),在召回阶段生效。lite 下忽略该参数并
|
MemoryLayer),在召回阶段生效。lite 下忽略该参数并
|
||||||
强制只召回 L1_RAW。
|
强制只召回 L1_RAW。
|
||||||
|
statuses: 仅用于 L1_RAW 定向搜索,可显式包含 active / shadow。
|
||||||
|
非 raw 搜索或 scene="all" 传入时拒绝,避免静默忽略。
|
||||||
rerank: 默认 True(原行为)。False 时本次调用跳过全部重排阶段:
|
rerank: 默认 True(原行为)。False 时本次调用跳过全部重排阶段:
|
||||||
chat 路的本地 cross-encoder 重排(recall.local_rerank_enabled)
|
chat 路的本地 cross-encoder 重排(recall.local_rerank_enabled)
|
||||||
与记忆关系图重排(graph_rerank / episode 扩展),working/
|
与记忆关系图重排(graph_rerank / episode 扩展),working/
|
||||||
@@ -1998,7 +2002,7 @@ class HyMemoryClient:
|
|||||||
intention_limit=intention_limit, created_after=created_after,
|
intention_limit=intention_limit, created_after=created_after,
|
||||||
reader=reader,
|
reader=reader,
|
||||||
workspace_id=workspace_id, branch=branch,
|
workspace_id=workspace_id, branch=branch,
|
||||||
request_id=request_id, layers=layers,
|
request_id=request_id, layers=layers, statuses=statuses,
|
||||||
embed_override=embed_override,
|
embed_override=embed_override,
|
||||||
query_embedding=query_embedding,
|
query_embedding=query_embedding,
|
||||||
rerank=rerank,
|
rerank=rerank,
|
||||||
@@ -3032,7 +3036,9 @@ class HyMemoryClient:
|
|||||||
# 版本演进链层(scene="coding" 独有)。放在 writer.write 之后、返回
|
# 版本演进链层(scene="coding" 独有)。放在 writer.write 之后、返回
|
||||||
# 之前,因为它只依赖对话原文,与向量层写入结果无关。
|
# 之前,因为它只依赖对话原文,与向量层写入结果无关。
|
||||||
# best-effort:这层挂不上不影响本次 add 的向量层产出。
|
# best-effort:这层挂不上不影响本次 add 的向量层产出。
|
||||||
if scene_variant == "coding":
|
# 向量层写入失败(success=False)时跳过:调用方会重跑整段,失败的
|
||||||
|
# 那次不该先把改动记进演进链。
|
||||||
|
if scene_variant == "coding" and resp.get("success"):
|
||||||
resp["chain_rounds"] = self._observe_chain(
|
resp["chain_rounds"] = self._observe_chain(
|
||||||
chat_messages, user_id=uid,
|
chat_messages, user_id=uid,
|
||||||
workspace_id=workspace_id, branch=branch,
|
workspace_id=workspace_id, branch=branch,
|
||||||
@@ -3384,12 +3390,14 @@ class HyMemoryClient:
|
|||||||
request_id: str,
|
request_id: str,
|
||||||
t0: float,
|
t0: float,
|
||||||
query_embedding: Optional[List[float]],
|
query_embedding: Optional[List[float]],
|
||||||
|
statuses: Optional[List[Any]],
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Implement `search(..., layers=['l1_raw'])` without a second API."""
|
"""Implement `search(..., layers=['l1_raw'])` without a second API."""
|
||||||
from .models.memory import MemoryLayer, MemoryStatus
|
from .models.memory import MemoryLayer, MemoryStatus
|
||||||
|
|
||||||
embedding = query_embedding or await self._embed_service.embed(query)
|
embedding = query_embedding or await self._embed_service.embed(query)
|
||||||
fetch = min(max(limit * (10 if session_ids else 2), limit), 10_000)
|
fetch = min(max(limit * (10 if session_ids else 2), limit), 10_000)
|
||||||
|
status_filter = self._normalize_statuses(statuses) or [MemoryStatus.ACTIVE]
|
||||||
hits = await self._vector_store.search(
|
hits = await self._vector_store.search(
|
||||||
query_embedding=embedding,
|
query_embedding=embedding,
|
||||||
user_ids=user_ids,
|
user_ids=user_ids,
|
||||||
@@ -3399,7 +3407,7 @@ class HyMemoryClient:
|
|||||||
layers=[MemoryLayer.L1_RAW],
|
layers=[MemoryLayer.L1_RAW],
|
||||||
limit=fetch,
|
limit=fetch,
|
||||||
score_threshold=min_score,
|
score_threshold=min_score,
|
||||||
status_filter=[MemoryStatus.ACTIVE],
|
status_filter=status_filter,
|
||||||
only_latest=False,
|
only_latest=False,
|
||||||
created_after=created_after,
|
created_after=created_after,
|
||||||
)
|
)
|
||||||
@@ -3417,7 +3425,7 @@ class HyMemoryClient:
|
|||||||
"memory_id": row.pop("raw_id"),
|
"memory_id": row.pop("raw_id"),
|
||||||
**row,
|
**row,
|
||||||
"layer": MemoryLayer.L1_RAW.value,
|
"layer": MemoryLayer.L1_RAW.value,
|
||||||
"status": MemoryStatus.ACTIVE.value,
|
"status": node.status.value,
|
||||||
"score": round(float(hit.get("score", 0.0)), 4),
|
"score": round(float(hit.get("score", 0.0)), 4),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -3861,6 +3869,7 @@ class HyMemoryClient:
|
|||||||
branch: Optional[str] = None,
|
branch: Optional[str] = None,
|
||||||
request_id: Optional[str] = None,
|
request_id: Optional[str] = None,
|
||||||
layers: Optional[List[Any]] = None,
|
layers: Optional[List[Any]] = None,
|
||||||
|
statuses: Optional[List[Any]] = None,
|
||||||
embed_override: Optional[Dict[str, Any]] = None,
|
embed_override: Optional[Dict[str, Any]] = None,
|
||||||
query_embedding: Optional[List[float]] = None,
|
query_embedding: Optional[List[float]] = None,
|
||||||
rerank: bool = True,
|
rerank: bool = True,
|
||||||
@@ -3890,7 +3899,7 @@ class HyMemoryClient:
|
|||||||
schema_min_score=schema_min_score, schema_limit=schema_limit,
|
schema_min_score=schema_min_score, schema_limit=schema_limit,
|
||||||
intention_limit=intention_limit, created_after=created_after,
|
intention_limit=intention_limit, created_after=created_after,
|
||||||
reader=reader, workspace_id=workspace_id, branch=branch,
|
reader=reader, workspace_id=workspace_id, branch=branch,
|
||||||
request_id=request_id, layers=layers,
|
request_id=request_id, layers=layers, statuses=statuses,
|
||||||
query_embedding=query_embedding,
|
query_embedding=query_embedding,
|
||||||
rerank=rerank,
|
rerank=rerank,
|
||||||
)
|
)
|
||||||
@@ -3905,7 +3914,7 @@ class HyMemoryClient:
|
|||||||
schema_min_score=schema_min_score, schema_limit=schema_limit,
|
schema_min_score=schema_min_score, schema_limit=schema_limit,
|
||||||
intention_limit=intention_limit, created_after=created_after,
|
intention_limit=intention_limit, created_after=created_after,
|
||||||
reader=reader, workspace_id=workspace_id, branch=branch,
|
reader=reader, workspace_id=workspace_id, branch=branch,
|
||||||
request_id=request_id, layers=layers,
|
request_id=request_id, layers=layers, statuses=statuses,
|
||||||
query_embedding=query_embedding,
|
query_embedding=query_embedding,
|
||||||
rerank=rerank,
|
rerank=rerank,
|
||||||
)
|
)
|
||||||
@@ -3935,6 +3944,7 @@ class HyMemoryClient:
|
|||||||
branch: Optional[str] = None,
|
branch: Optional[str] = None,
|
||||||
request_id: Optional[str] = None,
|
request_id: Optional[str] = None,
|
||||||
layers: Optional[List[Any]] = None,
|
layers: Optional[List[Any]] = None,
|
||||||
|
statuses: Optional[List[Any]] = None,
|
||||||
query_embedding: Optional[List[float]] = None,
|
query_embedding: Optional[List[float]] = None,
|
||||||
rerank: bool = True,
|
rerank: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -4020,6 +4030,17 @@ class HyMemoryClient:
|
|||||||
"Use single agent_id with multiple session_ids, or multiple agent_ids without session_ids."
|
"Use single agent_id with multiple session_ids, or multiple agent_ids without session_ids."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if statuses is not None:
|
||||||
|
from .models.memory import MemoryLayer
|
||||||
|
|
||||||
|
requested_layers = self._normalize_layers(layers)
|
||||||
|
if scene_all or (
|
||||||
|
not lite_mode and requested_layers != [MemoryLayer.L1_RAW]
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"statuses is supported only for explicit L1_RAW search"
|
||||||
|
)
|
||||||
|
|
||||||
if scene_all:
|
if scene_all:
|
||||||
return await self._search_all_scenes(
|
return await self._search_all_scenes(
|
||||||
query, queries=queries, search_mode=search_mode,
|
query, queries=queries, search_mode=search_mode,
|
||||||
@@ -4355,6 +4376,7 @@ class HyMemoryClient:
|
|||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
t0=t0,
|
t0=t0,
|
||||||
query_embedding=query_embedding,
|
query_embedding=query_embedding,
|
||||||
|
statuses=statuses,
|
||||||
)
|
)
|
||||||
|
|
||||||
read_req = ReadRequest(
|
read_req = ReadRequest(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ HY Memory - Coding Memory 模块
|
|||||||
|
|
||||||
from .types import (
|
from .types import (
|
||||||
CodingMemory,
|
CodingMemory,
|
||||||
|
CodingWriteError,
|
||||||
CodingMemoryDraft,
|
CodingMemoryDraft,
|
||||||
ReconcileOp,
|
ReconcileOp,
|
||||||
WorkingFact,
|
WorkingFact,
|
||||||
@@ -26,6 +27,7 @@ from .types import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CodingMemory",
|
"CodingMemory",
|
||||||
|
"CodingWriteError",
|
||||||
"CodingMemoryDraft",
|
"CodingMemoryDraft",
|
||||||
"ReconcileOp",
|
"ReconcileOp",
|
||||||
"WorkingFact",
|
"WorkingFact",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from .preproc import (
|
|||||||
prepare_extractor_messages,
|
prepare_extractor_messages,
|
||||||
)
|
)
|
||||||
from .judge import _store_coding_log
|
from .judge import _store_coding_log
|
||||||
from .types import CodingMemoryDraft, WorkingFactDraft
|
from .types import CodingMemoryDraft, CodingWriteError, WorkingFactDraft
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -217,6 +217,10 @@ class CodingMemoryExtractor:
|
|||||||
Returns:
|
Returns:
|
||||||
(经验草稿, 事实草稿)。两层都可能为空。只有显式启用
|
(经验草稿, 事实草稿)。两层都可能为空。只有显式启用
|
||||||
verbatim_fallback_enabled 时,模型没产出 fact 才调用 _verbatim_fact。
|
verbatim_fallback_enabled 时,模型没产出 fact 才调用 _verbatim_fact。
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CodingWriteError: 未开启原文兜底时,LLM 调用失败(LLM_ERROR)、返回空
|
||||||
|
content(EMPTY_RESPONSE)或回复解析不出 JSON 对象(JSON_PARSE_FAILED)。
|
||||||
"""
|
"""
|
||||||
if not messages:
|
if not messages:
|
||||||
return [], []
|
return [], []
|
||||||
@@ -268,8 +272,9 @@ class CodingMemoryExtractor:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[coding-extract] LLM call failed: {e}")
|
logger.warning(f"[coding-extract] LLM call failed: {e}")
|
||||||
# 经验层不兜底。事实层原文兜底也默认关闭,避免 assistant/tool 轨迹被
|
# 经验层不兜底。事实层原文兜底默认关闭,避免 assistant/tool 轨迹被
|
||||||
# 当作 statement 直接展示;仅在配置显式开启时保留旧行为。
|
# 当作 statement 直接展示;关闭时 LLM 失败抛 CodingWriteError(LLM_ERROR),
|
||||||
|
# 整次写入报失败,而不是「成功、0 条」。仅在配置显式开启时保留旧行为。
|
||||||
verbatim = (
|
verbatim = (
|
||||||
self._verbatim_fact(messages)
|
self._verbatim_fact(messages)
|
||||||
if self.verbatim_fallback_enabled else None
|
if self.verbatim_fallback_enabled else None
|
||||||
@@ -296,9 +301,33 @@ class CodingMemoryExtractor:
|
|||||||
),
|
),
|
||||||
elapsed_ms=round((time.perf_counter() - _t0) * 1000, 2),
|
elapsed_ms=round((time.perf_counter() - _t0) * 1000, 2),
|
||||||
)
|
)
|
||||||
|
if not self.verbatim_fallback_enabled:
|
||||||
|
raise CodingWriteError(
|
||||||
|
"LLM_ERROR",
|
||||||
|
f"coding extract LLM call failed: {type(e).__name__}: {e}",
|
||||||
|
) from e
|
||||||
return [], ([verbatim] if verbatim else [])
|
return [], ([verbatim] if verbatim else [])
|
||||||
_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
|
_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
|
||||||
|
|
||||||
|
# 模型答了且没有可记的内容(如 {"experiences": [], "fact": null})是正常的空
|
||||||
|
# 抽取;200 但 content 为空、或回复解析不出 JSON 对象,是模型失败,不能当成
|
||||||
|
# 「成功、0 条」。原文兜底显式开启时沿用旧行为(交给下面的 verbatim 兜底)。
|
||||||
|
failure = self._output_failure(resp.content)
|
||||||
|
if failure is not None and not self.verbatim_fallback_enabled:
|
||||||
|
code, message = failure
|
||||||
|
await _store_coding_log(
|
||||||
|
self.cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||||||
|
session_id=session_id or "", step="CODING_EXTRACT", prompt=prompt,
|
||||||
|
response=resp.content or "",
|
||||||
|
parsed=json.dumps(
|
||||||
|
{"drafts": 0, "error": code, "message": message},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
elapsed_ms=_elapsed, prompt_tokens=resp.prompt_tokens,
|
||||||
|
completion_tokens=resp.completion_tokens, total_tokens=resp.tokens_used,
|
||||||
|
)
|
||||||
|
raise CodingWriteError(code, message)
|
||||||
|
|
||||||
raw_experiences, raw_facts = self._parse_output(resp.content)
|
raw_experiences, raw_facts = self._parse_output(resp.content)
|
||||||
|
|
||||||
# 经验层的 files 走规则抽取(整段轨迹碰过的文件);事实层的 files 由 LLM
|
# 经验层的 files 走规则抽取(整段轨迹碰过的文件);事实层的 files 由 LLM
|
||||||
@@ -478,12 +507,22 @@ class CodingMemoryExtractor:
|
|||||||
return "\n".join(rows)
|
return "\n".join(rows)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _parse_output(
|
def _output_failure(cls, text: Optional[str]) -> Optional[Tuple[str, str]]:
|
||||||
cls, text: str
|
"""回复本身是失败时返回 (error_code, message);正常(含空抽取)返回 None。"""
|
||||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
if not (text or "").strip():
|
||||||
"""宽松解析 LLM 输出 JSON,返回 (experiences, facts) 两个原始列表。"""
|
return "EMPTY_RESPONSE", "coding extract LLM returned empty response"
|
||||||
|
if cls._load_output_object(text) is None:
|
||||||
|
return (
|
||||||
|
"JSON_PARSE_FAILED",
|
||||||
|
f"coding extract output is not a JSON object ({len(text)} chars)",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_output_object(text: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""宽松解析 LLM 输出,返回顶层 JSON 对象;解析不出返回 None。"""
|
||||||
if not text:
|
if not text:
|
||||||
return [], []
|
return None
|
||||||
s = text.strip()
|
s = text.strip()
|
||||||
# 去 markdown fence
|
# 去 markdown fence
|
||||||
if s.startswith("```"):
|
if s.startswith("```"):
|
||||||
@@ -509,8 +548,17 @@ class CodingMemoryExtractor:
|
|||||||
obj = json.loads(s[start: end + 1])
|
obj = json.loads(s[start: end + 1])
|
||||||
except Exception:
|
except Exception:
|
||||||
obj = None
|
obj = None
|
||||||
|
return obj if isinstance(obj, dict) else None
|
||||||
|
|
||||||
if not isinstance(obj, dict):
|
@classmethod
|
||||||
|
def _parse_output(
|
||||||
|
cls, text: str
|
||||||
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||||
|
"""宽松解析 LLM 输出 JSON,返回 (experiences, facts) 两个原始列表。"""
|
||||||
|
if not text:
|
||||||
|
return [], []
|
||||||
|
obj = cls._load_output_object(text)
|
||||||
|
if obj is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[coding-extract] failed to parse output ({len(text or '')} chars)"
|
f"[coding-extract] failed to parse output ({len(text or '')} chars)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ from .types import (
|
|||||||
|
|
||||||
|
|
||||||
MAX_FACT_CHAIN_ENTRIES,
|
MAX_FACT_CHAIN_ENTRIES,
|
||||||
|
CodingWriteError,
|
||||||
FactReconcileOp,
|
FactReconcileOp,
|
||||||
WorkingFact,
|
WorkingFact,
|
||||||
WorkingFactDraft,
|
WorkingFactDraft,
|
||||||
@@ -259,6 +261,16 @@ def build_chain_summary_prompt(
|
|||||||
return with_output_language(prompt, code, explicit=explicit is not None), code
|
return with_output_language(prompt, code, explicit=explicit is not None), code
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FactReconcilePlan:
|
||||||
|
"""plan() 的产物:覆盖判定已完成、尚未落库。apply() 才执行 store 操作。"""
|
||||||
|
|
||||||
|
drafts: List[WorkingFactDraft]
|
||||||
|
candidates: List[List[WorkingFact]] = field(default_factory=list)
|
||||||
|
supersedes: Dict[int, List[str]] = field(default_factory=dict)
|
||||||
|
context: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# WorkingFactReconciler
|
# WorkingFactReconciler
|
||||||
# ================================================================
|
# ================================================================
|
||||||
@@ -327,15 +339,63 @@ class WorkingFactReconciler:
|
|||||||
language: Optional[str] = None,
|
language: Optional[str] = None,
|
||||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||||
"""
|
"""
|
||||||
执行 reconcile + 持久化。memory_at 是这段对话的时间,记到新事实上。
|
执行 reconcile + 持久化(plan() 后紧接 apply())。memory_at 是这段对话的
|
||||||
language 为显式 prompt 语言码(None → zh/en 自动检测),同时用于演变总结。
|
时间,记到新事实上。language 为显式 prompt 语言码(None → zh/en 自动检测),
|
||||||
|
同时用于演变总结。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖,
|
(真正落库的新事实, 执行过的 ops)。
|
||||||
宁可多留一条过期事实,也不因为一次调用失败丢结论。
|
|
||||||
|
Raises:
|
||||||
|
CodingWriteError(RECONCILE_FAILED):覆盖判定的 LLM 失败 / 空回复 /
|
||||||
|
解析不出。此时既不插入新事实,也不覆盖任何旧事实。
|
||||||
"""
|
"""
|
||||||
|
plan = await self.plan(
|
||||||
|
drafts,
|
||||||
|
user_id=user_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
session_id=session_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
branch=branch,
|
||||||
|
source=source,
|
||||||
|
memory_at=memory_at,
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
|
return await self.apply(plan)
|
||||||
|
|
||||||
|
async def plan(
|
||||||
|
self,
|
||||||
|
drafts: List[WorkingFactDraft],
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
agent_id: str = "default_agent",
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
workspace_id: Optional[str] = None,
|
||||||
|
branch: Optional[str] = None,
|
||||||
|
source: str = "default",
|
||||||
|
memory_at: Optional[datetime] = None,
|
||||||
|
language: Optional[str] = None,
|
||||||
|
) -> FactReconcilePlan:
|
||||||
|
"""检索 candidates + 覆盖判定,不落库。
|
||||||
|
|
||||||
|
没有任何候选时不调 LLM(全部 INSERT 是确定的结论,不是兜底)。调了 LLM 时,
|
||||||
|
有效回复 = JSON 对象且 "ops" 是列表(空列表 = 都不覆盖、只插入)。LLM 调用
|
||||||
|
失败(LLM_UNAVAILABLE)、200 但 content 为空(EMPTY_RESPONSE)、回复不是
|
||||||
|
上述形状(JSON_PARSE_FAILED)时抛 CodingWriteError(RECONCILE_FAILED)。
|
||||||
|
此前 LLM 失败会退化为全 INSERT 不覆盖,留下本该被接续的旧事实。
|
||||||
|
"""
|
||||||
|
context = dict(
|
||||||
|
user_id=user_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
session_id=session_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
branch=branch,
|
||||||
|
source=source,
|
||||||
|
memory_at=memory_at,
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
if not drafts:
|
if not drafts:
|
||||||
return [], []
|
return FactReconcilePlan(drafts=[], context=context)
|
||||||
|
|
||||||
candidates = await self._retrieve_candidates(
|
candidates = await self._retrieve_candidates(
|
||||||
drafts,
|
drafts,
|
||||||
@@ -349,27 +409,37 @@ class WorkingFactReconciler:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
supersedes = await self._llm_decide(drafts, candidates, language=language)
|
supersedes = await self._llm_decide(drafts, candidates, language=language)
|
||||||
|
except CodingWriteError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(f"[fact-reconcile] LLM decide failed: {e}")
|
||||||
f"[fact-reconcile] LLM decide failed, insert without supersede: {e}"
|
raise CodingWriteError(
|
||||||
)
|
"RECONCILE_FAILED",
|
||||||
supersedes = {}
|
f"working fact reconcile LLM call failed: {type(e).__name__}: {e}",
|
||||||
|
cause="LLM_UNAVAILABLE",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
return FactReconcilePlan(
|
||||||
|
drafts=list(drafts),
|
||||||
|
candidates=candidates,
|
||||||
|
supersedes=supersedes,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def apply(
|
||||||
|
self, plan: FactReconcilePlan,
|
||||||
|
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||||
|
"""执行 plan() 的决策(覆盖旧事实 + 插入新事实)。"""
|
||||||
|
if not plan.drafts:
|
||||||
|
return [], []
|
||||||
facts, ops = await self._execute(
|
facts, ops = await self._execute(
|
||||||
drafts,
|
plan.drafts,
|
||||||
candidates,
|
plan.candidates,
|
||||||
supersedes,
|
plan.supersedes,
|
||||||
user_id=user_id,
|
**plan.context,
|
||||||
agent_id=agent_id,
|
|
||||||
session_id=session_id,
|
|
||||||
workspace_id=workspace_id,
|
|
||||||
branch=branch,
|
|
||||||
source=source,
|
|
||||||
memory_at=memory_at,
|
|
||||||
language=language,
|
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} "
|
f"[fact-reconcile] drafts={len(plan.drafts)} inserted={len(facts)} "
|
||||||
f"superseded={sum(1 for o in ops if o.action == 'SUPERSEDE')}"
|
f"superseded={sum(1 for o in ops if o.action == 'SUPERSEDE')}"
|
||||||
)
|
)
|
||||||
return facts, ops
|
return facts, ops
|
||||||
@@ -473,6 +543,11 @@ class WorkingFactReconciler:
|
|||||||
max_tokens=self.max_tokens,
|
max_tokens=self.max_tokens,
|
||||||
audit_stage="coding_fact_reconcile",
|
audit_stage="coding_fact_reconcile",
|
||||||
)
|
)
|
||||||
|
failure = self._reply_failure(resp.content)
|
||||||
|
if failure is not None:
|
||||||
|
code, message = failure
|
||||||
|
logger.warning(f"[fact-reconcile] {code}: {message}")
|
||||||
|
raise CodingWriteError("RECONCILE_FAILED", message, cause=code)
|
||||||
return self._parse_ops(resp.content)
|
return self._parse_ops(resp.content)
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
@@ -599,11 +674,24 @@ class WorkingFactReconciler:
|
|||||||
rows.append(f"for draft[{i}]:\n" + "\n".join(cand_rows))
|
rows.append(f"for draft[{i}]:\n" + "\n".join(cand_rows))
|
||||||
return "\n\n".join(rows) if rows else "(none)"
|
return "\n\n".join(rows) if rows else "(none)"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _reply_failure(cls, text: Optional[str]) -> Optional[Tuple[str, str]]:
|
||||||
|
"""回复本身是失败时返回 (cause, message);有效回复(含 {"ops": []})返回 None。"""
|
||||||
|
if not (text or "").strip():
|
||||||
|
return "EMPTY_RESPONSE", "working fact reconcile LLM returned empty response"
|
||||||
|
obj = cls._load_object(text)
|
||||||
|
if not isinstance(obj, dict) or not isinstance(obj.get("ops"), list):
|
||||||
|
return (
|
||||||
|
"JSON_PARSE_FAILED",
|
||||||
|
f"working fact reconcile output has no ops list ({len(text)} chars)",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_ops(text: str) -> Dict[int, List[str]]:
|
def _load_object(text: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
"""宽松解析 ops,返回 {draft_idx: [fact_id...]}。"""
|
"""宽松解析出顶层 JSON 对象;解析不出返回 None。"""
|
||||||
if not text:
|
if not text:
|
||||||
return {}
|
return None
|
||||||
s = text.strip()
|
s = text.strip()
|
||||||
# 去 markdown fence
|
# 去 markdown fence
|
||||||
if s.startswith("```"):
|
if s.startswith("```"):
|
||||||
@@ -621,7 +709,14 @@ class WorkingFactReconciler:
|
|||||||
obj = json.loads(s[start: end + 1])
|
obj = json.loads(s[start: end + 1])
|
||||||
except Exception:
|
except Exception:
|
||||||
obj = None
|
obj = None
|
||||||
|
return obj if isinstance(obj, dict) else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_ops(cls, text: str) -> Dict[int, List[str]]:
|
||||||
|
"""宽松解析 ops,返回 {draft_idx: [fact_id...]}。"""
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
obj = cls._load_object(text)
|
||||||
if not isinstance(obj, dict):
|
if not isinstance(obj, dict):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[fact-reconcile] failed to parse ops output ({len(text or '')} chars)"
|
f"[fact-reconcile] failed to parse ops output ({len(text or '')} chars)"
|
||||||
@@ -689,6 +784,11 @@ class WorkingFactReconciler:
|
|||||||
memory_at=memory_at,
|
memory_at=memory_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 先确认新事实能向量化,再去覆盖旧事实:否则 embedding 挂掉时旧事实
|
||||||
|
# 已被软删、新事实又落不了库,这条结论就凭空没了。失败抛
|
||||||
|
# CodingWriteError(EMBEDDING_FAILED),整次写入报失败。
|
||||||
|
new_embedding = await self.store.embed_fact(new_fact)
|
||||||
|
|
||||||
allowed = {c.fact_id for c in (candidates[idx] if idx < len(candidates) else [])}
|
allowed = {c.fact_id for c in (candidates[idx] if idx < len(candidates) else [])}
|
||||||
targets = [
|
targets = [
|
||||||
fid for fid in supersedes.get(idx, [])
|
fid for fid in supersedes.get(idx, [])
|
||||||
@@ -769,7 +869,9 @@ class WorkingFactReconciler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.store.insert_fact(new_fact)
|
await self.store.insert_fact(new_fact, embedding=new_embedding)
|
||||||
|
except CodingWriteError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[fact-reconcile] INSERT failed: {e}")
|
logger.warning(f"[fact-reconcile] INSERT failed: {e}")
|
||||||
continue
|
continue
|
||||||
|
|||||||
+130
-25
@@ -7,6 +7,9 @@ Coding Memory Reconciler
|
|||||||
(DELETE 由 LLM 在 draft 与 candidate 关系中观察到"明确否定/作废"时附带产出)
|
(DELETE 由 LLM 在 draft 与 candidate 关系中观察到"明确否定/作废"时附带产出)
|
||||||
|
|
||||||
执行端:reconcile() 调用 store 完成实际 ADD / UPDATE / DELETE 持久化操作。
|
执行端:reconcile() 调用 store 完成实际 ADD / UPDATE / DELETE 持久化操作。
|
||||||
|
CodingWriter 分两步用:先 plan()(检索 + LLM 决策,不落库),等经验层与事实层的
|
||||||
|
模型决策都成功后再 apply()(落库)。LLM 失败 / 空回复 / 解析不出时 plan() 抛
|
||||||
|
CodingWriteError(RECONCILE_FAILED),本次写入什么都不存。
|
||||||
|
|
||||||
复用 LLMConfig.model(与 chat reconciler 共用)。
|
复用 LLMConfig.model(与 chat reconciler 共用)。
|
||||||
|
|
||||||
@@ -17,6 +20,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from ..agent.llm_provider import LLMProvider
|
from ..agent.llm_provider import LLMProvider
|
||||||
@@ -29,7 +33,7 @@ from ..prompts import (
|
|||||||
)
|
)
|
||||||
from .judge import _store_coding_log
|
from .judge import _store_coding_log
|
||||||
from .store import CodingMemoryStore
|
from .store import CodingMemoryStore
|
||||||
from .types import CodingMemory, CodingMemoryDraft, ReconcileOp
|
from .types import CodingMemory, CodingMemoryDraft, CodingWriteError, ReconcileOp
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +97,15 @@ def build_experience_reconcile_prompt(
|
|||||||
return with_output_language(prompt, code, explicit=explicit is not None), code
|
return with_output_language(prompt, code, explicit=explicit is not None), code
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExperienceReconcilePlan:
|
||||||
|
"""plan() 的产物:模型已决策、尚未落库。apply() 才执行 store 操作。"""
|
||||||
|
|
||||||
|
drafts: List[CodingMemoryDraft]
|
||||||
|
candidates_per_draft: List[List[CodingMemory]] = field(default_factory=list)
|
||||||
|
ops: List[ReconcileOp] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# CodingMemoryReconciler
|
# CodingMemoryReconciler
|
||||||
# ================================================================
|
# ================================================================
|
||||||
@@ -143,21 +156,52 @@ class CodingMemoryReconciler:
|
|||||||
language: Optional[str] = None,
|
language: Optional[str] = None,
|
||||||
) -> List[ReconcileOp]:
|
) -> List[ReconcileOp]:
|
||||||
"""
|
"""
|
||||||
执行 reconcile + 持久化。返回执行的 ReconcileOp[](含成功执行的 ADD/UPDATE/DELETE/SKIP)。
|
执行 reconcile + 持久化(plan() 后紧接 apply())。返回执行的 ReconcileOp[]
|
||||||
|
(含成功执行的 ADD/UPDATE/DELETE/SKIP)。
|
||||||
|
|
||||||
执行错误(如 LLM 失败)会 fail-safe 回退到对每个 draft 直接 ADD。
|
LLM 失败、空回复或解析不出时抛 CodingWriteError(RECONCILE_FAILED),什么都
|
||||||
|
不落库——不再回退成「全部 ADD」。
|
||||||
|
"""
|
||||||
|
plan = await self.plan(
|
||||||
|
drafts,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=user_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
session_id=session_id,
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
|
return await self.apply(plan)
|
||||||
|
|
||||||
cache 非空时落 CODING_RECONCILE pipeline log——在 LLM 决策后、执行入库前打点,
|
async def plan(
|
||||||
这样即使 store 入库崩溃,reconcile 决策仍已可见(crash 不丢)。
|
self,
|
||||||
|
drafts: List[CodingMemoryDraft],
|
||||||
|
*,
|
||||||
|
request_id: str = "",
|
||||||
|
user_id: str = "",
|
||||||
|
agent_id: str = "default_agent",
|
||||||
|
session_id: str = "",
|
||||||
|
language: Optional[str] = None,
|
||||||
|
) -> ExperienceReconcilePlan:
|
||||||
|
"""检索 candidates + LLM 决策,不落库。
|
||||||
|
|
||||||
|
有效回复 = 一个 JSON 对象,其 "ops" 是列表:
|
||||||
|
- {"ops": []}:模型判定「无需任何操作」(与 chat reconcile 的 [] 同义),
|
||||||
|
成功、本层什么都不存;
|
||||||
|
- 非空列表:至少要有一条可识别的 op(ADD/UPDATE/DELETE/SKIP),否则视为
|
||||||
|
解析失败。漏掉的 draft 仍按原规则补 ADD(那是对有效回复的校验兜底,
|
||||||
|
不是模型失败兜底)。
|
||||||
|
LLM 调用失败(LLM_UNAVAILABLE)、200 但 content 为空(EMPTY_RESPONSE)、
|
||||||
|
回复不是上述形状(JSON_PARSE_FAILED)时抛 CodingWriteError(RECONCILE_FAILED)。
|
||||||
|
|
||||||
|
cache 非空时落 CODING_RECONCILE pipeline log(成功、失败都落)。
|
||||||
"""
|
"""
|
||||||
if not drafts:
|
if not drafts:
|
||||||
return []
|
return ExperienceReconcilePlan(drafts=[])
|
||||||
|
|
||||||
# 从 draft 回填 trace 身份(writer 通常显式传,这里兜底)
|
# 从 draft 回填 trace 身份(writer 通常显式传,这里兜底)
|
||||||
if drafts:
|
user_id = user_id or drafts[0].user_id
|
||||||
user_id = user_id or drafts[0].user_id
|
agent_id = agent_id or drafts[0].agent_id
|
||||||
agent_id = agent_id or drafts[0].agent_id
|
session_id = session_id or (drafts[0].session_id or "")
|
||||||
session_id = session_id or (drafts[0].session_id or "")
|
|
||||||
|
|
||||||
# 1. 检索 candidates
|
# 1. 检索 candidates
|
||||||
candidates_per_draft: List[List[CodingMemory]] = await self._retrieve_candidates(drafts)
|
candidates_per_draft: List[List[CodingMemory]] = await self._retrieve_candidates(drafts)
|
||||||
@@ -167,19 +211,20 @@ class CodingMemoryReconciler:
|
|||||||
_decide_prompt = ""
|
_decide_prompt = ""
|
||||||
_decide_raw = ""
|
_decide_raw = ""
|
||||||
_decide_resp = None
|
_decide_resp = None
|
||||||
|
failure: Optional[Tuple[str, str]] = None
|
||||||
|
ops: List[ReconcileOp] = []
|
||||||
try:
|
try:
|
||||||
ops, _decide_prompt, _decide_raw, _decide_resp = await self._llm_decide(
|
ops, _decide_prompt, _decide_raw, _decide_resp = await self._llm_decide(
|
||||||
drafts, candidates_per_draft, language=language,
|
drafts, candidates_per_draft, language=language,
|
||||||
)
|
)
|
||||||
|
failure = self._reply_failure(_decide_raw)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[coding-reconcile] LLM decide failed, fallback to ADD-all: {e}")
|
logger.warning(f"[coding-reconcile] LLM decide failed: {e}")
|
||||||
ops = [ReconcileOp(action="ADD", draft_idx=i) for i in range(len(drafts))]
|
|
||||||
_decide_raw = f"[LLM error] {type(e).__name__}: {str(e)[:300]}"
|
_decide_raw = f"[LLM error] {type(e).__name__}: {str(e)[:300]}"
|
||||||
|
failure = (
|
||||||
if not ops:
|
"LLM_UNAVAILABLE",
|
||||||
# LLM 输出无 ops,但有 drafts → fallback ADD-all
|
f"coding reconcile LLM call failed: {type(e).__name__}: {e}",
|
||||||
logger.info("[coding-reconcile] LLM returned no ops, fallback to ADD-all")
|
)
|
||||||
ops = [ReconcileOp(action="ADD", draft_idx=i) for i in range(len(drafts))]
|
|
||||||
|
|
||||||
_decide_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
|
_decide_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
|
||||||
# CODING_RECONCILE:LLM 决策完成即打点(执行入库之前,crash 不丢)
|
# CODING_RECONCILE:LLM 决策完成即打点(执行入库之前,crash 不丢)
|
||||||
@@ -187,17 +232,42 @@ class CodingMemoryReconciler:
|
|||||||
self.cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
self.cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||||||
session_id=session_id, step="CODING_RECONCILE",
|
session_id=session_id, step="CODING_RECONCILE",
|
||||||
prompt=_decide_prompt, response=_decide_raw,
|
prompt=_decide_prompt, response=_decide_raw,
|
||||||
parsed=json.dumps({"ops": [o.to_dict() for o in ops]}, ensure_ascii=False, default=str),
|
parsed=json.dumps(
|
||||||
|
{"error": failure[0], "message": failure[1], "stored": 0}
|
||||||
|
if failure else {"ops": [o.to_dict() for o in ops]},
|
||||||
|
ensure_ascii=False, default=str,
|
||||||
|
),
|
||||||
elapsed_ms=_decide_elapsed,
|
elapsed_ms=_decide_elapsed,
|
||||||
prompt_tokens=getattr(_decide_resp, "prompt_tokens", 0) if _decide_resp else 0,
|
prompt_tokens=getattr(_decide_resp, "prompt_tokens", 0) if _decide_resp else 0,
|
||||||
completion_tokens=getattr(_decide_resp, "completion_tokens", 0) if _decide_resp else 0,
|
completion_tokens=getattr(_decide_resp, "completion_tokens", 0) if _decide_resp else 0,
|
||||||
total_tokens=getattr(_decide_resp, "tokens_used", 0) if _decide_resp else 0,
|
total_tokens=getattr(_decide_resp, "tokens_used", 0) if _decide_resp else 0,
|
||||||
)
|
)
|
||||||
|
if failure is not None:
|
||||||
|
code, message = failure
|
||||||
|
logger.warning(f"[coding-reconcile] {code}: {message}")
|
||||||
|
raise CodingWriteError("RECONCILE_FAILED", message, cause=code)
|
||||||
|
|
||||||
# 3. 校验 + 执行
|
return ExperienceReconcilePlan(
|
||||||
executed = await self._execute(ops, drafts, candidates_per_draft)
|
drafts=list(drafts),
|
||||||
|
candidates_per_draft=candidates_per_draft,
|
||||||
|
ops=ops,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def apply(self, plan: ExperienceReconcilePlan) -> List[ReconcileOp]:
|
||||||
|
"""执行 plan() 的决策(store ADD / UPDATE / DELETE)。"""
|
||||||
|
if not plan.drafts:
|
||||||
|
return []
|
||||||
|
if not plan.ops:
|
||||||
|
# 有效的 {"ops": []}:模型判定无需任何操作,本层什么都不存。
|
||||||
|
logger.info(
|
||||||
|
f"[coding-reconcile] drafts={len(plan.drafts)} LLM decided no ops"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
executed = await self._execute(
|
||||||
|
plan.ops, plan.drafts, plan.candidates_per_draft,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[coding-reconcile] drafts={len(drafts)} ops={len(executed)} "
|
f"[coding-reconcile] drafts={len(plan.drafts)} ops={len(executed)} "
|
||||||
+ ", ".join(f"{a}={sum(1 for o in executed if o.action == a)}"
|
+ ", ".join(f"{a}={sum(1 for o in executed if o.action == a)}"
|
||||||
for a in ("ADD", "UPDATE", "DELETE", "SKIP"))
|
for a in ("ADD", "UPDATE", "DELETE", "SKIP"))
|
||||||
)
|
)
|
||||||
@@ -316,11 +386,30 @@ class CodingMemoryReconciler:
|
|||||||
rows.append(f"for draft[{i}]:\n" + "\n".join(cand_rows))
|
rows.append(f"for draft[{i}]:\n" + "\n".join(cand_rows))
|
||||||
return "\n\n".join(rows) if rows else "(none)"
|
return "\n\n".join(rows) if rows else "(none)"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _reply_failure(cls, text: Optional[str]) -> Optional[Tuple[str, str]]:
|
||||||
|
"""回复本身是失败时返回 (cause, message);有效回复(含 {"ops": []})返回 None。"""
|
||||||
|
if not (text or "").strip():
|
||||||
|
return "EMPTY_RESPONSE", "coding reconcile LLM returned empty response"
|
||||||
|
obj = cls._load_object(text)
|
||||||
|
ops_raw = obj.get("ops") if isinstance(obj, dict) else None
|
||||||
|
if not isinstance(ops_raw, list):
|
||||||
|
return (
|
||||||
|
"JSON_PARSE_FAILED",
|
||||||
|
f"coding reconcile output has no ops list ({len(text)} chars)",
|
||||||
|
)
|
||||||
|
if ops_raw and not cls._parse_ops(text):
|
||||||
|
return (
|
||||||
|
"JSON_PARSE_FAILED",
|
||||||
|
f"coding reconcile output has no recognizable op ({len(text)} chars)",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_ops(text: str) -> List[ReconcileOp]:
|
def _load_object(text: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
"""宽松解析 ops 列表。"""
|
"""宽松解析出顶层 JSON 对象;解析不出返回 None。"""
|
||||||
if not text:
|
if not text:
|
||||||
return []
|
return None
|
||||||
s = text.strip()
|
s = text.strip()
|
||||||
# 去 markdown fence
|
# 去 markdown fence
|
||||||
if s.startswith("```"):
|
if s.startswith("```"):
|
||||||
@@ -344,8 +433,15 @@ class CodingMemoryReconciler:
|
|||||||
obj = json.loads(s[start: end + 1])
|
obj = json.loads(s[start: end + 1])
|
||||||
except Exception:
|
except Exception:
|
||||||
obj = None
|
obj = None
|
||||||
|
return obj if isinstance(obj, dict) else None
|
||||||
|
|
||||||
if not obj or not isinstance(obj, dict):
|
@classmethod
|
||||||
|
def _parse_ops(cls, text: str) -> List[ReconcileOp]:
|
||||||
|
"""宽松解析 ops 列表。"""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
obj = cls._load_object(text)
|
||||||
|
if not obj:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[coding-reconcile] failed to parse ops output ({len(text or '')} chars)"
|
f"[coding-reconcile] failed to parse ops output ({len(text or '')} chars)"
|
||||||
)
|
)
|
||||||
@@ -394,6 +490,9 @@ class CodingMemoryReconciler:
|
|||||||
- UPDATE 需 draft_idx 合法 + target_experience_id 非空
|
- UPDATE 需 draft_idx 合法 + target_experience_id 非空
|
||||||
- DELETE 需 target_experience_id 非空
|
- DELETE 需 target_experience_id 非空
|
||||||
- SKIP 不执行任何 store 操作
|
- SKIP 不执行任何 store 操作
|
||||||
|
|
||||||
|
向量化失败(CodingWriteError(EMBEDDING_FAILED))不吞成「少执行一条」,
|
||||||
|
原样抛给 CodingWriter,整次写入报失败。
|
||||||
"""
|
"""
|
||||||
executed: List[ReconcileOp] = []
|
executed: List[ReconcileOp] = []
|
||||||
# 已被 UPDATE 占用的 draft_idx → 防止同 draft 被重复 ADD/UPDATE
|
# 已被 UPDATE 占用的 draft_idx → 防止同 draft 被重复 ADD/UPDATE
|
||||||
@@ -414,6 +513,8 @@ class CodingMemoryReconciler:
|
|||||||
op.target_experience_id = memory.experience_id # 回写 id 供 caller 读
|
op.target_experience_id = memory.experience_id # 回写 id 供 caller 读
|
||||||
executed.append(op)
|
executed.append(op)
|
||||||
consumed.add(op.draft_idx)
|
consumed.add(op.draft_idx)
|
||||||
|
except CodingWriteError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[coding-reconcile] ADD failed: {e}")
|
logger.warning(f"[coding-reconcile] ADD failed: {e}")
|
||||||
|
|
||||||
@@ -444,6 +545,8 @@ class CodingMemoryReconciler:
|
|||||||
await self.store.update(memory)
|
await self.store.update(memory)
|
||||||
executed.append(op)
|
executed.append(op)
|
||||||
consumed.add(op.draft_idx)
|
consumed.add(op.draft_idx)
|
||||||
|
except CodingWriteError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[coding-reconcile] UPDATE failed: {e}")
|
logger.warning(f"[coding-reconcile] UPDATE failed: {e}")
|
||||||
|
|
||||||
@@ -487,6 +590,8 @@ class CodingMemoryReconciler:
|
|||||||
target_experience_id=memory.experience_id,
|
target_experience_id=memory.experience_id,
|
||||||
reason="fallback ADD (no LLM op)",
|
reason="fallback ADD (no LLM op)",
|
||||||
))
|
))
|
||||||
|
except CodingWriteError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[coding-reconcile] fallback ADD failed: {e}")
|
logger.warning(f"[coding-reconcile] fallback ADD failed: {e}")
|
||||||
|
|
||||||
|
|||||||
+109
-26
@@ -44,7 +44,7 @@ from ..data.vector_store import create_vector_store
|
|||||||
from ..data.vector_store_base import VectorStoreBase
|
from ..data.vector_store_base import VectorStoreBase
|
||||||
from ..models.memory import MemoryLayer, MemoryNode, MemoryStatus
|
from ..models.memory import MemoryLayer, MemoryNode, MemoryStatus
|
||||||
from ..utils.async_executor import run_in_executor_guarded
|
from ..utils.async_executor import run_in_executor_guarded
|
||||||
from .types import CodingMemory, WorkingFact
|
from .types import CodingMemory, CodingWriteError, WorkingFact
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -599,10 +599,15 @@ class CodingMemoryStore:
|
|||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
async def insert(self, memory: CodingMemory) -> None:
|
async def insert(self, memory: CodingMemory) -> None:
|
||||||
"""新增一条 coding memory(SQLite + VDB 全套)"""
|
"""新增一条 coding memory(SQLite + VDB 全套)。
|
||||||
|
|
||||||
|
先向量化再落 SQLite:向量化失败抛 CodingWriteError(EMBEDDING_FAILED),
|
||||||
|
什么都不写,不留一条没有向量、永远检索不到的经验。
|
||||||
|
"""
|
||||||
self._fill_timestamps(memory)
|
self._fill_timestamps(memory)
|
||||||
|
embeddings = await self._embed_vec_keys(memory)
|
||||||
await _run_in_pool(self._upsert_sql_sync, memory)
|
await _run_in_pool(self._upsert_sql_sync, memory)
|
||||||
await self._upsert_vec_keys(memory)
|
await self._upsert_vec_keys(memory, embeddings=embeddings)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[coding-store] inserted experience_id={memory.experience_id} "
|
f"[coding-store] inserted experience_id={memory.experience_id} "
|
||||||
f"keys={1 + len(memory.search_keys)}"
|
f"keys={1 + len(memory.search_keys)}"
|
||||||
@@ -627,6 +632,8 @@ class CodingMemoryStore:
|
|||||||
):
|
):
|
||||||
raise ValueError("coding_memory_update_authority_mismatch")
|
raise ValueError("coding_memory_update_authority_mismatch")
|
||||||
prev_key_count = 1 + len(prev.search_keys)
|
prev_key_count = 1 + len(prev.search_keys)
|
||||||
|
# 先向量化:失败时旧经验(SQLite 与向量行)原样保留,不改一个字。
|
||||||
|
embeddings = await self._embed_vec_keys(memory)
|
||||||
|
|
||||||
memory.updated_at = datetime.utcnow()
|
memory.updated_at = datetime.utcnow()
|
||||||
if memory.created_at is None:
|
if memory.created_at is None:
|
||||||
@@ -637,7 +644,7 @@ class CodingMemoryStore:
|
|||||||
# 删旧 VDB
|
# 删旧 VDB
|
||||||
await self._delete_vec_keys_by_count(memory.experience_id, prev_key_count)
|
await self._delete_vec_keys_by_count(memory.experience_id, prev_key_count)
|
||||||
# 写新 VDB
|
# 写新 VDB
|
||||||
await self._upsert_vec_keys(memory)
|
await self._upsert_vec_keys(memory, embeddings=embeddings)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[coding-store] updated experience_id={memory.experience_id} "
|
f"[coding-store] updated experience_id={memory.experience_id} "
|
||||||
f"old_keys={prev_key_count} new_keys={1 + len(memory.search_keys)}"
|
f"old_keys={prev_key_count} new_keys={1 + len(memory.search_keys)}"
|
||||||
@@ -1000,12 +1007,22 @@ class CodingMemoryStore:
|
|||||||
# 事实层
|
# 事实层
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
async def insert_fact(self, fact: WorkingFact) -> None:
|
async def insert_fact(
|
||||||
"""新增一条事实(SQLite + 事实 collection 各一行)。"""
|
self, fact: WorkingFact, embedding: Optional[List[float]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""新增一条事实(SQLite + 事实 collection 各一行)。
|
||||||
|
|
||||||
|
embedding 可由调用方预先用 embed_fact() 算好(fact reconcile 要在覆盖旧
|
||||||
|
事实之前确认新事实能向量化);不传时这里算。向量化失败抛
|
||||||
|
CodingWriteError(EMBEDDING_FAILED),什么都不写。
|
||||||
|
"""
|
||||||
if fact.created_at is None:
|
if fact.created_at is None:
|
||||||
fact.created_at = datetime.utcnow()
|
fact.created_at = datetime.utcnow()
|
||||||
|
# 先向量化再落 SQLite,理由同 insert():不落没有向量的事实。
|
||||||
|
if embedding is None:
|
||||||
|
embedding = await self.embed_fact(fact)
|
||||||
await _run_in_pool(self._upsert_fact_sql_sync, fact)
|
await _run_in_pool(self._upsert_fact_sql_sync, fact)
|
||||||
await self._upsert_fact_vec(fact)
|
await self._upsert_fact_vec(fact, embedding=embedding)
|
||||||
logger.info(f"[coding-store] inserted fact_id={fact.fact_id}")
|
logger.info(f"[coding-store] inserted fact_id={fact.fact_id}")
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[coding-store] inserted fact_id={fact.fact_id} "
|
f"[coding-store] inserted fact_id={fact.fact_id} "
|
||||||
@@ -1574,25 +1591,64 @@ class CodingMemoryStore:
|
|||||||
# VDB 实现
|
# VDB 实现
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
async def _upsert_vec_keys(self, memory: CodingMemory) -> None:
|
@staticmethod
|
||||||
"""把 task + search_keys 写入 VDB(每行一个 MemoryNode)。"""
|
def _vec_key_texts(memory: CodingMemory) -> List[Tuple[str, str]]:
|
||||||
if self._vec is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
all_keys: List[Tuple[str, str]] = [("task", memory.task)]
|
all_keys: List[Tuple[str, str]] = [("task", memory.task)]
|
||||||
for k in memory.search_keys:
|
for k in memory.search_keys:
|
||||||
if k:
|
if k:
|
||||||
all_keys.append(("aspect", k))
|
all_keys.append(("aspect", k))
|
||||||
|
return all_keys
|
||||||
|
|
||||||
|
async def _embed_vec_keys(
|
||||||
|
self, memory: CodingMemory
|
||||||
|
) -> Optional[List[List[float]]]:
|
||||||
|
"""写入链路用:向量化 task + search_keys,失败抛 CodingWriteError。
|
||||||
|
|
||||||
|
无 VDB 时返回 None(不需要向量)。
|
||||||
|
"""
|
||||||
|
if self._vec is None:
|
||||||
|
return None
|
||||||
|
texts = [t for _, t in self._vec_key_texts(memory)]
|
||||||
|
try:
|
||||||
|
embeddings = await self.embed_service.embed_batch(texts)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[coding-store] embed_batch failed: {e}")
|
||||||
|
raise CodingWriteError(
|
||||||
|
"EMBEDDING_FAILED",
|
||||||
|
f"coding experience embedding failed: {type(e).__name__}: {e}",
|
||||||
|
) from e
|
||||||
|
if len(embeddings or []) != len(texts) or not all(embeddings):
|
||||||
|
raise CodingWriteError(
|
||||||
|
"EMBEDDING_FAILED",
|
||||||
|
"coding experience embedding returned no vector",
|
||||||
|
)
|
||||||
|
return embeddings
|
||||||
|
|
||||||
|
async def _upsert_vec_keys(
|
||||||
|
self,
|
||||||
|
memory: CodingMemory,
|
||||||
|
embeddings: Optional[List[List[float]]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""把 task + search_keys 写入 VDB(每行一个 MemoryNode)。
|
||||||
|
|
||||||
|
embeddings 由写入链路预先算好(见 _embed_vec_keys);不传时现算,
|
||||||
|
失败只记 warning(edit_experience 的手动编辑路径沿用这一行为)。
|
||||||
|
"""
|
||||||
|
if self._vec is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
all_keys = self._vec_key_texts(memory)
|
||||||
if not all_keys:
|
if not all_keys:
|
||||||
return
|
return
|
||||||
|
|
||||||
# batch embed
|
# batch embed
|
||||||
texts = [t for _, t in all_keys]
|
texts = [t for _, t in all_keys]
|
||||||
try:
|
if embeddings is None:
|
||||||
embeddings = await self.embed_service.embed_batch(texts)
|
try:
|
||||||
except Exception as e:
|
embeddings = await self.embed_service.embed_batch(texts)
|
||||||
logger.warning(f"[coding-store] embed_batch failed: {e}")
|
except Exception as e:
|
||||||
return
|
logger.warning(f"[coding-store] embed_batch failed: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
nodes: List[MemoryNode] = []
|
nodes: List[MemoryNode] = []
|
||||||
for i, ((kind, text), emb) in enumerate(zip(all_keys, embeddings)):
|
for i, ((kind, text), emb) in enumerate(zip(all_keys, embeddings)):
|
||||||
@@ -1623,7 +1679,34 @@ class CodingMemoryStore:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[coding-store] vdb upsert_batch failed: {e}")
|
logger.warning(f"[coding-store] vdb upsert_batch failed: {e}")
|
||||||
|
|
||||||
async def _upsert_fact_vec(self, fact: WorkingFact) -> None:
|
@staticmethod
|
||||||
|
def _fact_vector_text(fact: WorkingFact) -> str:
|
||||||
|
return f"{fact.subject}: {fact.statement}" if fact.subject else fact.statement
|
||||||
|
|
||||||
|
async def embed_fact(self, fact: WorkingFact) -> Optional[List[float]]:
|
||||||
|
"""写入链路用:向量化事实检索文本,失败抛 CodingWriteError。
|
||||||
|
|
||||||
|
无事实 VDB 或 statement 为空时返回 None(不需要向量)。
|
||||||
|
"""
|
||||||
|
if self._fact_vec is None or not fact.statement:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
embedding = await self.embed_service.embed(self._fact_vector_text(fact))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[coding-store] fact embed failed: {e}")
|
||||||
|
raise CodingWriteError(
|
||||||
|
"EMBEDDING_FAILED",
|
||||||
|
f"working fact embedding failed: {type(e).__name__}: {e}",
|
||||||
|
) from e
|
||||||
|
if not embedding:
|
||||||
|
raise CodingWriteError(
|
||||||
|
"EMBEDDING_FAILED", "working fact embedding returned no vector",
|
||||||
|
)
|
||||||
|
return embedding
|
||||||
|
|
||||||
|
async def _upsert_fact_vec(
|
||||||
|
self, fact: WorkingFact, embedding: Optional[List[float]] = None,
|
||||||
|
) -> None:
|
||||||
"""事实入向量库。一条事实一行,node_id 就是 fact_id。
|
"""事实入向量库。一条事实一行,node_id 就是 fact_id。
|
||||||
|
|
||||||
检索文本是 statement,前面缀上 subject(有的话)。subject 通常已经在
|
检索文本是 statement,前面缀上 subject(有的话)。subject 通常已经在
|
||||||
@@ -1634,14 +1717,14 @@ class CodingMemoryStore:
|
|||||||
"""
|
"""
|
||||||
if self._fact_vec is None or not fact.statement:
|
if self._fact_vec is None or not fact.statement:
|
||||||
return
|
return
|
||||||
vector_text = (
|
vector_text = self._fact_vector_text(fact)
|
||||||
f"{fact.subject}: {fact.statement}" if fact.subject else fact.statement
|
if embedding is None:
|
||||||
)
|
# edit_fact 的手动编辑路径:现算,失败只记 warning。
|
||||||
try:
|
try:
|
||||||
embedding = await self.embed_service.embed(vector_text)
|
embedding = await self.embed_service.embed(vector_text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[coding-store] fact embed failed: {e}")
|
logger.warning(f"[coding-store] fact embed failed: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
node = MemoryNode(
|
node = MemoryNode(
|
||||||
node_id=fact.fact_id,
|
node_id=fact.fact_id,
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ FactStatus = Literal["active", "superseded"]
|
|||||||
MAX_FACT_CHAIN_ENTRIES = 8
|
MAX_FACT_CHAIN_ENTRIES = 8
|
||||||
|
|
||||||
|
|
||||||
|
class CodingWriteError(RuntimeError):
|
||||||
|
"""working / coding 写入链路里让整次写入失败的模型错误。
|
||||||
|
|
||||||
|
error_code 是稳定失败码,CodingWriter 原样透传为 agent_error_code:
|
||||||
|
- LLM_ERROR 抽取 LLM 调用失败
|
||||||
|
- EMPTY_RESPONSE 抽取 LLM 返回 200 但 content 为空
|
||||||
|
- JSON_PARSE_FAILED 抽取 LLM 的回复解析不出 JSON 对象
|
||||||
|
- EMBEDDING_FAILED 经验 / 事实向量化失败(不落没有向量的节点)
|
||||||
|
- RECONCILE_FAILED 经验 / 事实 reconcile 的模型决策失败;具体原因在 cause
|
||||||
|
(LLM_UNAVAILABLE / EMPTY_RESPONSE / JSON_PARSE_FAILED),
|
||||||
|
透传为 reconcile_error_code,与 chat 链路一致
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, error_code: str, message: str, cause: Optional[str] = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.error_code = error_code
|
||||||
|
self.cause = cause
|
||||||
|
|
||||||
|
|
||||||
def _epoch(value: Optional[datetime]) -> Optional[int]:
|
def _epoch(value: Optional[datetime]) -> Optional[int]:
|
||||||
"""memory_at 对外是秒级 epoch,与 chat 一致。"""
|
"""memory_at 对外是秒级 epoch,与 chat 一致。"""
|
||||||
return int(value.timestamp()) if isinstance(value, datetime) else None
|
return int(value.timestamp()) if isinstance(value, datetime) else None
|
||||||
|
|||||||
+108
-43
@@ -10,8 +10,9 @@ CodingWriter - 写入端协调器
|
|||||||
├── recent history: 最近 K 个 user-turn(只作上下文)
|
├── recent history: 最近 K 个 user-turn(只作上下文)
|
||||||
├── new messages: 本次全部 turn(只做单条限长)
|
├── new messages: 本次全部 turn(只做单条限长)
|
||||||
├── extractor.extract(...) ← 一次 LLM 产出双层草稿
|
├── extractor.extract(...) ← 一次 LLM 产出双层草稿
|
||||||
├── reconciler.reconcile(经验草稿) ← ADD/UPDATE/DELETE/SKIP
|
├── reconciler.plan(经验草稿) ← ADD/UPDATE/DELETE/SKIP 决策
|
||||||
├── fact_reconciler.reconcile(事实草稿) ← INSERT/SUPERSEDE
|
├── fact_reconciler.plan(事实草稿) ← INSERT/SUPERSEDE 决策
|
||||||
|
├── reconciler.apply / fact_reconciler.apply ← 两层决策都成功后才落库
|
||||||
└── _crosslink(...) ← 同批全互链回填
|
└── _crosslink(...) ← 同批全互链回填
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ from .judge import classify_messages_is_coding
|
|||||||
from .preproc import has_any_tool_message, prepare_extractor_messages
|
from .preproc import has_any_tool_message, prepare_extractor_messages
|
||||||
from .reconciler import CodingMemoryReconciler
|
from .reconciler import CodingMemoryReconciler
|
||||||
from .store import CodingMemoryStore
|
from .store import CodingMemoryStore
|
||||||
from .types import FactReconcileOp, ReconcileOp, WorkingFact
|
from .types import CodingWriteError, FactReconcileOp, ReconcileOp, WorkingFact
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -104,6 +105,13 @@ class CodingWriter:
|
|||||||
],
|
],
|
||||||
"fact_ids": ["..."], # 这趟真正落库的 fact_id
|
"fact_ids": ["..."], # 这趟真正落库的 fact_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
模型失败(抽取 LLM 调用失败 / 空回复 / 解析不出)或向量化失败时返回
|
||||||
|
success=False,agent_error_code 为 LLM_ERROR / EMPTY_RESPONSE /
|
||||||
|
JSON_PARSE_FAILED / EMBEDDING_FAILED,error_code=502。经验层 / 事实层
|
||||||
|
reconcile 的模型决策失败时 agent_error_code=RECONCILE_FAILED,原因在
|
||||||
|
reconcile_error_code(LLM_UNAVAILABLE / EMPTY_RESPONSE / JSON_PARSE_FAILED),
|
||||||
|
本次什么都不落库。模型答了但没有值得记的内容仍是 success=True、0 条。
|
||||||
"""
|
"""
|
||||||
request_id = request_id or str(uuid.uuid4())
|
request_id = request_id or str(uuid.uuid4())
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
@@ -143,6 +151,10 @@ class CodingWriter:
|
|||||||
# 跟库里已有的什么关系是 reconcile 的事。把已有 task 摆到它面前,它会把
|
# 跟库里已有的什么关系是 reconcile 的事。把已有 task 摆到它面前,它会把
|
||||||
# 一个其实不同的任务往清单里某个措辞上靠,reconciler 随后判成 UPDATE 合并
|
# 一个其实不同的任务往清单里某个措辞上靠,reconciler 随后判成 UPDATE 合并
|
||||||
# 掉,那个区别就没了——措辞对不上顶多多存一条,措辞被带偏是直接写错。
|
# 掉,那个区别就没了——措辞对不上顶多多存一条,措辞被带偏是直接写错。
|
||||||
|
#
|
||||||
|
# 模型失败(LLM 调用失败 / 空回复 / 解析不出)抛 CodingWriteError:整次写入
|
||||||
|
# 报失败、什么都不存,连 trace 也不追加——调用方换个 request_id 重跑时,
|
||||||
|
# 历史窗口里不会多出这段本该是「新消息」的对话。
|
||||||
try:
|
try:
|
||||||
drafts, fact_drafts = await self.extractor.extract(
|
drafts, fact_drafts = await self.extractor.extract(
|
||||||
messages,
|
messages,
|
||||||
@@ -156,26 +168,26 @@ class CodingWriter:
|
|||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
language=language,
|
language=language,
|
||||||
)
|
)
|
||||||
finally:
|
except CodingWriteError as e:
|
||||||
# 历史服务于下一次 write;即使本次没跨过价值门槛,也不能丢掉上下文。
|
return self._failure_response(request_id, t0, e)
|
||||||
try:
|
|
||||||
await self.store.append_trace(
|
trace_kwargs = dict(
|
||||||
current_messages,
|
request_id=request_id,
|
||||||
request_id=request_id,
|
user_id=user_id,
|
||||||
user_id=user_id,
|
agent_id=agent_id or "default_agent",
|
||||||
agent_id=agent_id or "default_agent",
|
session_id=session_id,
|
||||||
session_id=session_id,
|
workspace_id=workspace_id,
|
||||||
workspace_id=workspace_id,
|
branch=branch,
|
||||||
branch=branch,
|
source=source,
|
||||||
source=source,
|
)
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[coding-write] append trace failed: {e}")
|
|
||||||
|
|
||||||
if not drafts and not fact_drafts:
|
if not drafts and not fact_drafts:
|
||||||
|
# 模型答了、只是没有值得记的:正常的空抽取,仍算成功。历史服务于下一次
|
||||||
|
# write;即使本次没跨过价值门槛,也不能丢掉上下文。
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[coding-write] no drafts produced (value bar); request_id={request_id}"
|
f"[coding-write] no drafts produced (value bar); request_id={request_id}"
|
||||||
)
|
)
|
||||||
|
await self._append_trace(current_messages, **trace_kwargs)
|
||||||
return self._empty_response(request_id, t0)
|
return self._empty_response(request_id, t0)
|
||||||
|
|
||||||
# extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。
|
# extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。
|
||||||
@@ -183,34 +195,51 @@ class CodingWriter:
|
|||||||
draft.source = source
|
draft.source = source
|
||||||
draft.memory_at = memory_at
|
draft.memory_at = memory_at
|
||||||
|
|
||||||
# 3. 经验层 reconcile + 持久化
|
# 3. 两层 reconcile 的模型决策(只检索 + 调 LLM,不落库)
|
||||||
ops: List[ReconcileOp] = (
|
# 4. 两层决策都成功后才落库:经验层 apply → 事实层 apply
|
||||||
await self.reconciler.reconcile(
|
#
|
||||||
drafts,
|
# 先把所有模型决策做完再写库,任何一层的决策失败(LLM 失败 / 空回复 /
|
||||||
request_id=request_id,
|
# 解析不出 → CodingWriteError(RECONCILE_FAILED))都发生在落库之前,本次
|
||||||
user_id=user_id,
|
# 写入一条经验 / 事实都没存、也没覆盖旧事实,换个 request_id 重跑即干净。
|
||||||
agent_id=agent_id,
|
# 两层决策互不依赖(各自检索各自的候选),提前做不改变结果。
|
||||||
session_id=session_id or "",
|
#
|
||||||
language=language,
|
# 向量化失败抛 CodingWriteError(EMBEDDING_FAILED):不落没有向量的节点,
|
||||||
)
|
# 整次写入报失败。
|
||||||
if drafts else []
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 事实层 reconcile + 持久化(独立链路,不受经验层结果影响)
|
|
||||||
facts: List[WorkingFact] = []
|
facts: List[WorkingFact] = []
|
||||||
fact_ops: List[FactReconcileOp] = []
|
fact_ops: List[FactReconcileOp] = []
|
||||||
if fact_drafts:
|
ops: List[ReconcileOp] = []
|
||||||
facts, fact_ops = await self.fact_reconciler.reconcile(
|
try:
|
||||||
fact_drafts,
|
experience_plan = (
|
||||||
user_id=user_id,
|
await self.reconciler.plan(
|
||||||
agent_id=agent_id,
|
drafts,
|
||||||
session_id=session_id,
|
request_id=request_id,
|
||||||
workspace_id=workspace_id,
|
user_id=user_id,
|
||||||
branch=branch,
|
agent_id=agent_id,
|
||||||
source=source,
|
session_id=session_id or "",
|
||||||
memory_at=memory_at,
|
language=language,
|
||||||
language=language,
|
)
|
||||||
|
if drafts else None
|
||||||
)
|
)
|
||||||
|
fact_plan = (
|
||||||
|
await self.fact_reconciler.plan(
|
||||||
|
fact_drafts,
|
||||||
|
user_id=user_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
session_id=session_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
branch=branch,
|
||||||
|
source=source,
|
||||||
|
memory_at=memory_at,
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
|
if fact_drafts else None
|
||||||
|
)
|
||||||
|
if experience_plan is not None:
|
||||||
|
ops = await self.reconciler.apply(experience_plan)
|
||||||
|
if fact_plan is not None:
|
||||||
|
facts, fact_ops = await self.fact_reconciler.apply(fact_plan)
|
||||||
|
except CodingWriteError as e:
|
||||||
|
return self._failure_response(request_id, t0, e)
|
||||||
|
|
||||||
experience_ids = [op.target_experience_id for op in ops
|
experience_ids = [op.target_experience_id for op in ops
|
||||||
if op.action in ("ADD", "UPDATE") and op.target_experience_id]
|
if op.action in ("ADD", "UPDATE") and op.target_experience_id]
|
||||||
@@ -218,6 +247,7 @@ class CodingWriter:
|
|||||||
|
|
||||||
# 5. 同批全互链
|
# 5. 同批全互链
|
||||||
await self._crosslink(experience_ids, facts, user_id=user_id)
|
await self._crosslink(experience_ids, facts, user_id=user_id)
|
||||||
|
await self._append_trace(current_messages, **trace_kwargs)
|
||||||
|
|
||||||
add_cnt = sum(1 for op in ops if op.action == "ADD")
|
add_cnt = sum(1 for op in ops if op.action == "ADD")
|
||||||
update_cnt = sum(1 for op in ops if op.action == "UPDATE")
|
update_cnt = sum(1 for op in ops if op.action == "UPDATE")
|
||||||
@@ -298,6 +328,41 @@ class CodingWriter:
|
|||||||
f"[coding-write] crosslink fact {f.fact_id} failed: {e}"
|
f"[coding-write] crosslink fact {f.fact_id} failed: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _append_trace(self, current_messages: List[ChatMessage], **kwargs) -> None:
|
||||||
|
"""把本次对话追加进 recent trace(下一次 write 的历史上下文),best-effort。"""
|
||||||
|
try:
|
||||||
|
await self.store.append_trace(current_messages, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[coding-write] append trace failed: {e}")
|
||||||
|
|
||||||
|
def _failure_response(
|
||||||
|
self, request_id: str, t0: float, error: CodingWriteError,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""模型 / 向量化失败:success=False,稳定失败码在 agent_error_code。
|
||||||
|
|
||||||
|
形状与 chat 链路的失败回执一致(error_code=502,error_message 带
|
||||||
|
「[CODE] 」前缀),调用方据 success 判定并重跑。
|
||||||
|
"""
|
||||||
|
code = error.error_code
|
||||||
|
cause = getattr(error, "cause", None)
|
||||||
|
message = f"{cause}: {error}" if cause else str(error)
|
||||||
|
logger.warning(
|
||||||
|
f"[coding-write] failed: code={code} request_id={request_id}: {message}"
|
||||||
|
)
|
||||||
|
resp = self._empty_response(request_id, t0)
|
||||||
|
resp.update({
|
||||||
|
"success": False,
|
||||||
|
"error_code": 502,
|
||||||
|
"error_message": f"[{code}] {message}",
|
||||||
|
"agent_status": "failed",
|
||||||
|
"agent_error_code": code,
|
||||||
|
"agent_error": message,
|
||||||
|
})
|
||||||
|
if cause:
|
||||||
|
# 与 chat 链路一致:reconcile 失败的具体原因
|
||||||
|
resp["reconcile_error_code"] = cause
|
||||||
|
return resp
|
||||||
|
|
||||||
def _empty_response(self, request_id: str, t0: float) -> Dict[str, Any]:
|
def _empty_response(self, request_id: str, t0: float) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
@@ -432,6 +432,14 @@ class RecallConfig:
|
|||||||
retrieval_clauses_require_keyword: bool = None # env: MEMORY_RETRIEVAL_CLAUSES_REQUIRE_KEYWORD
|
retrieval_clauses_require_keyword: bool = None # env: MEMORY_RETRIEVAL_CLAUSES_REQUIRE_KEYWORD
|
||||||
retrieval_clauses_min_keyword_score: float = None # normalized BM25 floor
|
retrieval_clauses_min_keyword_score: float = None # normalized BM25 floor
|
||||||
supplemental_propositions_enabled: bool = None # env: MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED
|
supplemental_propositions_enabled: bool = None # env: MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED
|
||||||
|
# ── Intention(L7)判重 ──
|
||||||
|
# 开启(默认):召回时同 user+agent+同天 memory_at+同 valid_until 且
|
||||||
|
# cosine≥阈值 的意图视为复本,只返回最早一条,其余异步从 VDB 删除(历史行为)。
|
||||||
|
# 关闭:读侧与写入侧批内都不判重——不隐藏、不丢弃、不删除任何意图。
|
||||||
|
intention_dedup_enabled: bool = None # env: MEMORY_INTENTION_DEDUP_ENABLED
|
||||||
|
# 读侧意图判重 cosine 阈值。env: MEMORY_INTENTION_DEDUP_THRESHOLD,
|
||||||
|
# 未设置时回退到共享的 MEMORY_DEDUP_THRESHOLD(兼容旧部署),默认 0.95。
|
||||||
|
intention_dedup_threshold: float = None
|
||||||
|
|
||||||
# 最小分数阈值
|
# 最小分数阈值
|
||||||
min_score_threshold: float = 0.0
|
min_score_threshold: float = 0.0
|
||||||
@@ -750,6 +758,15 @@ class RecallConfig:
|
|||||||
self.supplemental_propositions_enabled = _get_env_bool(
|
self.supplemental_propositions_enabled = _get_env_bool(
|
||||||
"MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED", False
|
"MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED", False
|
||||||
)
|
)
|
||||||
|
if self.intention_dedup_enabled is None:
|
||||||
|
self.intention_dedup_enabled = _get_env_bool(
|
||||||
|
"MEMORY_INTENTION_DEDUP_ENABLED", True
|
||||||
|
)
|
||||||
|
if self.intention_dedup_threshold is None:
|
||||||
|
self.intention_dedup_threshold = _get_env_float(
|
||||||
|
"MEMORY_INTENTION_DEDUP_THRESHOLD",
|
||||||
|
_get_env_float("MEMORY_DEDUP_THRESHOLD", 0.95),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -1384,6 +1401,8 @@ class MemoryConfig:
|
|||||||
"supplemental_propositions_enabled": (
|
"supplemental_propositions_enabled": (
|
||||||
self.recall.supplemental_propositions_enabled
|
self.recall.supplemental_propositions_enabled
|
||||||
),
|
),
|
||||||
|
"intention_dedup_enabled": self.recall.intention_dedup_enabled,
|
||||||
|
"intention_dedup_threshold": self.recall.intention_dedup_threshold,
|
||||||
},
|
},
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"default_version": self.pipeline.default_version,
|
"default_version": self.pipeline.default_version,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ HY Memory - Intention (L7) 召回 + 惰性过期转换 + 复本判定
|
|||||||
→ 视为同一个意图的复本(extractor 复读机 / 多 session 重复表达)。
|
→ 视为同一个意图的复本(extractor 复读机 / 多 session 重复表达)。
|
||||||
保留组里 `gmt_created` 最早的(老的先来先得,稳定 id);
|
保留组里 `gmt_created` 最早的(老的先来先得,稳定 id);
|
||||||
- **异步删库**:其余复本 fire-and-forget 从 VDB 删除,不影响本次调用 RT。
|
- **异步删库**:其余复本 fire-and-forget 从 VDB 删除,不影响本次调用 RT。
|
||||||
|
- **开关**:判重受 `MemoryConfig.recall.intention_dedup_enabled`(默认开)与
|
||||||
|
`recall.intention_dedup_threshold` 控制,由 reader 按 client 配置传入。
|
||||||
|
关闭时完全跳过判重:不隐藏、不删除任何意图(写入侧批内判重同样跳过)。
|
||||||
|
|
||||||
Q: 为什么要看 memory_at 和 valid_until?
|
Q: 为什么要看 memory_at 和 valid_until?
|
||||||
A: 意图是 point-in-time 信号。同一句"我周五要开会",如果一条 memory_at 在
|
A: 意图是 point-in-time 信号。同一句"我周五要开会",如果一条 memory_at 在
|
||||||
@@ -38,8 +41,14 @@ from ...models.memory import MemoryLayer, MemoryStatus
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _get_dedup_threshold() -> float:
|
def _get_dedup_threshold(configured: Optional[float] = None) -> float:
|
||||||
"""去重 cosine 阈值:沿用 dedup 内核的 MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
|
"""去重 cosine 阈值:优先用 client 配置;否则(或配置非法)沿用 dedup 内核的
|
||||||
|
MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
|
||||||
|
try:
|
||||||
|
if configured is not None:
|
||||||
|
return float(configured)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning(f"[intention] invalid dedup threshold: {configured!r}")
|
||||||
try:
|
try:
|
||||||
return float(os.environ.get("MEMORY_DEDUP_THRESHOLD", 0.95))
|
return float(os.environ.get("MEMORY_DEDUP_THRESHOLD", 0.95))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -244,6 +253,8 @@ async def recall_intentions(
|
|||||||
stats_out: Optional[Dict[str, Any]] = None,
|
stats_out: Optional[Dict[str, Any]] = None,
|
||||||
include_sources: Optional[List[str]] = None,
|
include_sources: Optional[List[str]] = None,
|
||||||
exclude_sources: Optional[List[str]] = None,
|
exclude_sources: Optional[List[str]] = None,
|
||||||
|
dedup_enabled: bool = True,
|
||||||
|
dedup_threshold: Optional[float] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
召回未过期的 L7_INTENTION 节点(VDB),惰性过期转 L2_FACT,判重并异步删复本。
|
召回未过期的 L7_INTENTION 节点(VDB),惰性过期转 L2_FACT,判重并异步删复本。
|
||||||
@@ -260,6 +271,11 @@ async def recall_intentions(
|
|||||||
now: 当前时间(默认 datetime.now(),测试可注入)
|
now: 当前时间(默认 datetime.now(),测试可注入)
|
||||||
stats_out: 可选 dict,本函数把分段耗时与数量**原地回填**进去,供调用方落
|
stats_out: 可选 dict,本函数把分段耗时与数量**原地回填**进去,供调用方落
|
||||||
READ_INTENTION pipeline log。传 None 则完全不统计(零开销)。
|
READ_INTENTION pipeline log。传 None 则完全不统计(零开销)。
|
||||||
|
dedup_enabled: 是否执行读侧判重(及复本异步删库)。False 时跳过判重,
|
||||||
|
所有未过期意图原样返回,不删除任何节点。默认 True(历史行为)。
|
||||||
|
dedup_threshold: 判重 cosine 阈值。None 时回退到 env
|
||||||
|
MEMORY_DEDUP_THRESHOLD(默认 0.95),兼容直接调用本函数的旧代码;
|
||||||
|
reader 总是传入 `recall.intention_dedup_threshold`。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
[{"node_id", "score", "node", "layer": "l7_intention", "source": "vdb_intention"}, ...]
|
[{"node_id", "score", "node", "layer": "l7_intention", "source": "vdb_intention"}, ...]
|
||||||
@@ -272,7 +288,7 @@ async def recall_intentions(
|
|||||||
埋点字段(回填进 stats_out):
|
埋点字段(回填进 stats_out):
|
||||||
耗时(ms): vdb_search_ms / expire_ms / backfill_embed_ms / dedup_ms / total_ms
|
耗时(ms): vdb_search_ms / expire_ms / backfill_embed_ms / dedup_ms / total_ms
|
||||||
数量: fetched / expired / survivors / dedup_dropped / returned
|
数量: fetched / expired / survivors / dedup_dropped / returned
|
||||||
配置: fetch_limit / out_limit / dedup_threshold
|
配置: fetch_limit / out_limit / dedup_enabled / dedup_threshold
|
||||||
说明: expire_ms、backfill_embed_ms 只有真的发生了对应操作才非 0,
|
说明: expire_ms、backfill_embed_ms 只有真的发生了对应操作才非 0,
|
||||||
借此可判断慢在 VDB 检索、逐条过期转换、补拉向量还是判重计算。
|
借此可判断慢在 VDB 检索、逐条过期转换、补拉向量还是判重计算。
|
||||||
"""
|
"""
|
||||||
@@ -358,7 +374,7 @@ async def recall_intentions(
|
|||||||
_t = time.perf_counter()
|
_t = time.perf_counter()
|
||||||
_backfilled = 0
|
_backfilled = 0
|
||||||
_missing_cnt = 0
|
_missing_cnt = 0
|
||||||
if survivors:
|
if survivors and dedup_enabled: # 补拉只为判重;关闭时无需这次 VDB 读
|
||||||
missing_ids = [
|
missing_ids = [
|
||||||
h.get("node_id") for h in survivors
|
h.get("node_id") for h in survivors
|
||||||
if h.get("node_id") and not getattr(h.get("node"), "embedding", None)
|
if h.get("node_id") and not getattr(h.get("node"), "embedding", None)
|
||||||
@@ -391,18 +407,23 @@ async def recall_intentions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 2. 判重:同 user + 同天 memory_at + valid_until 同日 + cosine≥threshold
|
# 2. 判重:同 user + 同天 memory_at + valid_until 同日 + cosine≥threshold
|
||||||
|
# dedup_enabled=False → 完全跳过:不隐藏、不删除。
|
||||||
_t = time.perf_counter()
|
_t = time.perf_counter()
|
||||||
threshold = _get_dedup_threshold()
|
threshold = _get_dedup_threshold(dedup_threshold)
|
||||||
try:
|
if dedup_enabled:
|
||||||
kept, delete_ids = _partition_duplicates(survivors, threshold)
|
try:
|
||||||
except Exception as e:
|
kept, delete_ids = _partition_duplicates(survivors, threshold)
|
||||||
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
|
except Exception as e:
|
||||||
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
|
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
|
||||||
|
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
|
||||||
|
kept, delete_ids = survivors, []
|
||||||
|
else:
|
||||||
kept, delete_ids = survivors, []
|
kept, delete_ids = survivors, []
|
||||||
# 纯 CPU(cosine 两两比较);survivors 多时可能显著
|
# 纯 CPU(cosine 两两比较);survivors 多时可能显著
|
||||||
_stat(
|
_stat(
|
||||||
dedup_ms=round((time.perf_counter() - _t) * 1000, 2),
|
dedup_ms=round((time.perf_counter() - _t) * 1000, 2),
|
||||||
dedup_dropped=len(delete_ids),
|
dedup_dropped=len(delete_ids),
|
||||||
|
dedup_enabled=bool(dedup_enabled),
|
||||||
dedup_threshold=threshold,
|
dedup_threshold=threshold,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1856,6 +1856,12 @@ class BaseReadPipeline(ReadPipeline):
|
|||||||
stats_out=_intent_stats,
|
stats_out=_intent_stats,
|
||||||
include_sources=request.include_sources,
|
include_sources=request.include_sources,
|
||||||
exclude_sources=request.exclude_sources,
|
exclude_sources=request.exclude_sources,
|
||||||
|
dedup_enabled=bool(getattr(
|
||||||
|
self.config.recall, "intention_dedup_enabled", True
|
||||||
|
)),
|
||||||
|
dedup_threshold=getattr(
|
||||||
|
self.config.recall, "intention_dedup_threshold", None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await trace_log.log_intention(
|
await trace_log.log_intention(
|
||||||
stats=_intent_stats,
|
stats=_intent_stats,
|
||||||
|
|||||||
@@ -73,6 +73,19 @@ def _write_apply_verify_enabled() -> bool:
|
|||||||
_RECONCILE_LOOP_USER_LOCKS = weakref.WeakKeyDictionary()
|
_RECONCILE_LOOP_USER_LOCKS = weakref.WeakKeyDictionary()
|
||||||
|
|
||||||
|
|
||||||
|
class ReconcileFailure(str):
|
||||||
|
"""reconcile 失败时 `_reconcile_and_store` 返回的 error:仍是错误信息字符串,
|
||||||
|
另带稳定的失败码 `code`(LLM_UNAVAILABLE / EMPTY_RESPONSE / JSON_PARSE_FAILED /
|
||||||
|
RECONCILE_EXCEPTION),供 writer 透传给调用方。"""
|
||||||
|
|
||||||
|
code: str = "RECONCILE_FAILED"
|
||||||
|
|
||||||
|
def __new__(cls, message: str, code: str = "RECONCILE_FAILED"):
|
||||||
|
obj = super().__new__(cls, message)
|
||||||
|
obj.code = code
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def _reconcile_user_lock(user_id: str) -> asyncio.Lock:
|
def _reconcile_user_lock(user_id: str) -> asyncio.Lock:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
locks = _RECONCILE_LOOP_USER_LOCKS.get(loop)
|
locks = _RECONCILE_LOOP_USER_LOCKS.get(loop)
|
||||||
@@ -635,6 +648,7 @@ class MemoryWriter(WritePipeline):
|
|||||||
不查 VDB、不做"新写入 vs 库里已有"的对比,避免给 VDB 增加读压力。
|
不查 VDB、不做"新写入 vs 库里已有"的对比,避免给 VDB 增加读压力。
|
||||||
库内存量污染由 recall 侧异步删除逐步清理。best-effort:任何异常都
|
库内存量污染由 recall 侧异步删除逐步清理。best-effort:任何异常都
|
||||||
回退到"不判重、全写入",绝不影响 upsert 主流程。
|
回退到"不判重、全写入",绝不影响 upsert 主流程。
|
||||||
|
`recall.intention_dedup_enabled=False` 时跳过批内判重,全部写入。
|
||||||
"""
|
"""
|
||||||
if not intentions:
|
if not intentions:
|
||||||
return []
|
return []
|
||||||
@@ -660,7 +674,11 @@ class MemoryWriter(WritePipeline):
|
|||||||
# ============ 批内 dedup(不查 VDB,只对本批 intentions 互斥判重) ============
|
# ============ 批内 dedup(不查 VDB,只对本批 intentions 互斥判重) ============
|
||||||
keep_mask: List[bool] = [True] * len(intentions)
|
keep_mask: List[bool] = [True] * len(intentions)
|
||||||
try:
|
try:
|
||||||
dropped = self._intention_batch_dedup(intentions, embeddings)
|
dropped = (
|
||||||
|
self._intention_batch_dedup(intentions, embeddings)
|
||||||
|
if getattr(self.config.recall, "intention_dedup_enabled", True)
|
||||||
|
else []
|
||||||
|
)
|
||||||
for i in dropped:
|
for i in dropped:
|
||||||
if 0 <= i < len(keep_mask):
|
if 0 <= i < len(keep_mask):
|
||||||
keep_mask[i] = False
|
keep_mask[i] = False
|
||||||
@@ -1257,15 +1275,6 @@ class MemoryWriter(WritePipeline):
|
|||||||
recon_tokens 含 prompt/completion/total(reconcile 链路 LLM 消耗)
|
recon_tokens 含 prompt/completion/total(reconcile 链路 LLM 消耗)
|
||||||
"""
|
"""
|
||||||
stored_ids: List[str] = []
|
stored_ids: List[str] = []
|
||||||
# Episode:本次写入归属的内部会话 id(跨 write 把"一件事"归到一起)。
|
|
||||||
# 只数 user 侧记忆(与 rolling summary 只数 pending_user_count 一致)——
|
|
||||||
# agent 侧记忆若参与计数会把 episode 提前撑到上限、切得过碎,白付摘要的 token。
|
|
||||||
episode_id = await self._resolve_episode_id(
|
|
||||||
request, _count_user_owned(new_memories_meta)
|
|
||||||
)
|
|
||||||
session_episode_id = self._session_episode_id(request)
|
|
||||||
if session_episode_id:
|
|
||||||
episode_id = session_episode_id
|
|
||||||
# 关系图用:收集本次落库的 (node, op),供 graph 投影建边。
|
# 关系图用:收集本次落库的 (node, op),供 graph 投影建边。
|
||||||
# 收 op 是为了拿 op.related(LLM 标注的 typed 关系)。
|
# 收 op 是为了拿 op.related(LLM 标注的 typed 关系)。
|
||||||
graph_payload: List[Tuple[Any, Any]] = []
|
graph_payload: List[Tuple[Any, Any]] = []
|
||||||
@@ -1292,7 +1301,32 @@ class MemoryWriter(WritePipeline):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not recon_result.success:
|
if not recon_result.success:
|
||||||
return [], recon_result.error, {"add": 0, "supersede": 0, "update": 0, "total": 0}, {"prompt": 0, "completion": 0, "total": 0}
|
# reconcile 失败:本次一条新记忆都不落库(不做 ADD-all 兜底),由调用方重跑。
|
||||||
|
# episode 状态也还没推进,重跑不会把同一批记忆重复计数。
|
||||||
|
error = ReconcileFailure(
|
||||||
|
recon_result.error or "reconcile failed",
|
||||||
|
code=recon_result.error_code or "RECONCILE_FAILED",
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
f"TRACE_PERF [{req_id}] S1_RECONCILE_FAILED "
|
||||||
|
f"code={error.code} candidates={len(new_memory_texts)} stored=0"
|
||||||
|
)
|
||||||
|
return [], error, {"add": 0, "supersede": 0, "update": 0, "total": 0}, {
|
||||||
|
"prompt": recon_result.prompt_tokens,
|
||||||
|
"completion": recon_result.completion_tokens,
|
||||||
|
"total": recon_result.total_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Episode:本次写入归属的内部会话 id(跨 write 把"一件事"归到一起)。
|
||||||
|
# 只数 user 侧记忆(与 rolling summary 只数 pending_user_count 一致)——
|
||||||
|
# agent 侧记忆若参与计数会把 episode 提前撑到上限、切得过碎,白付摘要的 token。
|
||||||
|
# 放在 reconcile 成功之后:失败的写入不能推进 episode 计数。
|
||||||
|
episode_id = await self._resolve_episode_id(
|
||||||
|
request, _count_user_owned(new_memories_meta)
|
||||||
|
)
|
||||||
|
session_episode_id = self._session_episode_id(request)
|
||||||
|
if session_episode_id:
|
||||||
|
episode_id = session_episode_id
|
||||||
|
|
||||||
# 分类统计
|
# 分类统计
|
||||||
_op_counts = {}
|
_op_counts = {}
|
||||||
@@ -1303,16 +1337,7 @@ class MemoryWriter(WritePipeline):
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"TRACE_PERF [{req_id}] S1_RECONCILE_DONE "
|
f"TRACE_PERF [{req_id}] S1_RECONCILE_DONE "
|
||||||
f"ops={len(recon_result.ops)} candidates={len(new_memory_texts)} | {_op_counts_str}"
|
f"ops={len(recon_result.ops)} candidates={len(new_memory_texts)} | {_op_counts_str}"
|
||||||
+ (f" | DEGRADED({recon_result.degraded_reason})"
|
|
||||||
if getattr(recon_result, "degraded", False) else "")
|
|
||||||
)
|
)
|
||||||
if getattr(recon_result, "degraded", False):
|
|
||||||
# 走了 ADD-all 兜底:内容保住了,但没做去重/演化判定。必须可观测,
|
|
||||||
# 否则线上只会看到"记忆变多了"而不知道为什么。
|
|
||||||
logger.warning(
|
|
||||||
f"[write] reconcile degraded → ADD-all "
|
|
||||||
f"({recon_result.degraded_reason}), {len(recon_result.ops)} memories stored as-is"
|
|
||||||
)
|
|
||||||
# 逐条 op 详情由 reconciler 的 "[reconciler] ops detail" 单条 JSON list 输出,
|
# 逐条 op 详情由 reconciler 的 "[reconciler] ops detail" 单条 JSON list 输出,
|
||||||
# 这里不再逐条打印(避免重复刷屏)。
|
# 这里不再逐条打印(避免重复刷屏)。
|
||||||
|
|
||||||
@@ -2502,12 +2527,10 @@ class MemoryWriter(WritePipeline):
|
|||||||
response.extra["extract_tokens"] = {"prompt": 0, "completion": 0, "total": 0}
|
response.extra["extract_tokens"] = {"prompt": 0, "completion": 0, "total": 0}
|
||||||
|
|
||||||
if recon_error is not None:
|
if recon_error is not None:
|
||||||
response.success = False
|
# 与 write() 一致:reconcile 失败不落任何记忆,报失败供调用方重跑。
|
||||||
response.error_code = 502
|
self._mark_reconcile_failure(response, recon_error)
|
||||||
response.error_message = f"[RECONCILE_FAILED] {recon_error}"
|
response.extra["agent_mode"] = "extracted"
|
||||||
response.extra["agent_status"] = "failed"
|
logger.warning(f"[write_extracted] reconcile failed: {recon_error}")
|
||||||
response.extra["agent_error"] = recon_error
|
|
||||||
response.extra["agent_nodes"] = 0
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
response.success = True
|
response.success = True
|
||||||
@@ -3526,12 +3549,11 @@ class MemoryWriter(WritePipeline):
|
|||||||
response.extra["reconcile_ops"] = ops_counts
|
response.extra["reconcile_ops"] = ops_counts
|
||||||
response.extra["reconcile_tokens"] = recon_tokens
|
response.extra["reconcile_tokens"] = recon_tokens
|
||||||
if recon_error is not None:
|
if recon_error is not None:
|
||||||
|
# stored_ids 为空:reconcile 失败时一条新记忆都不落库,write()
|
||||||
|
# 随后回滚 L1_RAW(retry_safe=True),换个 request_id 重跑即可。
|
||||||
response.extra["agent_stored_ids"] = stored_ids
|
response.extra["agent_stored_ids"] = stored_ids
|
||||||
self._mark_agent_failure(
|
self._mark_reconcile_failure(
|
||||||
response,
|
response, recon_error, agent_nodes=len(stored_ids),
|
||||||
error_code="RECONCILE_FAILED",
|
|
||||||
error_message=recon_error,
|
|
||||||
agent_nodes=len(stored_ids),
|
|
||||||
)
|
)
|
||||||
logger.warning(f"[write] reconcile failed: {recon_error}")
|
logger.warning(f"[write] reconcile failed: {recon_error}")
|
||||||
return
|
return
|
||||||
@@ -3654,6 +3676,29 @@ class MemoryWriter(WritePipeline):
|
|||||||
response.error_code = 502
|
response.error_code = 502
|
||||||
response.error_message = f"[{code}] {message}"
|
response.error_message = f"[{code}] {message}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _mark_reconcile_failure(
|
||||||
|
cls,
|
||||||
|
response: WriteResponse,
|
||||||
|
recon_error: str,
|
||||||
|
*,
|
||||||
|
agent_nodes: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""reconcile 失败:agent_error_code 恒为 RECONCILE_FAILED,具体原因
|
||||||
|
(LLM_UNAVAILABLE / EMPTY_RESPONSE / JSON_PARSE_FAILED / RECONCILE_EXCEPTION)
|
||||||
|
放在 reconcile_error_code。"""
|
||||||
|
reason = getattr(recon_error, "code", "") or "RECONCILE_FAILED"
|
||||||
|
response.extra["reconcile_error_code"] = reason
|
||||||
|
cls._mark_agent_failure(
|
||||||
|
response,
|
||||||
|
error_code="RECONCILE_FAILED",
|
||||||
|
error_message=(
|
||||||
|
f"{reason}: {recon_error}"
|
||||||
|
if reason != "RECONCILE_FAILED" else str(recon_error)
|
||||||
|
),
|
||||||
|
agent_nodes=agent_nodes,
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_agent_failure(self, request, response, agent_result, tracer_span, extract_timing: Optional[Dict[str, Any]] = None) -> None:
|
async def _handle_agent_failure(self, request, response, agent_result, tracer_span, extract_timing: Optional[Dict[str, Any]] = None) -> None:
|
||||||
"""处理 agent extract 失败的情况。"""
|
"""处理 agent extract 失败的情况。"""
|
||||||
if agent_result is None:
|
if agent_result is None:
|
||||||
|
|||||||
@@ -137,6 +137,15 @@ memhub profile
|
|||||||
`user-profile` / `memhub profile` 调用 SDK `get_user_profile()`,返回组装后的
|
`user-profile` / `memhub profile` 调用 SDK `get_user_profile()`,返回组装后的
|
||||||
用户长期画像,不通过普通 Search 拼装。
|
用户长期画像,不通过普通 Search 拼装。
|
||||||
|
|
||||||
|
读接口都回显 `user_id`(实际读的用户,未指定时为 `local`):
|
||||||
|
`GET /api/v2/memory-library`、`/memory-library/layers`、`/user-profile`、
|
||||||
|
`/user-profile/versions`、`/home-summary`、`/memories/graph`、`/raw`,
|
||||||
|
`POST /api/v2/context/search`、`/api/v1/recall`、`/api/v2/memories/ask`;
|
||||||
|
`/memories/ask/stream` 写在第一个 `recall` 事件上。
|
||||||
|
`DELETE /api/v2/memories/{id}` 只在删掉调用者自己的记忆时回 204;另一个用户的
|
||||||
|
记忆和不存在的 ID 一律回 404 `memory_not_found`(不暴露是否存在),调用方按
|
||||||
|
「已不存在」处理。
|
||||||
|
|
||||||
返回值:
|
返回值:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
+3
-3
@@ -18,8 +18,8 @@ If you run into a problem, please open an Issue with the version, platform,repro
|
|||||||
|
|
||||||
## 发行与平台合同
|
## 发行与平台合同
|
||||||
|
|
||||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.11`。MemHub 源码版本为 `1.0.6`
|
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
|
||||||
(build 37),GitHub 仓库为
|
(build 41),GitHub 仓库为
|
||||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||||
`memhub-cli`。当前代码架构见
|
`memhub-cli`。当前代码架构见
|
||||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||||
@@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
|
|||||||
|
|
||||||
## Hy Memory
|
## Hy Memory
|
||||||
|
|
||||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.11`。SDK 配置、权限边界及 search
|
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.14`。SDK 配置、权限边界及 search
|
||||||
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
||||||
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
||||||
|
|
||||||
|
|||||||
@@ -2352,7 +2352,7 @@
|
|||||||
{
|
{
|
||||||
"SPDXID": "SPDXRef-Package-hy-memory",
|
"SPDXID": "SPDXRef-Package-hy-memory",
|
||||||
"name": "hy-memory",
|
"name": "hy-memory",
|
||||||
"versionInfo": "2.0.11",
|
"versionInfo": "2.0.14",
|
||||||
"downloadLocation": "NOASSERTION",
|
"downloadLocation": "NOASSERTION",
|
||||||
"filesAnalyzed": false,
|
"filesAnalyzed": false,
|
||||||
"licenseConcluded": "Apache-2.0",
|
"licenseConcluded": "Apache-2.0",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
|
|||||||
column is filled in as part of release review, and the SBOM records
|
column is filled in as part of release review, and the SBOM records
|
||||||
`NOASSERTION` until then rather than guessing.
|
`NOASSERTION` until then rather than guessing.
|
||||||
|
|
||||||
## hy-memory 2.0.11
|
## hy-memory 2.0.14
|
||||||
|
|
||||||
License: Apache-2.0
|
License: Apache-2.0
|
||||||
|
|
||||||
|
|||||||
+29
-13
@@ -1938,7 +1938,7 @@ async function runCommand(
|
|||||||
return reject(
|
return reject(
|
||||||
descriptor,
|
descriptor,
|
||||||
asJson,
|
asJson,
|
||||||
"--user-id 和 --root 只能用于接入单个 hermes 来源(Hermes profile),用户 ID 只能是 1–64 位字母、数字、点、下划线或连字符。",
|
"--user-id 和 --root 只能用于接入单个 hermes 来源(Hermes profile),用户 ID 只能是 1–64 位字母、数字、点、下划线或连字符。用户 ID 不必等于 Hermes profile 名,例如 --user-id wife-work --root ~/.hermes/profiles/wife:work。",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!parsed) return usage();
|
if (!parsed) return usage();
|
||||||
@@ -2328,29 +2328,41 @@ async function runCommand(
|
|||||||
...userIdParameters(userId),
|
...userIdParameters(userId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// A delete answered 404 `memory_not_found` is already gone (deleted
|
||||||
|
// before, or never this user's): what was asked for holds, so it exits 0
|
||||||
|
// as committed and says so, instead of failing a retry or a double run.
|
||||||
|
const alreadyGone =
|
||||||
|
parsed.kind === "delete" &&
|
||||||
|
outcome.state === "rejected" &&
|
||||||
|
outcome.code === "memory_not_found";
|
||||||
|
const state = alreadyGone ? "committed" : outcome.state;
|
||||||
const document = {
|
const document = {
|
||||||
schema_version: 2,
|
schema_version: 2,
|
||||||
product: PRODUCT,
|
product: PRODUCT,
|
||||||
command: `memories ${parsed.kind}`,
|
command: `memories ${parsed.kind}`,
|
||||||
state: outcome.state,
|
state,
|
||||||
operation: parsed.kind,
|
operation: parsed.kind,
|
||||||
memory_id: parsed.memoryId,
|
memory_id: parsed.memoryId,
|
||||||
...(outcome.state === "committed"
|
...(alreadyGone
|
||||||
? parsed.kind === "update"
|
? { already_gone: true }
|
||||||
? { result: outcome.data }
|
: outcome.state === "committed"
|
||||||
: {}
|
? parsed.kind === "update"
|
||||||
: { code: outcome.code }),
|
? { result: outcome.data }
|
||||||
|
: {}
|
||||||
|
: { code: outcome.code }),
|
||||||
};
|
};
|
||||||
emit(
|
emit(
|
||||||
asJson
|
asJson
|
||||||
? `${JSON.stringify(document)}\n`
|
? `${JSON.stringify(document)}\n`
|
||||||
: outcome.state === "committed"
|
: alreadyGone
|
||||||
? `已${parsed.kind === "update" ? "更新" : "删除"}记忆 ${parsed.memoryId}\n`
|
? `未找到记忆 ${parsed.memoryId}(可能已删除),无需再删。\n`
|
||||||
: outcome.code === "memory_id_ambiguous"
|
: outcome.state === "committed"
|
||||||
? "这个 ID 同时存在于多个场景;请加 --kind(以及 --variant)指明是哪一条。\n"
|
? `已${parsed.kind === "update" ? "更新" : "删除"}记忆 ${parsed.memoryId}\n`
|
||||||
: `操作失败:${outcome.code}\n`,
|
: outcome.code === "memory_id_ambiguous"
|
||||||
|
? "这个 ID 同时存在于多个场景;请加 --kind(以及 --variant)指明是哪一条。\n"
|
||||||
|
: `操作失败:${outcome.code}\n`,
|
||||||
);
|
);
|
||||||
return outcome.state === "committed" ? EXIT_OK : EXIT_FAILED;
|
return state === "committed" ? EXIT_OK : EXIT_FAILED;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
named === "settings" &&
|
named === "settings" &&
|
||||||
@@ -4371,6 +4383,10 @@ function renderWriteReceiptText(receipt: MemoryWriteReceipt): string {
|
|||||||
...(receipt.memory_ids && receipt.memory_ids.length > 0
|
...(receipt.memory_ids && receipt.memory_ids.length > 0
|
||||||
? [`memory_ids: ${receipt.memory_ids.join(",")}`]
|
? [`memory_ids: ${receipt.memory_ids.join(",")}`]
|
||||||
: []),
|
: []),
|
||||||
|
...(receipt.error_code ? [`error_code: ${receipt.error_code}`] : []),
|
||||||
|
...(receipt.next_retry_at
|
||||||
|
? [`next_retry_at: ${receipt.next_retry_at}`]
|
||||||
|
: []),
|
||||||
"",
|
"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
|||||||
{
|
{
|
||||||
name: "sources",
|
name: "sources",
|
||||||
summary:
|
summary:
|
||||||
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。Hermes 的每个 profile 是一个独立用户:用 --user-id 和 --root 接入,授权并导入历史(会自动排除 ~/.hermes/profiles/**,各用户互不读取)。",
|
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。Hermes 的每个 profile 是一个独立用户:用 --user-id 和 --root 接入,授权并导入历史(任何层级的 profiles/ 目录都会被排除,各用户互不读取)。--root 不能是 profiles 目录本身,也不能与另一个用户的 Hermes 目录相同或互相包含(~/.hermes 与 ~/.hermes/profiles/<name> 除外)。",
|
||||||
usage:
|
usage:
|
||||||
"memhub sources list [--json]\n memhub sources connect <id> [<id> ...] [--import] [--confirm-overrides] [--request-id <uuid>] [--json]\n memhub sources connect hermes --user-id <id> --root <path> [--import] [--request-id <uuid>] [--json]\n memhub sources connect --all-detected [--import] [--confirm-overrides] [--json]\n memhub sources receipt <request-id> [--json]",
|
"memhub sources list [--json]\n memhub sources connect <id> [<id> ...] [--import] [--confirm-overrides] [--request-id <uuid>] [--json]\n memhub sources connect hermes --user-id <id> --root <path> [--import] [--request-id <uuid>] [--json]\n memhub sources connect --all-detected [--import] [--confirm-overrides] [--json]\n memhub sources receipt <request-id> [--json]",
|
||||||
options: [
|
options: [
|
||||||
@@ -270,7 +270,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
|||||||
{
|
{
|
||||||
flag: "--user-id <id>",
|
flag: "--user-id <id>",
|
||||||
summary:
|
summary:
|
||||||
"只用于 hermes:这条接入属于哪个用户(默认 local)。和 --root 一起接入一个 Hermes profile,例如 --user-id wife --root ~/.hermes/profiles/wife;其他来源只能是 local。",
|
"只用于 hermes:这条接入属于哪个用户(默认 local)。和 --root 一起接入一个 Hermes profile,例如 --user-id wife --root ~/.hermes/profiles/wife;用户 ID 不必等于 profile 名,例如 --user-id wife-work --root ~/.hermes/profiles/wife:work。其他来源只能是 local。",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
flag: "--root <path>",
|
flag: "--root <path>",
|
||||||
@@ -397,7 +397,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
|||||||
{
|
{
|
||||||
name: "memories",
|
name: "memories",
|
||||||
summary:
|
summary:
|
||||||
"列出、搜索、查看、添加、修改或物理删除记忆(对话、工作、编码三个场景)。add 和 App 的「添加记忆」是同一个入口;delete 不可逆,需 --yes。",
|
"列出、搜索、查看、添加、修改或物理删除记忆(对话、工作、编码三个场景)。add 和 App 的「添加记忆」是同一个入口;delete 不可逆,需 --yes;要删的记忆已不存在(或不属于该用户)时提示未找到,按已删除处理并退出 0。",
|
||||||
usage:
|
usage:
|
||||||
"memhub memories list [--scene <all|chat|working|coding>] [--source <id>] [--query <text>] [--status <state>] [--offset <n>] [--limit <n>] [--user-id <id>] [--json]\n memhub memories show <memory-id> [--kind <kind>] [--variant <variant>] [--user-id <id>] [--json]\n memhub memories add <text> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add --content-file <path> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add-status <request-id> [--user-id <id>] [--json]\n memhub memories update <memory-id> --content-file <path> [--kind <kind>] [--variant <variant>] [--task <text>] [--user-id <id>] [--json]\n memhub memories delete <memory-id> [--kind <kind>] [--variant <variant>] [--yes] [--user-id <id>] [--json]",
|
"memhub memories list [--scene <all|chat|working|coding>] [--source <id>] [--query <text>] [--status <state>] [--offset <n>] [--limit <n>] [--user-id <id>] [--json]\n memhub memories show <memory-id> [--kind <kind>] [--variant <variant>] [--user-id <id>] [--json]\n memhub memories add <text> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add --content-file <path> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add-status <request-id> [--user-id <id>] [--json]\n memhub memories update <memory-id> --content-file <path> [--kind <kind>] [--variant <variant>] [--task <text>] [--user-id <id>] [--json]\n memhub memories delete <memory-id> [--kind <kind>] [--variant <variant>] [--yes] [--user-id <id>] [--json]",
|
||||||
options: [
|
options: [
|
||||||
@@ -461,11 +461,12 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
|||||||
{
|
{
|
||||||
name: "settings",
|
name: "settings",
|
||||||
summary:
|
summary:
|
||||||
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。settings features 查看或开关 SDK 的可选检索与提取功能(全局设置,默认全部保持 SDK 默认值):graph_rerank 图谱重排(仅 ultra)、same_series_timeline 同系列时间线(需 graph_rerank)、episode_recall 片段召回(pro / ultra,需先开 session_episodes 写入片段)、session_episodes 片段写入、episode_summary 片段摘要(每个片段一次模型调用)、intention 意图提取(默认开)、few_shot 提取示例;set 需 --yes,不加时只显示计划;reset 恢复默认值。settings language 查看或设置界面语言(App 切换语言时会自动设置):简体中文(zh-Hans)和 system 不指定 SDK 语言,SDK 照旧自动识别中文或英文;其余语言(en、zh-Hant、ja、es、de、pt、fr、ko、ru、id)会让提取等 SDK 提示使用该语言,立即生效。",
|
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。settings features 查看或开关 SDK 的可选检索与提取功能(全局设置,默认全部保持 SDK 默认值):graph_rerank 图谱重排(仅 ultra)、same_series_timeline 同系列时间线(需 graph_rerank)、episode_recall 片段召回(pro / ultra,需先开 session_episodes 写入片段)、session_episodes 片段写入、episode_summary 片段摘要(每个片段一次模型调用)、intention 意图提取(默认开)、intention_dedup 意图判重(默认开;关闭则检索与写入都不判重、不删除意图)、few_shot 提取示例,以及数值 intention_dedup_threshold 意图判重阈值(默认 0.95,可设 0.8–1.0);set 需 --yes,不加时只显示计划;reset 恢复默认值。settings language 查看或设置界面语言(App 切换语言时会自动设置):简体中文(zh-Hans)和 system 不指定 SDK 语言,SDK 照旧自动识别中文或英文;其余语言(en、zh-Hant、ja、es、de、pt、fr、ko、ru、id)会让提取等 SDK 提示使用该语言,立即生效。",
|
||||||
usage:
|
usage:
|
||||||
"memhub settings mode [lite|pro|ultra] [--yes] [--json]\n" +
|
"memhub settings mode [lite|pro|ultra] [--yes] [--json]\n" +
|
||||||
" memhub settings features [--json]\n" +
|
" memhub settings features [--json]\n" +
|
||||||
" memhub settings features set <name>=<true|false> [...] [--yes] [--json]\n" +
|
" memhub settings features set <name>=<true|false> [...] [--yes] [--json]\n" +
|
||||||
|
" memhub settings features set intention_dedup_threshold=<0.8-1.0> [--yes] [--json]\n" +
|
||||||
" memhub settings features reset [<name> ...] [--json]\n" +
|
" memhub settings features reset [<name> ...] [--json]\n" +
|
||||||
" memhub settings language [<code>|system] [--json]",
|
" memhub settings language [<code>|system] [--json]",
|
||||||
options: [
|
options: [
|
||||||
@@ -476,7 +477,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
|||||||
{
|
{
|
||||||
flag: "features set <name>=<true|false>",
|
flag: "features set <name>=<true|false>",
|
||||||
summary:
|
summary:
|
||||||
"开关一个或多个 SDK 功能;功能在当前模式下不生效时会注明原因(例如 graph_rerank 需要 ultra)。",
|
"开关一个或多个 SDK 功能;功能在当前模式下不生效时会注明原因(例如 graph_rerank 需要 ultra)。intention_dedup_threshold 取 0.8–1.0 的数值。",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
flag: "features reset [<name> ...]",
|
flag: "features reset [<name> ...]",
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ const READ_ONLY_PATHS = [
|
|||||||
"/api/v1/model-profiles",
|
"/api/v1/model-profiles",
|
||||||
"/api/v1/source-grants",
|
"/api/v1/source-grants",
|
||||||
"/api/v1/sources",
|
"/api/v1/sources",
|
||||||
|
"/api/v2/home-projection",
|
||||||
"/api/v2/home-summary",
|
"/api/v2/home-summary",
|
||||||
|
"/api/v2/raw",
|
||||||
"/api/v2/memory-library",
|
"/api/v2/memory-library",
|
||||||
"/api/v2/user-profile",
|
"/api/v2/user-profile",
|
||||||
"/api/v2/user-profile/versions",
|
"/api/v2/user-profile/versions",
|
||||||
"/api/v1/settings/memory-mode",
|
"/api/v1/settings/memory-mode",
|
||||||
|
"/api/v1/settings/memory-mode/plan",
|
||||||
"/api/v1/settings/memory-features",
|
"/api/v1/settings/memory-features",
|
||||||
"/api/v1/settings/ui-language",
|
"/api/v1/settings/ui-language",
|
||||||
"/api/v2/evolve",
|
"/api/v2/evolve",
|
||||||
@@ -268,6 +271,11 @@ export interface MemoryWriteReceipt {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
raw_ids?: string[];
|
raw_ids?: string[];
|
||||||
memory_ids?: string[];
|
memory_ids?: string[];
|
||||||
|
/** Stable code of the last failure while the write still waits; null when
|
||||||
|
* none. Optional so older Runtimes stay readable. */
|
||||||
|
error_code?: string | null;
|
||||||
|
/** When Core will try this write again (ISO); null when not scheduled. */
|
||||||
|
next_retry_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenAIMessage {
|
export interface OpenAIMessage {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
HomeProjectionV1,
|
||||||
ImportIssue,
|
ImportIssue,
|
||||||
ImportJob,
|
ImportJob,
|
||||||
LiveSyncHealth,
|
LiveSyncHealth,
|
||||||
@@ -26,6 +27,9 @@ export const MEMORY_STATUSES = [
|
|||||||
const ID_MAX = 128;
|
const ID_MAX = 128;
|
||||||
const LABEL_MAX = 256;
|
const LABEL_MAX = 256;
|
||||||
const LIST_MAX = 500;
|
const LIST_MAX = 500;
|
||||||
|
const USER_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
||||||
|
const ISO_TIME =
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
||||||
const LIVE_SYNC_STATES = [
|
const LIVE_SYNC_STATES = [
|
||||||
"idle",
|
"idle",
|
||||||
"running",
|
"running",
|
||||||
@@ -406,6 +410,153 @@ export function validateHomeSummary(value: unknown): SdkHomeSummary | null {
|
|||||||
return summary as unknown as SdkHomeSummary;
|
return summary as unknown as SdkHomeSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `GET /api/v2/home-projection`: the mode-aware home content plane. */
|
||||||
|
export function validateHomeProjection(
|
||||||
|
value: unknown,
|
||||||
|
): HomeProjectionV1 | null {
|
||||||
|
const projection = exactRecord(value, [
|
||||||
|
"projection_version",
|
||||||
|
"user_id",
|
||||||
|
"effective_mode",
|
||||||
|
"capabilities",
|
||||||
|
"visible_count",
|
||||||
|
"display_state",
|
||||||
|
"latest",
|
||||||
|
"observed_at",
|
||||||
|
]);
|
||||||
|
if (!projection) return null;
|
||||||
|
const mode = projection.effective_mode;
|
||||||
|
const capabilities = exactRecord(projection.capabilities, [
|
||||||
|
"content_plane",
|
||||||
|
"generation_required",
|
||||||
|
"graph_enabled",
|
||||||
|
"evolve_available",
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
projection.projection_version !== 1 ||
|
||||||
|
typeof projection.user_id !== "string" ||
|
||||||
|
!USER_ID.test(projection.user_id) ||
|
||||||
|
!oneOf(mode, ["lite", "pro", "ultra"] as const) ||
|
||||||
|
!capabilities ||
|
||||||
|
capabilities.content_plane !== (mode === "lite" ? "raw" : "memory") ||
|
||||||
|
capabilities.generation_required !== (mode !== "lite") ||
|
||||||
|
capabilities.graph_enabled !== (mode === "ultra") ||
|
||||||
|
capabilities.evolve_available !== (mode === "ultra") ||
|
||||||
|
count(projection.visible_count) === null ||
|
||||||
|
!oneOf(projection.display_state, [
|
||||||
|
"empty",
|
||||||
|
"excerpt",
|
||||||
|
"no_user_excerpt",
|
||||||
|
"no_displayable_memory",
|
||||||
|
] as const) ||
|
||||||
|
!isoTime(projection.observed_at) ||
|
||||||
|
!validProjectionLatest(projection.latest, mode)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const visible = projection.visible_count as number;
|
||||||
|
const latest = projection.latest as Record<string, unknown> | null;
|
||||||
|
if (
|
||||||
|
(visible === 0 &&
|
||||||
|
(latest !== null || projection.display_state !== "empty")) ||
|
||||||
|
(visible > 0 && projection.display_state === "empty") ||
|
||||||
|
(latest === null &&
|
||||||
|
["excerpt", "no_user_excerpt"].includes(
|
||||||
|
projection.display_state as string,
|
||||||
|
)) ||
|
||||||
|
(latest?.type === "raw" &&
|
||||||
|
projection.display_state !== latest.display_state) ||
|
||||||
|
(latest?.type === "memory" && projection.display_state !== "excerpt")
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return projection as unknown as HomeProjectionV1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validProjectionLatest(
|
||||||
|
value: unknown,
|
||||||
|
mode: "lite" | "pro" | "ultra",
|
||||||
|
): boolean {
|
||||||
|
if (value === null) return true;
|
||||||
|
const base = [
|
||||||
|
"type",
|
||||||
|
"time",
|
||||||
|
"time_basis",
|
||||||
|
"excerpt",
|
||||||
|
"truncated",
|
||||||
|
"source_id",
|
||||||
|
];
|
||||||
|
const candidate = record(value);
|
||||||
|
if (!candidate) return false;
|
||||||
|
const keys =
|
||||||
|
candidate.type === "raw"
|
||||||
|
? [...base, "raw_id", "session_id", "display_state"]
|
||||||
|
: [...base, "memory_id", "scene", "kind", "variant"];
|
||||||
|
const latest = exactRecord(value, keys);
|
||||||
|
if (
|
||||||
|
!latest ||
|
||||||
|
!(latest.time === null || isoTime(latest.time)) ||
|
||||||
|
![null, "conversation", "import", "write", "update"].includes(
|
||||||
|
latest.time_basis as null | string,
|
||||||
|
) ||
|
||||||
|
text(latest.excerpt, 200) === null ||
|
||||||
|
typeof latest.truncated !== "boolean" ||
|
||||||
|
text(latest.source_id, ID_MAX) === null
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (latest.type === "raw") {
|
||||||
|
return (
|
||||||
|
mode === "lite" &&
|
||||||
|
nonemptyText(latest.raw_id, ID_MAX) &&
|
||||||
|
nonemptyText(latest.session_id, LABEL_MAX) &&
|
||||||
|
oneOf(latest.display_state, ["excerpt", "no_user_excerpt"] as const) &&
|
||||||
|
(latest.display_state !== "no_user_excerpt" ||
|
||||||
|
(latest.excerpt === "" && latest.truncated === false))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
latest.type === "memory" &&
|
||||||
|
mode !== "lite" &&
|
||||||
|
nonemptyText(latest.memory_id, ID_MAX) &&
|
||||||
|
oneOf(latest.scene, ["chat", "working", "coding"] as const) &&
|
||||||
|
oneOf(latest.kind, ["chat", "experience"] as const) &&
|
||||||
|
[null, "working", "coding"].includes(latest.variant as null | string) &&
|
||||||
|
((latest.kind === "chat" &&
|
||||||
|
latest.scene === "chat" &&
|
||||||
|
latest.variant === null) ||
|
||||||
|
(latest.kind === "experience" &&
|
||||||
|
latest.scene === latest.variant &&
|
||||||
|
oneOf(latest.scene, ["working", "coding"] as const)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bounded `/api/v2/raw?limit=1` envelope used only for its real total. */
|
||||||
|
export function validateRawTotal(value: unknown): number | null {
|
||||||
|
const page = exactRecord(value, [
|
||||||
|
"user_id",
|
||||||
|
"items",
|
||||||
|
"total",
|
||||||
|
"offset",
|
||||||
|
"limit",
|
||||||
|
"time_filtered",
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
!page ||
|
||||||
|
typeof page.user_id !== "string" ||
|
||||||
|
!USER_ID.test(page.user_id) ||
|
||||||
|
!Array.isArray(page.items) ||
|
||||||
|
page.items.length > 1 ||
|
||||||
|
count(page.total) === null ||
|
||||||
|
page.offset !== 0 ||
|
||||||
|
page.limit !== 1 ||
|
||||||
|
page.time_filtered !== false
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return page.total as number;
|
||||||
|
}
|
||||||
|
|
||||||
function identifiers(value: unknown): string[] | null {
|
function identifiers(value: unknown): string[] | null {
|
||||||
return list(value ?? [], (item) => text(item, ID_MAX));
|
return list(value ?? [], (item) => text(item, ID_MAX));
|
||||||
}
|
}
|
||||||
@@ -430,6 +581,30 @@ function record(value: unknown): Record<string, unknown> | null {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exactRecord(
|
||||||
|
value: unknown,
|
||||||
|
keys: readonly string[],
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const item = record(value);
|
||||||
|
return item &&
|
||||||
|
Object.keys(item).length === keys.length &&
|
||||||
|
keys.every((key) => Object.hasOwn(item, key))
|
||||||
|
? item
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoTime(value: unknown): value is string {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
ISO_TIME.test(value) &&
|
||||||
|
Number.isFinite(Date.parse(value))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonemptyText(value: unknown, max: number): value is string {
|
||||||
|
return typeof value === "string" && value.length > 0 && value.length <= max;
|
||||||
|
}
|
||||||
|
|
||||||
function text(value: unknown, max: number): string | null {
|
function text(value: unknown, max: number): string | null {
|
||||||
return typeof value === "string" && value.length <= max ? value : null;
|
return typeof value === "string" && value.length <= max ? value : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,20 @@ describe("models activate with an embedding change", () => {
|
|||||||
code: "embedding_switch_required",
|
code: "embedding_switch_required",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not treat a domain 404 as an old-Core route miss", async () => {
|
||||||
|
transport.post.mockResolvedValue({
|
||||||
|
status: 404,
|
||||||
|
data: { detail: "embedding_switch_not_found" },
|
||||||
|
});
|
||||||
|
const result = await capturedRun(["models", "activate", PROFILE, "--json"]);
|
||||||
|
expect(result.code).toBe(1);
|
||||||
|
expect(
|
||||||
|
transport.post.mock.calls.some(([url]) =>
|
||||||
|
String(url).endsWith("/activate"),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("models switch and spaces", () => {
|
describe("models switch and spaces", () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* makes it live. The CLI shows the plan, asks, starts the job and follows it.
|
* makes it live. The CLI shows the plan, asks, starts the job and follows it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { isBareNotFound } from "@one-memory/operations";
|
||||||
import type { CoreReader } from "./core-client.js";
|
import type { CoreReader } from "./core-client.js";
|
||||||
import type {
|
import type {
|
||||||
CoreDeleteOperations,
|
CoreDeleteOperations,
|
||||||
@@ -387,7 +388,11 @@ async function requestPlan(
|
|||||||
}
|
}
|
||||||
if (outcome.state === "rejected") {
|
if (outcome.state === "rejected") {
|
||||||
// A Core before the registry has no plan route: activation as before.
|
// A Core before the registry has no plan route: activation as before.
|
||||||
if (outcome.http_status === 404 && outcome.code === "http_404") {
|
if (
|
||||||
|
outcome.http_status === 404 &&
|
||||||
|
outcome.code === "http_404" &&
|
||||||
|
isBareNotFound(outcome.body)
|
||||||
|
) {
|
||||||
return { state: "unsupported" };
|
return { state: "unsupported" };
|
||||||
}
|
}
|
||||||
const plan = parsePlan(record(outcome.body)?.plan);
|
const plan = parsePlan(record(outcome.body)?.plan);
|
||||||
|
|||||||
@@ -348,12 +348,25 @@ export function parseApiKey(bytes: Buffer | null): string | undefined {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What to do about a refusal whose code alone does not say. */
|
||||||
|
const FLOW_CODE_HINTS: Record<string, string> = {
|
||||||
|
hermes_root_is_profiles_container:
|
||||||
|
"--root 指向的是 profiles 目录本身;请指向其中一个 profile,例如 ~/.hermes/profiles/wife。",
|
||||||
|
hermes_root_overlaps_other_user:
|
||||||
|
"--root 与另一个用户已接入的 Hermes 目录相同或互相包含,会读到对方的记录;请换一个目录,或先撤销对方的接入。~/.hermes 与 ~/.hermes/profiles/<name> 可以分属两个用户。",
|
||||||
|
};
|
||||||
|
|
||||||
export function renderFlowText(document: FlowDocument): string {
|
export function renderFlowText(document: FlowDocument): string {
|
||||||
const lines = [
|
const lines = [
|
||||||
`操作状态:${document.state}`,
|
`操作状态:${document.state}`,
|
||||||
`操作 ID:${document.operation_id ?? "无"}`,
|
`操作 ID:${document.operation_id ?? "无"}`,
|
||||||
"code" in document ? `代码:${document.code}` : "已返回有界回执。",
|
"code" in document ? `代码:${document.code}` : "已返回有界回执。",
|
||||||
];
|
];
|
||||||
|
const hint =
|
||||||
|
"code" in document && typeof document.code === "string"
|
||||||
|
? FLOW_CODE_HINTS[document.code]
|
||||||
|
: undefined;
|
||||||
|
if (hint) lines.push(hint);
|
||||||
// A partial success or an all-rejected batch must name each source that was
|
// A partial success or an all-rejected batch must name each source that was
|
||||||
// not accepted; a bare "partial" leaves the user with nothing to act on.
|
// not accepted; a bare "partial" leaves the user with nothing to act on.
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -233,6 +233,31 @@ describe("memories add-status", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes the retry code and time of an addition still waiting", async () => {
|
||||||
|
transport.get.mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
...addReceipt("pending"),
|
||||||
|
error_code: "model_auth_failed",
|
||||||
|
next_retry_at: "2026-11-02T10:00:00+00:00",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const json = await capturedRun([
|
||||||
|
"memories",
|
||||||
|
"add-status",
|
||||||
|
REQUEST,
|
||||||
|
"--json",
|
||||||
|
]);
|
||||||
|
expect(JSON.parse(json.stdout).receipt).toMatchObject({
|
||||||
|
state: "pending",
|
||||||
|
error_code: "model_auth_failed",
|
||||||
|
next_retry_at: "2026-11-02T10:00:00+00:00",
|
||||||
|
});
|
||||||
|
const text = await capturedRun(["memories", "add-status", REQUEST]);
|
||||||
|
expect(text.stdout).toContain("error_code: model_auth_failed");
|
||||||
|
expect(text.stdout).toContain("next_retry_at: 2026-11-02T10:00:00+00:00");
|
||||||
|
});
|
||||||
|
|
||||||
it("reports an unknown request id as not_found, exit 1", async () => {
|
it("reports an unknown request id as not_found, exit 1", async () => {
|
||||||
transport.get.mockResolvedValue({
|
transport.get.mockResolvedValue({
|
||||||
status: 404,
|
status: 404,
|
||||||
@@ -366,6 +391,33 @@ describe("memories update / delete pass the namespace", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports a delete answered 404 as already gone and exits 0", async () => {
|
||||||
|
transport.delete.mockResolvedValue({
|
||||||
|
status: 404,
|
||||||
|
data: { detail: "memory_not_found" },
|
||||||
|
});
|
||||||
|
const json = await capturedRun([
|
||||||
|
"memories",
|
||||||
|
"delete",
|
||||||
|
"gone-1",
|
||||||
|
"--yes",
|
||||||
|
"--json",
|
||||||
|
]);
|
||||||
|
expect(json.code).toBe(0);
|
||||||
|
expect(JSON.parse(json.stdout)).toEqual({
|
||||||
|
schema_version: 2,
|
||||||
|
product: expect.any(String),
|
||||||
|
command: "memories delete",
|
||||||
|
state: "committed",
|
||||||
|
operation: "delete",
|
||||||
|
memory_id: "gone-1",
|
||||||
|
already_gone: true,
|
||||||
|
});
|
||||||
|
const text = await capturedRun(["memories", "delete", "gone-1", "--yes"]);
|
||||||
|
expect(text.code).toBe(0);
|
||||||
|
expect(text.stdout).toContain("未找到记忆 gone-1");
|
||||||
|
});
|
||||||
|
|
||||||
it("explains an ambiguous id instead of a bare code", async () => {
|
it("explains an ambiguous id instead of a bare code", async () => {
|
||||||
transport.delete.mockResolvedValue({
|
transport.delete.mockResolvedValue({
|
||||||
status: 409,
|
status: 409,
|
||||||
|
|||||||
@@ -36,15 +36,17 @@ const DEFAULTS: Record<string, boolean> = {
|
|||||||
session_episodes: false,
|
session_episodes: false,
|
||||||
episode_summary: false,
|
episode_summary: false,
|
||||||
intention: true,
|
intention: true,
|
||||||
|
intention_dedup: true,
|
||||||
few_shot: false,
|
few_shot: false,
|
||||||
};
|
};
|
||||||
|
const THRESHOLD_DEFAULT = 0.95;
|
||||||
|
|
||||||
function featuresState(
|
function featuresState(
|
||||||
configured: Record<string, boolean> = {},
|
configured: Record<string, boolean | number> = {},
|
||||||
mode = "pro",
|
mode = "pro",
|
||||||
extra: Record<string, unknown> = {},
|
extra: Record<string, unknown> = {},
|
||||||
) {
|
) {
|
||||||
const nonDefault: Record<string, boolean> = {};
|
const nonDefault: Record<string, boolean | number> = {};
|
||||||
const features = Object.entries(DEFAULTS).map(([name, fallback]) => {
|
const features = Object.entries(DEFAULTS).map(([name, fallback]) => {
|
||||||
const enabled = configured[name] ?? fallback;
|
const enabled = configured[name] ?? fallback;
|
||||||
if (enabled !== fallback) nonDefault[name] = enabled;
|
if (enabled !== fallback) nonDefault[name] = enabled;
|
||||||
@@ -70,7 +72,34 @@ function featuresState(
|
|||||||
description: name,
|
description: name,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { mode, features, non_default: nonDefault, ...extra };
|
const threshold =
|
||||||
|
(configured.intention_dedup_threshold as number | undefined) ??
|
||||||
|
THRESHOLD_DEFAULT;
|
||||||
|
if (threshold !== THRESHOLD_DEFAULT)
|
||||||
|
nonDefault.intention_dedup_threshold = threshold;
|
||||||
|
const dedupOn =
|
||||||
|
(configured.intention_dedup as boolean | undefined) ??
|
||||||
|
DEFAULTS.intention_dedup;
|
||||||
|
const parameters = [
|
||||||
|
{
|
||||||
|
name: "intention_dedup_threshold",
|
||||||
|
value: threshold,
|
||||||
|
configured:
|
||||||
|
"intention_dedup_threshold" in configured
|
||||||
|
? configured.intention_dedup_threshold
|
||||||
|
: null,
|
||||||
|
default: THRESHOLD_DEFAULT,
|
||||||
|
minimum: 0.8,
|
||||||
|
maximum: 1,
|
||||||
|
active: dedupOn,
|
||||||
|
reason: dedupOn ? null : "requires_intention_dedup",
|
||||||
|
requires_feature: "intention_dedup",
|
||||||
|
sdk_field: "recall.intention_dedup_threshold",
|
||||||
|
env: "MEMORY_INTENTION_DEDUP_THRESHOLD",
|
||||||
|
description: "threshold",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return { mode, features, parameters, non_default: nonDefault, ...extra };
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -110,11 +139,16 @@ describe("memhub settings features", () => {
|
|||||||
expect(result.code).toBe(0);
|
expect(result.code).toBe(0);
|
||||||
const json = JSON.parse(result.stdout);
|
const json = JSON.parse(result.stdout);
|
||||||
expect(json.state).toBe("ok");
|
expect(json.state).toBe("ok");
|
||||||
expect(json.features.features).toHaveLength(7);
|
expect(json.features.features).toHaveLength(8);
|
||||||
|
expect(json.features.parameters[0].name).toBe("intention_dedup_threshold");
|
||||||
expect(transport.put).not.toHaveBeenCalled();
|
expect(transport.put).not.toHaveBeenCalled();
|
||||||
const text = (await capturedRun(["settings", "features"])).stdout;
|
const text = (await capturedRun(["settings", "features"])).stdout;
|
||||||
expect(text).toContain("graph_rerank:关(默认)");
|
expect(text).toContain("graph_rerank:关(默认)");
|
||||||
expect(text).toContain("intention:开(默认)");
|
expect(text).toContain("intention:开(默认)");
|
||||||
|
expect(text).toContain("intention_dedup:开(默认)");
|
||||||
|
expect(text).toContain(
|
||||||
|
"intention_dedup_threshold:0.95(默认,可设 0.8–1)",
|
||||||
|
);
|
||||||
expect(text).toContain("先开启 session_episodes");
|
expect(text).toContain("先开启 session_episodes");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,6 +194,130 @@ describe("memhub settings features", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("turns intention dedup off and sets its threshold, round trip", async () => {
|
||||||
|
transport.put.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: featuresState({ intention_dedup: false }, "pro", {
|
||||||
|
changed: ["intention_dedup"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const off = await capturedRun([
|
||||||
|
"settings",
|
||||||
|
"features",
|
||||||
|
"set",
|
||||||
|
"intention_dedup=false",
|
||||||
|
"--yes",
|
||||||
|
"--json",
|
||||||
|
]);
|
||||||
|
expect(off.code).toBe(0);
|
||||||
|
expect(transport.put).toHaveBeenLastCalledWith(
|
||||||
|
"/api/v1/settings/memory-features",
|
||||||
|
{ intention_dedup: false },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const offJson = JSON.parse(off.stdout);
|
||||||
|
expect(offJson.state).toBe("committed");
|
||||||
|
expect(offJson.changed).toEqual(["intention_dedup"]);
|
||||||
|
expect(offJson.features.parameters[0].reason).toBe(
|
||||||
|
"requires_intention_dedup",
|
||||||
|
);
|
||||||
|
|
||||||
|
transport.get.mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
data: featuresState({ intention_dedup: false }),
|
||||||
|
});
|
||||||
|
transport.put.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: featuresState(
|
||||||
|
{ intention_dedup: true, intention_dedup_threshold: 0.9 },
|
||||||
|
"pro",
|
||||||
|
{ changed: ["intention_dedup", "intention_dedup_threshold"] },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const on = await capturedRun([
|
||||||
|
"settings",
|
||||||
|
"features",
|
||||||
|
"set",
|
||||||
|
"intention_dedup=true",
|
||||||
|
"intention_dedup_threshold=0.9",
|
||||||
|
"--yes",
|
||||||
|
]);
|
||||||
|
expect(on.code).toBe(0);
|
||||||
|
expect(transport.put).toHaveBeenLastCalledWith(
|
||||||
|
"/api/v1/settings/memory-features",
|
||||||
|
{ intention_dedup: true, intention_dedup_threshold: 0.9 },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(on.stdout).toContain(
|
||||||
|
"已更新:intention_dedup、intention_dedup_threshold",
|
||||||
|
);
|
||||||
|
expect(on.stdout).toContain("intention_dedup_threshold:0.9(已设置");
|
||||||
|
|
||||||
|
transport.get.mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
data: featuresState({ intention_dedup_threshold: 0.9 }),
|
||||||
|
});
|
||||||
|
transport.put.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: featuresState({}, "pro", {
|
||||||
|
changed: ["intention_dedup_threshold"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const reset = await capturedRun([
|
||||||
|
"settings",
|
||||||
|
"features",
|
||||||
|
"reset",
|
||||||
|
"intention_dedup_threshold",
|
||||||
|
]);
|
||||||
|
expect(reset.code).toBe(0);
|
||||||
|
expect(transport.put).toHaveBeenLastCalledWith(
|
||||||
|
"/api/v1/settings/memory-features",
|
||||||
|
{ intention_dedup_threshold: null },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previews a threshold without --yes and refuses one out of range", async () => {
|
||||||
|
const preview = await capturedRun([
|
||||||
|
"settings",
|
||||||
|
"features",
|
||||||
|
"set",
|
||||||
|
"intention_dedup_threshold=0.85",
|
||||||
|
]);
|
||||||
|
expect(preview.code).toBe(0);
|
||||||
|
expect(preview.stdout).toContain("- intention_dedup_threshold = 0.85");
|
||||||
|
for (const value of ["0.5", "0.79", "1.01", "2", "-0.9", "abc", "1e0"]) {
|
||||||
|
const result = await capturedRun([
|
||||||
|
"settings",
|
||||||
|
"features",
|
||||||
|
"set",
|
||||||
|
`intention_dedup_threshold=${value}`,
|
||||||
|
"--yes",
|
||||||
|
]);
|
||||||
|
expect(result.code, value).toBe(2);
|
||||||
|
}
|
||||||
|
expect(transport.put).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send a threshold to a Runtime that does not list it", async () => {
|
||||||
|
const { parameters: _unused, ...older } = featuresState();
|
||||||
|
transport.get.mockResolvedValue({ status: 200, data: older });
|
||||||
|
const result = await capturedRun([
|
||||||
|
"settings",
|
||||||
|
"features",
|
||||||
|
"set",
|
||||||
|
"intention_dedup_threshold=0.9",
|
||||||
|
"--yes",
|
||||||
|
"--json",
|
||||||
|
]);
|
||||||
|
expect(result.code).toBe(1);
|
||||||
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||||
|
state: "rejected",
|
||||||
|
code: "memory_feature_unsupported",
|
||||||
|
});
|
||||||
|
expect(transport.put).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("does nothing when the values are already set", async () => {
|
it("does nothing when the values are already set", async () => {
|
||||||
transport.get.mockResolvedValue({
|
transport.get.mockResolvedValue({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -210,6 +368,8 @@ describe("memhub settings features", () => {
|
|||||||
["settings", "features", "set", "graph_rerank=yes"],
|
["settings", "features", "set", "graph_rerank=yes"],
|
||||||
["settings", "features", "set", "graph_rerank"],
|
["settings", "features", "set", "graph_rerank"],
|
||||||
["settings", "features", "set", "few_shot=true", "few_shot=false"],
|
["settings", "features", "set", "few_shot=true", "few_shot=false"],
|
||||||
|
["settings", "features", "set", "intention_dedup=0.9"],
|
||||||
|
["settings", "features", "set", "intention_dedup_threshold=true"],
|
||||||
["settings", "features", "reset", "nope"],
|
["settings", "features", "reset", "nope"],
|
||||||
["settings", "features", "toggle"],
|
["settings", "features", "toggle"],
|
||||||
]) {
|
]) {
|
||||||
@@ -269,7 +429,11 @@ describe("status pipeline features", () => {
|
|||||||
last_evolve_at: null,
|
last_evolve_at: null,
|
||||||
graph_build: { state: "idle", users: {} },
|
graph_build: { state: "idle", users: {} },
|
||||||
evolve: { mode: "pro", schedule: null, running: [], users: {} },
|
evolve: { mode: "pro", schedule: null, running: [], users: {} },
|
||||||
features: { episode_recall: true, intention: false },
|
features: {
|
||||||
|
episode_recall: true,
|
||||||
|
intention: false,
|
||||||
|
intention_dedup_threshold: 0.9,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const report = await composeStatusReport({
|
const report = await composeStatusReport({
|
||||||
@@ -284,9 +448,12 @@ describe("status pipeline features", () => {
|
|||||||
expect(report.pipeline?.features).toEqual({
|
expect(report.pipeline?.features).toEqual({
|
||||||
episode_recall: true,
|
episode_recall: true,
|
||||||
intention: false,
|
intention: false,
|
||||||
|
intention_dedup_threshold: 0.9,
|
||||||
});
|
});
|
||||||
const text = renderStatusText(report, "MemHub");
|
const text = renderStatusText(report, "MemHub");
|
||||||
expect(text).toContain("SDK 功能:episode_recall=开、intention=关");
|
expect(text).toContain(
|
||||||
|
"SDK 功能:episode_recall=开、intention=关、intention_dedup_threshold=0.9",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* `memhub settings features [set <name>=<true|false> ... | reset [<name> ...]]`.
|
* `memhub settings features [set <name>=<true|false> ... | reset [<name> ...]]`.
|
||||||
*
|
*
|
||||||
* The SDK's optional retrieval and extraction features (graph rerank, episode
|
* The SDK's optional retrieval and extraction features (graph rerank, episode
|
||||||
* recall, the episode write side, intention, few-shot). One global setting in
|
* recall, the episode write side, intention and its dedup, few-shot), plus the
|
||||||
|
* intention dedup threshold, the one numeric setting. One global setting in
|
||||||
* the Runtime; an unset feature keeps the SDK default, so an install nobody
|
* the Runtime; an unset feature keeps the SDK default, so an install nobody
|
||||||
* touched behaves as before. `set` prints its plan first and needs `--yes`,
|
* touched behaves as before. `set` prints its plan first and needs `--yes`,
|
||||||
* like `settings mode`, because some features spend model calls; `reset`
|
* like `settings mode`, because some features spend model calls; `reset`
|
||||||
@@ -20,10 +21,19 @@ export const MEMORY_FEATURES = [
|
|||||||
"session_episodes",
|
"session_episodes",
|
||||||
"episode_summary",
|
"episode_summary",
|
||||||
"intention",
|
"intention",
|
||||||
|
"intention_dedup",
|
||||||
"few_shot",
|
"few_shot",
|
||||||
] as const;
|
] as const;
|
||||||
export type MemoryFeature = (typeof MEMORY_FEATURES)[number];
|
export type MemoryFeature = (typeof MEMORY_FEATURES)[number];
|
||||||
|
|
||||||
|
/** Numeric settings, with the range the Runtime accepts (inclusive). */
|
||||||
|
export const MEMORY_PARAMETERS = {
|
||||||
|
intention_dedup_threshold: { minimum: 0.8, maximum: 1 },
|
||||||
|
} as const;
|
||||||
|
export type MemoryParameter = keyof typeof MEMORY_PARAMETERS;
|
||||||
|
export type MemorySetting = MemoryFeature | MemoryParameter;
|
||||||
|
type SettingValues = Partial<Record<MemorySetting, boolean | number>>;
|
||||||
|
|
||||||
const FEATURE_LABEL: Record<MemoryFeature, string> = {
|
const FEATURE_LABEL: Record<MemoryFeature, string> = {
|
||||||
graph_rerank: "图谱重排(沿知识图谱的边传播支持度;仅 ultra)",
|
graph_rerank: "图谱重排(沿知识图谱的边传播支持度;仅 ultra)",
|
||||||
same_series_timeline:
|
same_series_timeline:
|
||||||
@@ -33,6 +43,8 @@ const FEATURE_LABEL: Record<MemoryFeature, string> = {
|
|||||||
session_episodes: "片段写入(同一会话提取的记忆归入同一片段)",
|
session_episodes: "片段写入(同一会话提取的记忆归入同一片段)",
|
||||||
episode_summary: "片段摘要(片段结束时总结一次;每个片段一次模型调用)",
|
episode_summary: "片段摘要(片段结束时总结一次;每个片段一次模型调用)",
|
||||||
intention: "意图提取(随事实一起提取用户的计划与意图)",
|
intention: "意图提取(随事实一起提取用户的计划与意图)",
|
||||||
|
intention_dedup:
|
||||||
|
"意图判重(检索时把同一天、相似度达到阈值的意图视为重复,只返回最早一条并删除其余;关闭则不判重、不删除)",
|
||||||
few_shot: "提取示例(在提取提示里附上示例;不加调用,输入更长)",
|
few_shot: "提取示例(在提取提示里附上示例;不加调用,输入更长)",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,21 +53,42 @@ const REASON_TEXT: Record<string, string> = {
|
|||||||
requires_pro: "lite 模式下不生效(需要 pro 或 ultra)",
|
requires_pro: "lite 模式下不生效(需要 pro 或 ultra)",
|
||||||
requires_ultra: "当前模式下不生效(需要 ultra)",
|
requires_ultra: "当前模式下不生效(需要 ultra)",
|
||||||
requires_graph_rerank: "需要先开启并生效 graph_rerank",
|
requires_graph_rerank: "需要先开启并生效 graph_rerank",
|
||||||
|
requires_intention_dedup: "intention_dedup 已关闭,阈值不起作用",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARAMETER_LABEL: Record<MemoryParameter, string> = {
|
||||||
|
intention_dedup_threshold: "意图判重阈值(余弦相似度,越高保留越多)",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FeaturesCommand =
|
export type FeaturesCommand =
|
||||||
| { kind: "show" }
|
| { kind: "show" }
|
||||||
| {
|
| {
|
||||||
kind: "set";
|
kind: "set";
|
||||||
values: Partial<Record<MemoryFeature, boolean>>;
|
values: SettingValues;
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
}
|
}
|
||||||
| { kind: "reset"; names: MemoryFeature[] };
|
| { kind: "reset"; names: MemorySetting[] };
|
||||||
|
|
||||||
function isFeature(value: string): value is MemoryFeature {
|
function isFeature(value: string): value is MemoryFeature {
|
||||||
return (MEMORY_FEATURES as readonly string[]).includes(value);
|
return (MEMORY_FEATURES as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isParameter(value: string): value is MemoryParameter {
|
||||||
|
return Object.hasOwn(MEMORY_PARAMETERS, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSetting(value: string): value is MemorySetting {
|
||||||
|
return isFeature(value) || isParameter(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A parameter's value: a plain decimal in its range; null = usage. */
|
||||||
|
function parameterValue(name: MemoryParameter, raw: string): number | null {
|
||||||
|
if (!/^\d+(\.\d+)?$/.test(raw)) return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
const { minimum, maximum } = MEMORY_PARAMETERS[name];
|
||||||
|
return value >= minimum && value <= maximum ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Arguments after `settings features`; null = usage. */
|
/** Arguments after `settings features`; null = usage. */
|
||||||
export function parseFeaturesCommand(
|
export function parseFeaturesCommand(
|
||||||
values: readonly string[],
|
values: readonly string[],
|
||||||
@@ -68,18 +101,28 @@ export function parseFeaturesCommand(
|
|||||||
const positional = tail.filter((value) => value !== "--yes");
|
const positional = tail.filter((value) => value !== "--yes");
|
||||||
if (verb === "set") {
|
if (verb === "set") {
|
||||||
if (positional.length === 0) return null;
|
if (positional.length === 0) return null;
|
||||||
const parsed: Partial<Record<MemoryFeature, boolean>> = {};
|
const parsed: SettingValues = {};
|
||||||
for (const item of positional) {
|
for (const item of positional) {
|
||||||
const match = /^([a-z_]+)=(true|false)$/.exec(item);
|
const match = /^([a-z_]+)=(.+)$/.exec(item);
|
||||||
if (!match || !isFeature(match[1]) || match[1] in parsed) return null;
|
if (!match || match[1] in parsed) return null;
|
||||||
parsed[match[1]] = match[2] === "true";
|
const [, name, raw] = match;
|
||||||
|
if (isFeature(name)) {
|
||||||
|
if (raw !== "true" && raw !== "false") return null;
|
||||||
|
parsed[name] = raw === "true";
|
||||||
|
} else if (isParameter(name)) {
|
||||||
|
const value = parameterValue(name, raw);
|
||||||
|
if (value === null) return null;
|
||||||
|
parsed[name] = value;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { kind: "set", values: parsed, confirmed: yes === 1 };
|
return { kind: "set", values: parsed, confirmed: yes === 1 };
|
||||||
}
|
}
|
||||||
if (verb === "reset") {
|
if (verb === "reset") {
|
||||||
if (new Set(positional).size !== positional.length) return null;
|
if (new Set(positional).size !== positional.length) return null;
|
||||||
if (!positional.every(isFeature)) return null;
|
if (!positional.every(isSetting)) return null;
|
||||||
return { kind: "reset", names: positional as MemoryFeature[] };
|
return { kind: "reset", names: positional as MemorySetting[] };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -96,11 +139,26 @@ export interface FeatureState {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParameterState {
|
||||||
|
name: MemoryParameter;
|
||||||
|
value: number;
|
||||||
|
configured: number | null;
|
||||||
|
default: number;
|
||||||
|
minimum: number;
|
||||||
|
maximum: number;
|
||||||
|
active: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
requires_feature: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FeaturesState {
|
export interface FeaturesState {
|
||||||
mode: string;
|
mode: string;
|
||||||
features: FeatureState[];
|
features: FeatureState[];
|
||||||
non_default: Partial<Record<MemoryFeature, boolean>>;
|
/** Absent from a Runtime older than the intention dedup threshold. */
|
||||||
changed?: MemoryFeature[];
|
parameters?: ParameterState[];
|
||||||
|
non_default: SettingValues;
|
||||||
|
changed?: MemorySetting[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function featuresState(value: unknown): FeaturesState | null {
|
function featuresState(value: unknown): FeaturesState | null {
|
||||||
@@ -125,6 +183,20 @@ function featuresState(value: unknown): FeaturesState | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (item.parameters !== undefined) {
|
||||||
|
if (!Array.isArray(item.parameters)) return null;
|
||||||
|
for (const parameter of item.parameters) {
|
||||||
|
if (
|
||||||
|
!parameter ||
|
||||||
|
typeof parameter !== "object" ||
|
||||||
|
typeof (parameter as ParameterState).name !== "string" ||
|
||||||
|
typeof (parameter as ParameterState).value !== "number" ||
|
||||||
|
typeof (parameter as ParameterState).active !== "boolean"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return item as unknown as FeaturesState;
|
return item as unknown as FeaturesState;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,12 +209,12 @@ export type FeaturesDocument = {
|
|||||||
| {
|
| {
|
||||||
state: "preview";
|
state: "preview";
|
||||||
features: FeaturesState;
|
features: FeaturesState;
|
||||||
target: Partial<Record<MemoryFeature, boolean>>;
|
target: SettingValues;
|
||||||
applied: false;
|
applied: false;
|
||||||
next_step: string;
|
next_step: string;
|
||||||
}
|
}
|
||||||
| { state: "unchanged"; features: FeaturesState }
|
| { state: "unchanged"; features: FeaturesState }
|
||||||
| { state: "committed"; features: FeaturesState; changed: MemoryFeature[] }
|
| { state: "committed"; features: FeaturesState; changed: MemorySetting[] }
|
||||||
| { state: "rejected" | "unknown" | "unavailable"; code: string }
|
| { state: "rejected" | "unknown" | "unavailable"; code: string }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -170,13 +242,23 @@ export async function runFeaturesCommand(
|
|||||||
if (command.kind === "show") {
|
if (command.kind === "show") {
|
||||||
return { ...BASE, state: "ok", features: current };
|
return { ...BASE, state: "ok", features: current };
|
||||||
}
|
}
|
||||||
|
// What a person set, per name this Runtime lists; null = the default.
|
||||||
|
const configured = new Map<string, boolean | number | null>([
|
||||||
|
...current.features.map(
|
||||||
|
(feature) => [feature.name, feature.configured] as const,
|
||||||
|
),
|
||||||
|
...(current.parameters ?? []).map(
|
||||||
|
(parameter) => [parameter.name, parameter.configured] as const,
|
||||||
|
),
|
||||||
|
]);
|
||||||
let body: Record<string, unknown>;
|
let body: Record<string, unknown>;
|
||||||
if (command.kind === "set") {
|
if (command.kind === "set") {
|
||||||
const configured = new Map(
|
if (Object.keys(command.values).some((name) => !configured.has(name))) {
|
||||||
current.features.map((feature) => [feature.name, feature.configured]),
|
// An older Runtime does not know the name; it would answer 422.
|
||||||
);
|
return { ...BASE, state: "rejected", code: "memory_feature_unsupported" };
|
||||||
|
}
|
||||||
const differs = Object.entries(command.values).some(
|
const differs = Object.entries(command.values).some(
|
||||||
([name, value]) => configured.get(name as MemoryFeature) !== value,
|
([name, value]) => configured.get(name) !== value,
|
||||||
);
|
);
|
||||||
if (!differs) return { ...BASE, state: "unchanged", features: current };
|
if (!differs) return { ...BASE, state: "unchanged", features: current };
|
||||||
if (!command.confirmed) {
|
if (!command.confirmed) {
|
||||||
@@ -197,16 +279,10 @@ export async function runFeaturesCommand(
|
|||||||
const names =
|
const names =
|
||||||
command.names.length > 0
|
command.names.length > 0
|
||||||
? command.names
|
? command.names
|
||||||
: current.features
|
: [...configured]
|
||||||
.filter((feature) => feature.configured !== null)
|
.filter(([, value]) => value !== null)
|
||||||
.map((feature) => feature.name);
|
.map(([name]) => name as MemorySetting);
|
||||||
if (
|
if (names.every((name) => (configured.get(name) ?? null) === null)) {
|
||||||
names.every(
|
|
||||||
(name) =>
|
|
||||||
current.features.find((feature) => feature.name === name)
|
|
||||||
?.configured === null,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return { ...BASE, state: "unchanged", features: current };
|
return { ...BASE, state: "unchanged", features: current };
|
||||||
}
|
}
|
||||||
body = Object.fromEntries(names.map((name) => [name, null]));
|
body = Object.fromEntries(names.map((name) => [name, null]));
|
||||||
@@ -233,6 +309,16 @@ export function featuresExitCode(document: FeaturesDocument): number {
|
|||||||
: 1;
|
: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parameterLine(parameter: ParameterState): string {
|
||||||
|
const label = PARAMETER_LABEL[parameter.name] ?? parameter.name;
|
||||||
|
const origin = parameter.configured === null ? "默认" : "已设置";
|
||||||
|
const activity =
|
||||||
|
!parameter.active && parameter.reason
|
||||||
|
? `,${REASON_TEXT[parameter.reason] ?? parameter.reason}`
|
||||||
|
: "";
|
||||||
|
return `${parameter.name}:${parameter.value}(${origin}${activity},可设 ${parameter.minimum}–${parameter.maximum})— ${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
function featureLine(feature: FeatureState): string {
|
function featureLine(feature: FeatureState): string {
|
||||||
const label = FEATURE_LABEL[feature.name] ?? feature.name;
|
const label = FEATURE_LABEL[feature.name] ?? feature.name;
|
||||||
const value = feature.enabled ? "开" : "关";
|
const value = feature.enabled ? "开" : "关";
|
||||||
@@ -251,6 +337,8 @@ export function renderFeaturesText(document: FeaturesDocument): string {
|
|||||||
const state = document.features;
|
const state = document.features;
|
||||||
const lines = [`当前模式:${state.mode}`];
|
const lines = [`当前模式:${state.mode}`];
|
||||||
for (const feature of state.features) lines.push(featureLine(feature));
|
for (const feature of state.features) lines.push(featureLine(feature));
|
||||||
|
for (const parameter of state.parameters ?? [])
|
||||||
|
lines.push(parameterLine(parameter));
|
||||||
lines.push(
|
lines.push(
|
||||||
"片段召回(episode_recall)只对带片段的记忆有用:先开启 session_episodes,之后提取的记忆才会带片段。",
|
"片段召回(episode_recall)只对带片段的记忆有用:先开启 session_episodes,之后提取的记忆才会带片段。",
|
||||||
);
|
);
|
||||||
@@ -258,7 +346,9 @@ export function renderFeaturesText(document: FeaturesDocument): string {
|
|||||||
if (document.state === "preview") {
|
if (document.state === "preview") {
|
||||||
lines.push("将设置:");
|
lines.push("将设置:");
|
||||||
for (const [name, value] of Object.entries(document.target)) {
|
for (const [name, value] of Object.entries(document.target)) {
|
||||||
lines.push(`- ${name} = ${value ? "开" : "关"}`);
|
lines.push(
|
||||||
|
`- ${name} = ${typeof value === "number" ? value : value ? "开" : "关"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
lines.push(`未执行任何改动。确认后运行:${document.next_step}`);
|
lines.push(`未执行任何改动。确认后运行:${document.next_step}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function languageState(uiLanguage: string | null, extra = {}) {
|
function languageState(uiLanguage: string | null, extra = {}) {
|
||||||
const sdk =
|
const sdk = uiLanguage === null ? "auto" : uiLanguage;
|
||||||
uiLanguage === null || uiLanguage === "zh-Hans" ? "auto" : uiLanguage;
|
|
||||||
return {
|
return {
|
||||||
ui_language: uiLanguage,
|
ui_language: uiLanguage,
|
||||||
sdk_language: sdk,
|
sdk_language: sdk,
|
||||||
@@ -95,7 +94,7 @@ describe("memhub settings language", () => {
|
|||||||
});
|
});
|
||||||
expect(transport.put).not.toHaveBeenCalled();
|
expect(transport.put).not.toHaveBeenCalled();
|
||||||
const text = (await capturedRun(["settings", "language"])).stdout;
|
const text = (await capturedRun(["settings", "language"])).stdout;
|
||||||
expect(text).toContain("界面语言:跟随系统(未选择)");
|
expect(text).toContain("界面语言:未设置(SDK 自动识别)");
|
||||||
expect(text).toContain("SDK 提示语言:auto");
|
expect(text).toContain("SDK 提示语言:auto");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
* `memhub settings language [<code>|system]`.
|
* `memhub settings language [<code>|system]`.
|
||||||
*
|
*
|
||||||
* The display language the App reports to the Runtime (`ui_language`), and
|
* The display language the App reports to the Runtime (`ui_language`), and
|
||||||
* the SDK prompt language it implies. zh-Hans and "system" (no explicit
|
* the SDK prompt language it implies. Every code, zh-Hans and en included,
|
||||||
* choice) pass no language, so the SDK keeps detecting Chinese or English as
|
* makes extraction and the other SDK prompts use that language; "system"
|
||||||
* before; every other code, en included, makes extraction and the other SDK
|
* (no language set) passes none, so the SDK keeps detecting Chinese or
|
||||||
* prompts use that language. A change applies at once: a write in flight
|
* English as before. The App sends the language it shows, so it is only
|
||||||
|
* unset on an install without the App. A change applies at once: a write in flight
|
||||||
* finishes in the old language, the next ones use the new one. The App sends
|
* finishes in the old language, the next ones use the new one. The App sends
|
||||||
* its own choice again whenever it changes there.
|
* its own choice again whenever it changes there.
|
||||||
*/
|
*/
|
||||||
@@ -162,14 +163,14 @@ export function renderLanguageText(document: LanguageDocument): string {
|
|||||||
const lines = [
|
const lines = [
|
||||||
`界面语言:${
|
`界面语言:${
|
||||||
state.ui_language === null
|
state.ui_language === null
|
||||||
? "跟随系统(未选择)"
|
? "未设置(SDK 自动识别)"
|
||||||
: `${state.ui_language}(${NAMES[state.ui_language]})`
|
: `${state.ui_language}(${NAMES[state.ui_language]})`
|
||||||
}`,
|
}`,
|
||||||
`SDK 提示语言:${sdkText(state.sdk_language)}`,
|
`SDK 提示语言:${sdkText(state.sdk_language)}`,
|
||||||
];
|
];
|
||||||
if (document.state === "ok") {
|
if (document.state === "ok") {
|
||||||
lines.push(
|
lines.push(
|
||||||
`可选:${UI_LANGUAGES.join("、")}、system。简体中文和 system 不指定语言,其余语言(包括 en)会让 SDK 使用该语言。`,
|
`可选:${UI_LANGUAGES.join("、")}、system。任一语言(包括 zh-Hans、en)都会让 SDK 使用该语言;system 不指定语言,SDK 自动识别中文或英文。`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (document.state === "unchanged") lines.push("没有需要改动的设置。");
|
if (document.state === "unchanged") lines.push("没有需要改动的设置。");
|
||||||
|
|||||||
@@ -248,6 +248,10 @@ function addReceipt(receipt: MemoryAddReceipt): Record<string, unknown> {
|
|||||||
task_id: receipt.task_id,
|
task_id: receipt.task_id,
|
||||||
memory_ids: receipt.memory_ids,
|
memory_ids: receipt.memory_ids,
|
||||||
error: receipt.error,
|
error: receipt.error,
|
||||||
|
error_code:
|
||||||
|
typeof receipt.error_code === "string" ? receipt.error_code : null,
|
||||||
|
next_retry_at:
|
||||||
|
typeof receipt.next_retry_at === "string" ? receipt.next_retry_at : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +351,12 @@ export function renderMemoryAddText(document: MemoryAddDocument): string {
|
|||||||
...((document.receipt.memory_ids as string[]).length > 0
|
...((document.receipt.memory_ids as string[]).length > 0
|
||||||
? [`memory_ids: ${(document.receipt.memory_ids as string[]).join(",")}`]
|
? [`memory_ids: ${(document.receipt.memory_ids as string[]).join(",")}`]
|
||||||
: []),
|
: []),
|
||||||
|
...(document.receipt.error_code
|
||||||
|
? [`error_code: ${document.receipt.error_code}`]
|
||||||
|
: []),
|
||||||
|
...(document.receipt.next_retry_at
|
||||||
|
? [`next_retry_at: ${document.receipt.next_retry_at}`]
|
||||||
|
: []),
|
||||||
document.command === "memories add"
|
document.command === "memories add"
|
||||||
? `查询进度:memhub memories add-status ${document.request_id};重试请带上同一个 --request-id,不会重复添加。`
|
? `查询进度:memhub memories add-status ${document.request_id};重试请带上同一个 --request-id,不会重复添加。`
|
||||||
: "",
|
: "",
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ const CORE = new Map<string, { status: number; data: unknown }>([
|
|||||||
blocked_stages: [
|
blocked_stages: [
|
||||||
{ kind: "extract", reason: "model_missing", count: 2 },
|
{ kind: "extract", reason: "model_missing", count: 2 },
|
||||||
],
|
],
|
||||||
|
pipeline: { mode: "pro", configured_mode: "pro" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -202,7 +203,9 @@ const READ_ONLY_PATHS = [
|
|||||||
"/api/v1/live-sync",
|
"/api/v1/live-sync",
|
||||||
"/api/v1/memory-processing",
|
"/api/v1/memory-processing",
|
||||||
"/api/v1/model-profiles",
|
"/api/v1/model-profiles",
|
||||||
|
"/api/v2/home-projection",
|
||||||
"/api/v2/home-summary",
|
"/api/v2/home-summary",
|
||||||
|
"/api/v2/raw",
|
||||||
"/api/v2/memory-library",
|
"/api/v2/memory-library",
|
||||||
"/api/v2/memories/",
|
"/api/v2/memories/",
|
||||||
"/api/v2/user-profile",
|
"/api/v2/user-profile",
|
||||||
@@ -257,6 +260,9 @@ describe("CLI-20 status summary", () => {
|
|||||||
expect(report.sections.model).toMatchObject({ state: "ok", profiles: 1 });
|
expect(report.sections.model).toMatchObject({ state: "ok", profiles: 1 });
|
||||||
expect(report.sections.memories).toMatchObject({
|
expect(report.sections.memories).toMatchObject({
|
||||||
state: "ok",
|
state: "ok",
|
||||||
|
content_plane: "memory",
|
||||||
|
visible_count: 42,
|
||||||
|
effective_mode: "pro",
|
||||||
memory_count: 42,
|
memory_count: 42,
|
||||||
});
|
});
|
||||||
expect(report.summary.state).toBe("ok");
|
expect(report.summary.state).toBe("ok");
|
||||||
@@ -903,7 +909,10 @@ describe("CLI-21 memory reads", () => {
|
|||||||
expect(isReadOnlyPath("/api/v2/memories/ask")).toBe(false);
|
expect(isReadOnlyPath("/api/v2/memories/ask")).toBe(false);
|
||||||
expect(isReadOnlyPath("/api/v2/memories/m1")).toBe(true);
|
expect(isReadOnlyPath("/api/v2/memories/m1")).toBe(true);
|
||||||
expect(isReadOnlyPath("/api/v2/memory-library")).toBe(true);
|
expect(isReadOnlyPath("/api/v2/memory-library")).toBe(true);
|
||||||
|
expect(isReadOnlyPath("/api/v2/home-projection")).toBe(true);
|
||||||
expect(isReadOnlyPath("/api/v2/home-summary")).toBe(true);
|
expect(isReadOnlyPath("/api/v2/home-summary")).toBe(true);
|
||||||
|
expect(isReadOnlyPath("/api/v2/raw")).toBe(true);
|
||||||
|
expect(isReadOnlyPath("/api/v1/settings/memory-mode/plan")).toBe(true);
|
||||||
// The retired v1 data plane is no longer a CLI read.
|
// The retired v1 data plane is no longer a CLI read.
|
||||||
expect(isReadOnlyPath("/api/v1/memories/m1")).toBe(false);
|
expect(isReadOnlyPath("/api/v1/memories/m1")).toBe(false);
|
||||||
expect(isReadOnlyPath("/api/v1/home-summary")).toBe(false);
|
expect(isReadOnlyPath("/api/v1/home-summary")).toBe(false);
|
||||||
@@ -1085,7 +1094,8 @@ describe("malformed success responses", () => {
|
|||||||
|
|
||||||
it("refuses a model profile whose string fields are not strings", async () => {
|
it("refuses a model profile whose string fields are not strings", async () => {
|
||||||
transport.get.mockImplementation(async (url: string) => {
|
transport.get.mockImplementation(async (url: string) => {
|
||||||
if (url !== "/api/v1/model-profiles") return CORE.get(url);
|
if (url !== "/api/v1/model-profiles")
|
||||||
|
return CORE.get(url) ?? { status: 404, data: { detail: "not_found" } };
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
data: [
|
data: [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||||
import { composeDoctorReport, renderDoctorText } from "./doctor-report.js";
|
import { composeDoctorReport, renderDoctorText } from "./doctor-report.js";
|
||||||
import {
|
import {
|
||||||
composeStatusReport,
|
composeStatusReport,
|
||||||
@@ -41,6 +42,108 @@ describe("status default output", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("status on a paused model", () => {
|
||||||
|
const processing = (backoff: {
|
||||||
|
llm_backoff_until: string | null;
|
||||||
|
llm_backoff_error: string | null;
|
||||||
|
}) => ({
|
||||||
|
pending: 4,
|
||||||
|
fresh_pending: 4,
|
||||||
|
backlog_pending: 0,
|
||||||
|
outside_window_pending: 0,
|
||||||
|
running: 0,
|
||||||
|
waiting_for_model: 0,
|
||||||
|
completed: 1,
|
||||||
|
failed: 0,
|
||||||
|
blocked_stages: [],
|
||||||
|
suspended: 2,
|
||||||
|
...backoff,
|
||||||
|
});
|
||||||
|
const reader =
|
||||||
|
(summary: unknown): CoreReader =>
|
||||||
|
async <T>(path: string) =>
|
||||||
|
(path === "/api/v1/memory-processing"
|
||||||
|
? { state: "ok", data: summary }
|
||||||
|
: { state: "unavailable", code: "x" }) as ReadOutcome<T>;
|
||||||
|
const compose = (summary: unknown) =>
|
||||||
|
composeStatusReport({
|
||||||
|
dataDir: "/tmp/isolated/.one-memory",
|
||||||
|
runtime: {
|
||||||
|
state: "running",
|
||||||
|
code: "runtime_verified",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 30001,
|
||||||
|
},
|
||||||
|
background: null,
|
||||||
|
reader: reader(summary),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the code and the retry time while the breaker pauses", async () => {
|
||||||
|
const report = await compose(
|
||||||
|
processing({
|
||||||
|
llm_backoff_until: "2026-11-02T10:00:00+00:00",
|
||||||
|
llm_backoff_error: "model_auth_failed",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(report.sections.processing).toMatchObject({
|
||||||
|
state: "ok",
|
||||||
|
pending: 4,
|
||||||
|
suspended: 2,
|
||||||
|
llm_backoff_until: "2026-11-02T10:00:00+00:00",
|
||||||
|
llm_backoff_error: "model_auth_failed",
|
||||||
|
});
|
||||||
|
const text = renderStatusText(report, "MemHub");
|
||||||
|
expect(text).toContain(
|
||||||
|
"模型暂停:model_auth_failed,将于 2026-11-02T10:00:00+00:00 自动重试",
|
||||||
|
);
|
||||||
|
expect(text).not.toContain("llm_backoff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints no pause line and null fields when nothing is paused", async () => {
|
||||||
|
const report = await compose(
|
||||||
|
processing({ llm_backoff_until: null, llm_backoff_error: null }),
|
||||||
|
);
|
||||||
|
expect(report.sections.processing).toMatchObject({
|
||||||
|
llm_backoff_until: null,
|
||||||
|
llm_backoff_error: null,
|
||||||
|
});
|
||||||
|
expect(renderStatusText(report, "MemHub")).not.toContain("模型暂停");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a restarting write drain and its exit code", async () => {
|
||||||
|
const report = await compose({
|
||||||
|
...processing({ llm_backoff_until: null, llm_backoff_error: null }),
|
||||||
|
write_drain: "restarting",
|
||||||
|
write_drain_error: "operational_error",
|
||||||
|
});
|
||||||
|
expect(report.sections.processing).toMatchObject({
|
||||||
|
write_drain: "restarting",
|
||||||
|
write_drain_error: "operational_error",
|
||||||
|
});
|
||||||
|
const text = renderStatusText(report, "MemHub");
|
||||||
|
expect(text).toContain(
|
||||||
|
"写入队列:异常退出(operational_error),正在自动重启",
|
||||||
|
);
|
||||||
|
expect(text).not.toContain("write_drain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about a running drain and nulls what an older Core omits", async () => {
|
||||||
|
const running = await compose({
|
||||||
|
...processing({ llm_backoff_until: null, llm_backoff_error: null }),
|
||||||
|
write_drain: "running",
|
||||||
|
write_drain_error: null,
|
||||||
|
});
|
||||||
|
expect(renderStatusText(running, "MemHub")).not.toContain("写入队列");
|
||||||
|
const older = await compose(
|
||||||
|
processing({ llm_backoff_until: null, llm_backoff_error: null }),
|
||||||
|
);
|
||||||
|
expect(older.sections.processing).toMatchObject({
|
||||||
|
write_drain: null,
|
||||||
|
write_drain_error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("status and doctor on an unfinished upgrade migration", () => {
|
describe("status and doctor on an unfinished upgrade migration", () => {
|
||||||
it("names the migration log and the issues page", async () => {
|
it("names the migration log and the issues page", async () => {
|
||||||
const report = await composeStatusReport({
|
const report = await composeStatusReport({
|
||||||
@@ -77,3 +180,140 @@ describe("status and doctor on an unfinished upgrade migration", () => {
|
|||||||
expect(report.runtime.log_file).toBeUndefined();
|
expect(report.runtime.log_file).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mode-aware status content projection", () => {
|
||||||
|
const runtime = {
|
||||||
|
state: "running" as const,
|
||||||
|
code: "runtime_verified",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 30001,
|
||||||
|
};
|
||||||
|
const processing = (mode: "lite" | "pro" | "ultra") => ({
|
||||||
|
pending: 0,
|
||||||
|
fresh_pending: 0,
|
||||||
|
backlog_pending: 0,
|
||||||
|
outside_window_pending: 0,
|
||||||
|
running: 0,
|
||||||
|
waiting_for_model: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
pipeline: { mode, configured_mode: mode },
|
||||||
|
});
|
||||||
|
const projection = {
|
||||||
|
projection_version: 1,
|
||||||
|
user_id: "local",
|
||||||
|
effective_mode: "lite",
|
||||||
|
capabilities: {
|
||||||
|
content_plane: "raw",
|
||||||
|
generation_required: false,
|
||||||
|
graph_enabled: false,
|
||||||
|
evolve_available: false,
|
||||||
|
},
|
||||||
|
visible_count: 7,
|
||||||
|
display_state: "excerpt",
|
||||||
|
latest: {
|
||||||
|
type: "raw",
|
||||||
|
raw_id: "raw-1",
|
||||||
|
session_id: "session-1",
|
||||||
|
display_state: "excerpt",
|
||||||
|
time: null,
|
||||||
|
time_basis: null,
|
||||||
|
excerpt: "hello",
|
||||||
|
truncated: false,
|
||||||
|
source_id: "cursor",
|
||||||
|
},
|
||||||
|
observed_at: "2026-11-04T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("uses the projection without reading either legacy count endpoint", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const reader: CoreReader = async <T>(path: string) => {
|
||||||
|
calls.push(path);
|
||||||
|
const data =
|
||||||
|
path === "/api/v2/home-projection"
|
||||||
|
? projection
|
||||||
|
: path === "/api/v1/memory-processing"
|
||||||
|
? processing("lite")
|
||||||
|
: null;
|
||||||
|
return { state: "ok", data } as ReadOutcome<T>;
|
||||||
|
};
|
||||||
|
const report = await composeStatusReport({
|
||||||
|
dataDir: "/tmp/isolated",
|
||||||
|
runtime,
|
||||||
|
background: null,
|
||||||
|
reader,
|
||||||
|
});
|
||||||
|
expect(report.sections.memories).toEqual({
|
||||||
|
state: "ok",
|
||||||
|
content_plane: "raw",
|
||||||
|
visible_count: 7,
|
||||||
|
effective_mode: "lite",
|
||||||
|
raw_count: 7,
|
||||||
|
});
|
||||||
|
expect(calls).not.toContain("/api/v2/raw");
|
||||||
|
expect(calls).not.toContain("/api/v2/home-summary");
|
||||||
|
expect(renderStatusText(report, "MemHub")).toContain(
|
||||||
|
"原始对话:原始对话 7",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the real raw total only for an old Lite Core", async () => {
|
||||||
|
const reader: CoreReader = async <T>(path: string) => {
|
||||||
|
if (path === "/api/v2/home-projection")
|
||||||
|
return { state: "not_found", code: "http_404" } as ReadOutcome<T>;
|
||||||
|
if (path === "/api/v1/memory-processing")
|
||||||
|
return {
|
||||||
|
state: "ok",
|
||||||
|
data: processing("lite"),
|
||||||
|
} as ReadOutcome<T>;
|
||||||
|
if (path === "/api/v2/raw")
|
||||||
|
return {
|
||||||
|
state: "ok",
|
||||||
|
data: {
|
||||||
|
user_id: "local",
|
||||||
|
items: [],
|
||||||
|
total: 9,
|
||||||
|
offset: 0,
|
||||||
|
limit: 1,
|
||||||
|
time_filtered: false,
|
||||||
|
},
|
||||||
|
} as ReadOutcome<T>;
|
||||||
|
return { state: "unavailable", code: "x" } as ReadOutcome<T>;
|
||||||
|
};
|
||||||
|
const report = await composeStatusReport({
|
||||||
|
dataDir: "/tmp/isolated",
|
||||||
|
runtime,
|
||||||
|
background: null,
|
||||||
|
reader,
|
||||||
|
});
|
||||||
|
expect(report.sections.memories).toMatchObject({
|
||||||
|
state: "ok",
|
||||||
|
content_plane: "raw",
|
||||||
|
visible_count: 9,
|
||||||
|
raw_count: 9,
|
||||||
|
});
|
||||||
|
expect(report.sections.memories).not.toHaveProperty("memory_count");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects extra projection fields instead of falling back or leaking them", async () => {
|
||||||
|
const reader: CoreReader = async <T>(path: string) =>
|
||||||
|
({
|
||||||
|
state: "ok",
|
||||||
|
data:
|
||||||
|
path === "/api/v2/home-projection"
|
||||||
|
? { ...projection, secret: "must-not-leak" }
|
||||||
|
: processing("lite"),
|
||||||
|
}) as ReadOutcome<T>;
|
||||||
|
const report = await composeStatusReport({
|
||||||
|
dataDir: "/tmp/isolated",
|
||||||
|
runtime,
|
||||||
|
background: null,
|
||||||
|
reader,
|
||||||
|
});
|
||||||
|
expect(report.sections.memories).toEqual({
|
||||||
|
state: "invalid",
|
||||||
|
code: "response_invalid",
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(report)).not.toContain("must-not-leak");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
HomeProjectionV1,
|
||||||
LiveSyncHealth,
|
LiveSyncHealth,
|
||||||
MemoryProcessingSummary,
|
MemoryProcessingSummary,
|
||||||
ModelProfile,
|
ModelProfile,
|
||||||
@@ -12,10 +13,12 @@ import { sdkMigrationLogPath } from "@one-memory/runtime-control";
|
|||||||
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||||
import {
|
import {
|
||||||
type SdkHomeSummary,
|
type SdkHomeSummary,
|
||||||
|
validateHomeProjection,
|
||||||
validateHomeSummary,
|
validateHomeSummary,
|
||||||
validateLiveSync,
|
validateLiveSync,
|
||||||
validateModelProfiles,
|
validateModelProfiles,
|
||||||
validateProcessing,
|
validateProcessing,
|
||||||
|
validateRawTotal,
|
||||||
validateSources,
|
validateSources,
|
||||||
} from "./core-responses.js";
|
} from "./core-responses.js";
|
||||||
import { describeEmbeddingSwitchSummary } from "./embedding-switch-flow.js";
|
import { describeEmbeddingSwitchSummary } from "./embedding-switch-flow.js";
|
||||||
@@ -85,7 +88,7 @@ export interface PipelineSummary {
|
|||||||
>;
|
>;
|
||||||
graph_build: string | null;
|
graph_build: string | null;
|
||||||
/** SDK features (`memory_features`) whose value differs from the default. */
|
/** SDK features (`memory_features`) whose value differs from the default. */
|
||||||
features: Record<string, boolean>;
|
features: Record<string, boolean | number>;
|
||||||
/** The App's display language; null = never chosen or "follow the system". */
|
/** The App's display language; null = never chosen or "follow the system". */
|
||||||
ui_language: string | null;
|
ui_language: string | null;
|
||||||
/** The SDK prompt language: `auto` (zh/en detection) or the code passed. */
|
/** The SDK prompt language: `auto` (zh/en detection) or the code passed. */
|
||||||
@@ -134,12 +137,13 @@ export function describePipeline(value: unknown): PipelineSummary | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const features: Record<string, boolean> = {};
|
const features: Record<string, boolean | number> = {};
|
||||||
if (item.features && typeof item.features === "object") {
|
if (item.features && typeof item.features === "object") {
|
||||||
for (const [name, value] of Object.entries(
|
for (const [name, value] of Object.entries(
|
||||||
item.features as Record<string, unknown>,
|
item.features as Record<string, unknown>,
|
||||||
)) {
|
)) {
|
||||||
if (typeof value === "boolean") features[name] = value;
|
if (typeof value === "boolean" || typeof value === "number")
|
||||||
|
features[name] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const graph =
|
const graph =
|
||||||
@@ -210,12 +214,12 @@ export async function composeStatusReport(options: {
|
|||||||
for (const name of SECTION_NAMES) sections[name] = { state, code };
|
for (const name of SECTION_NAMES) sections[name] = { state, code };
|
||||||
} else {
|
} else {
|
||||||
const reader = options.reader;
|
const reader = options.reader;
|
||||||
const [sources, sync, processing, model, memories] = await Promise.all([
|
const [sources, sync, processing, model, projection] = await Promise.all([
|
||||||
reader<unknown>("/api/v1/sources"),
|
reader<unknown>("/api/v1/sources"),
|
||||||
reader<unknown>("/api/v1/live-sync"),
|
reader<unknown>("/api/v1/live-sync"),
|
||||||
reader<unknown>("/api/v1/memory-processing"),
|
reader<unknown>("/api/v1/memory-processing"),
|
||||||
reader<unknown>("/api/v1/model-profiles"),
|
reader<unknown>("/api/v1/model-profiles"),
|
||||||
reader<unknown>("/api/v2/home-summary"),
|
reader<unknown>("/api/v2/home-projection"),
|
||||||
]);
|
]);
|
||||||
sections.sources = section(sources, validateSources, describeSources);
|
sections.sources = section(sources, validateSources, describeSources);
|
||||||
sections.sync = section(sync, validateLiveSync, describeSync);
|
sections.sync = section(sync, validateLiveSync, describeSync);
|
||||||
@@ -227,11 +231,7 @@ export async function composeStatusReport(options: {
|
|||||||
pipeline =
|
pipeline =
|
||||||
processing.state === "ok" ? describePipeline(processing.data) : null;
|
processing.state === "ok" ? describePipeline(processing.data) : null;
|
||||||
sections.model = section(model, validateModelProfiles, describeModel);
|
sections.model = section(model, validateModelProfiles, describeModel);
|
||||||
sections.memories = section(
|
sections.memories = await memorySection(reader, projection, pipeline);
|
||||||
memories,
|
|
||||||
validateHomeSummary,
|
|
||||||
describeMemories,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// A bridge we could not inspect is unknown, not broken and not fine.
|
// A bridge we could not inspect is unknown, not broken and not fine.
|
||||||
const bridge: StatusDocument["bridge"] =
|
const bridge: StatusDocument["bridge"] =
|
||||||
@@ -311,7 +311,9 @@ export function renderStatusText(
|
|||||||
];
|
];
|
||||||
for (const name of SECTION_NAMES) {
|
for (const name of SECTION_NAMES) {
|
||||||
lines.push(
|
lines.push(
|
||||||
`${SECTION_LABELS[name]}:${sectionLine(report.sections[name])}`,
|
`${sectionLabel(name, report.sections[name])}:${sectionLine(
|
||||||
|
report.sections[name],
|
||||||
|
)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (report.pipeline) lines.push(`记忆模式:${pipelineLine(report.pipeline)}`);
|
if (report.pipeline) lines.push(`记忆模式:${pipelineLine(report.pipeline)}`);
|
||||||
@@ -319,6 +321,10 @@ export function renderStatusText(
|
|||||||
report.sections.processing?.embedding_switch,
|
report.sections.processing?.embedding_switch,
|
||||||
);
|
);
|
||||||
if (embeddingSwitch !== null) lines.push(`向量模型:${embeddingSwitch}`);
|
if (embeddingSwitch !== null) lines.push(`向量模型:${embeddingSwitch}`);
|
||||||
|
const backoff = modelBackoffLine(report.sections.processing);
|
||||||
|
if (backoff !== null) lines.push(backoff);
|
||||||
|
const drain = writeDrainLine(report.sections.processing);
|
||||||
|
if (drain !== null) lines.push(drain);
|
||||||
if (report.summary.state === "partial") {
|
if (report.summary.state === "partial") {
|
||||||
const notes = [
|
const notes = [
|
||||||
report.summary.degraded.length > 0
|
report.summary.degraded.length > 0
|
||||||
@@ -334,6 +340,35 @@ export function renderStatusText(
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Model paused" while Core's circuit breaker holds model work back; null
|
||||||
|
* otherwise. Nothing to do here: the queue resumes by itself once the model
|
||||||
|
* answers again (or after the model is fixed). */
|
||||||
|
function modelBackoffLine(section: Section | undefined): string | null {
|
||||||
|
if (section?.state !== "ok") return null;
|
||||||
|
const error = section.llm_backoff_error;
|
||||||
|
if (typeof error !== "string" || error.length === 0) return null;
|
||||||
|
const until = section.llm_backoff_until;
|
||||||
|
return `模型暂停:${error},${
|
||||||
|
typeof until === "string" && until.length > 0
|
||||||
|
? `将于 ${until} 自动重试`
|
||||||
|
: "稍后自动重试"
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The durable write drain, when it is not simply running: it exited on an
|
||||||
|
* error and is coming back (`restarting`), or it is not running at all. */
|
||||||
|
function writeDrainLine(section: Section | undefined): string | null {
|
||||||
|
if (section?.state !== "ok") return null;
|
||||||
|
const state = section.write_drain;
|
||||||
|
if (state !== "restarting" && state !== "stopped") return null;
|
||||||
|
const error = section.write_drain_error;
|
||||||
|
const reason =
|
||||||
|
typeof error === "string" && error.length > 0 ? `(${error})` : "";
|
||||||
|
return state === "restarting"
|
||||||
|
? `写入队列:异常退出${reason},正在自动重启`
|
||||||
|
: `写入队列:已停止${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function pipelineLine(pipeline: PipelineSummary): string {
|
export function pipelineLine(pipeline: PipelineSummary): string {
|
||||||
const parts: string[] = [pipeline.mode];
|
const parts: string[] = [pipeline.mode];
|
||||||
if (pipeline.forced_by_env)
|
if (pipeline.forced_by_env)
|
||||||
@@ -357,7 +392,10 @@ export function pipelineLine(pipeline: PipelineSummary): string {
|
|||||||
if (changed.length > 0) {
|
if (changed.length > 0) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`;SDK 功能:${changed
|
`;SDK 功能:${changed
|
||||||
.map(([name, value]) => `${name}=${value ? "开" : "关"}`)
|
.map(
|
||||||
|
([name, value]) =>
|
||||||
|
`${name}=${typeof value === "number" ? value : value ? "开" : "关"}`,
|
||||||
|
)
|
||||||
.join("、")}`,
|
.join("、")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -380,15 +418,33 @@ function runtimeLine(report: StatusDocument): string {
|
|||||||
})`;
|
})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fields that have their own line (or none) instead of a place in the
|
||||||
|
* section's one-line summary. */
|
||||||
|
const SECTION_LINE_OMITTED = new Set([
|
||||||
|
"state",
|
||||||
|
"code",
|
||||||
|
"embedding_switch",
|
||||||
|
"llm_backoff_until",
|
||||||
|
"llm_backoff_error",
|
||||||
|
"write_drain",
|
||||||
|
"write_drain_error",
|
||||||
|
"content_plane",
|
||||||
|
"visible_count",
|
||||||
|
"effective_mode",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sectionLabel(name: string, section: Section | undefined): string {
|
||||||
|
return name === "memories" && section?.content_plane === "raw"
|
||||||
|
? "原始对话"
|
||||||
|
: SECTION_LABELS[name];
|
||||||
|
}
|
||||||
|
|
||||||
function sectionLine(section: Section | undefined): string {
|
function sectionLine(section: Section | undefined): string {
|
||||||
if (!section) return "未检查";
|
if (!section) return "未检查";
|
||||||
if (section.state === "not_checked") return "未检查";
|
if (section.state === "not_checked") return "未检查";
|
||||||
if (section.state !== "ok") return "暂时不可用,请稍后重试";
|
if (section.state !== "ok") return "暂时不可用,请稍后重试";
|
||||||
return Object.entries(section)
|
return Object.entries(section)
|
||||||
.filter(
|
.filter(([key]) => !SECTION_LINE_OMITTED.has(key))
|
||||||
([key]) =>
|
|
||||||
key !== "state" && key !== "code" && key !== "embedding_switch",
|
|
||||||
)
|
|
||||||
.map(([key, value]) => `${fieldLabel(key)} ${formatValue(value)}`)
|
.map(([key, value]) => `${fieldLabel(key)} ${formatValue(value)}`)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
@@ -424,6 +480,7 @@ function fieldLabel(key: string): string {
|
|||||||
backlog_pending: "历史待处理",
|
backlog_pending: "历史待处理",
|
||||||
outside_window_pending: "稍后处理",
|
outside_window_pending: "稍后处理",
|
||||||
waiting_for_model: "等待模型",
|
waiting_for_model: "等待模型",
|
||||||
|
suspended: "来源已暂停",
|
||||||
completed: "已完成",
|
completed: "已完成",
|
||||||
failed: "失败",
|
failed: "失败",
|
||||||
blocked: "卡住",
|
blocked: "卡住",
|
||||||
@@ -433,6 +490,10 @@ function fieldLabel(key: string): string {
|
|||||||
profiles: "配置",
|
profiles: "配置",
|
||||||
active: "当前",
|
active: "当前",
|
||||||
memory_count: "记忆",
|
memory_count: "记忆",
|
||||||
|
raw_count: "原始对话",
|
||||||
|
visible_count: "可见",
|
||||||
|
content_plane: "内容层",
|
||||||
|
effective_mode: "模式",
|
||||||
};
|
};
|
||||||
return labels[key] ?? key;
|
return labels[key] ?? key;
|
||||||
}
|
}
|
||||||
@@ -504,10 +565,47 @@ function describeProcessing(
|
|||||||
reason: block.reason,
|
reason: block.reason,
|
||||||
count: block.count,
|
count: block.count,
|
||||||
})),
|
})),
|
||||||
|
suspended: typeof summary.suspended === "number" ? summary.suspended : 0,
|
||||||
|
...llmBackoffFields(summary),
|
||||||
|
...writeDrainFields(summary),
|
||||||
...embeddingSwitchField(summary),
|
...embeddingSwitchField(summary),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The model circuit breaker: set only while Core pauses model work because
|
||||||
|
* the provider keeps failing. Both null when nothing is paused. */
|
||||||
|
function llmBackoffFields(summary: MemoryProcessingSummary): {
|
||||||
|
llm_backoff_until: string | null;
|
||||||
|
llm_backoff_error: string | null;
|
||||||
|
} {
|
||||||
|
const until = summary.llm_backoff_until;
|
||||||
|
const error = summary.llm_backoff_error;
|
||||||
|
return {
|
||||||
|
llm_backoff_until:
|
||||||
|
typeof until === "string" && until.length <= 64 ? until : null,
|
||||||
|
llm_backoff_error:
|
||||||
|
typeof error === "string" && /^[a-z_]{1,64}$/.test(error) ? error : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Durable write drain liveness. Null when Core does not report it (older
|
||||||
|
* Core, or recovery has not started the drain yet). */
|
||||||
|
function writeDrainFields(summary: MemoryProcessingSummary): {
|
||||||
|
write_drain: "running" | "restarting" | "stopped" | null;
|
||||||
|
write_drain_error: string | null;
|
||||||
|
} {
|
||||||
|
const state = summary.write_drain;
|
||||||
|
const error = summary.write_drain_error;
|
||||||
|
return {
|
||||||
|
write_drain:
|
||||||
|
state === "running" || state === "restarting" || state === "stopped"
|
||||||
|
? state
|
||||||
|
: null,
|
||||||
|
write_drain_error:
|
||||||
|
typeof error === "string" && /^[a-z_]{1,64}$/.test(error) ? error : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The running switch or merge, projected; absent when none runs. */
|
/** The running switch or merge, projected; absent when none runs. */
|
||||||
function embeddingSwitchField(
|
function embeddingSwitchField(
|
||||||
summary: MemoryProcessingSummary,
|
summary: MemoryProcessingSummary,
|
||||||
@@ -548,6 +646,70 @@ function describeMemories(summary: SdkHomeSummary): Record<string, unknown> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function memorySection(
|
||||||
|
reader: CoreReader,
|
||||||
|
projection: ReadOutcome<unknown>,
|
||||||
|
pipeline: PipelineSummary | null,
|
||||||
|
): Promise<Section> {
|
||||||
|
if (projection.state === "ok") {
|
||||||
|
const parsed = validateHomeProjection(projection.data);
|
||||||
|
return parsed === null
|
||||||
|
? { state: "invalid", code: "response_invalid" }
|
||||||
|
: { state: "ok", ...describeProjection(parsed) };
|
||||||
|
}
|
||||||
|
if (projection.state !== "not_found") {
|
||||||
|
return {
|
||||||
|
state: projection.state === "denied" ? "denied" : "unavailable",
|
||||||
|
code: projection.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pipeline === null) {
|
||||||
|
return { state: "unavailable", code: "effective_mode_unavailable" };
|
||||||
|
}
|
||||||
|
if (pipeline.mode === "lite") {
|
||||||
|
const raw = await reader<unknown>("/api/v2/raw", {
|
||||||
|
limit: 1,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
if (raw.state !== "ok") {
|
||||||
|
return {
|
||||||
|
state: raw.state === "denied" ? "denied" : "unavailable",
|
||||||
|
code: raw.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const total = validateRawTotal(raw.data);
|
||||||
|
return total === null
|
||||||
|
? { state: "invalid", code: "response_invalid" }
|
||||||
|
: {
|
||||||
|
state: "ok",
|
||||||
|
content_plane: "raw",
|
||||||
|
visible_count: total,
|
||||||
|
effective_mode: "lite",
|
||||||
|
raw_count: total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const legacy = await reader<unknown>("/api/v2/home-summary");
|
||||||
|
return section(legacy, validateHomeSummary, (summary) => ({
|
||||||
|
content_plane: "memory",
|
||||||
|
visible_count: summary.memory_count,
|
||||||
|
effective_mode: pipeline.mode,
|
||||||
|
...describeMemories(summary),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeProjection(
|
||||||
|
projection: HomeProjectionV1,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
content_plane: projection.capabilities.content_plane,
|
||||||
|
visible_count: projection.visible_count,
|
||||||
|
effective_mode: projection.effective_mode,
|
||||||
|
...(projection.capabilities.content_plane === "raw"
|
||||||
|
? { raw_count: projection.visible_count }
|
||||||
|
: { memory_count: projection.visible_count }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sum(
|
function sum(
|
||||||
sources: SourceResponse[],
|
sources: SourceResponse[],
|
||||||
key: "imported_conversations" | "imported_messages",
|
key: "imported_conversations" | "imported_messages",
|
||||||
|
|||||||
@@ -208,6 +208,27 @@ describe("--user-id on the memory commands", () => {
|
|||||||
expect(direct.get.mock.calls[0][1].params).toEqual({ user_id: "wife" });
|
expect(direct.get.mock.calls[0][1].params).toEqual({ user_id: "wife" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prints the retry code and time of a write still waiting", async () => {
|
||||||
|
direct.get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
request_id: REQUEST,
|
||||||
|
state: "accepted",
|
||||||
|
created_at: "T1",
|
||||||
|
updated_at: "T2",
|
||||||
|
error_code: "model_timeout",
|
||||||
|
next_retry_at: "2026-11-02T10:00:00+00:00",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await capturedRun(["write", "status", REQUEST]);
|
||||||
|
expect(text.stdout).toContain("error_code: model_timeout");
|
||||||
|
expect(text.stdout).toContain("next_retry_at: 2026-11-02T10:00:00+00:00");
|
||||||
|
const json = await capturedRun(["write", "status", REQUEST, "--json"]);
|
||||||
|
expect(JSON.parse(json.stdout).write).toMatchObject({
|
||||||
|
error_code: "model_timeout",
|
||||||
|
next_retry_at: "2026-11-02T10:00:00+00:00",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("passes the user in the body of memory writes and searches", async () => {
|
it("passes the user in the body of memory writes and searches", async () => {
|
||||||
await capturedRun(["memories", "add", "Prefers tea", "--user-id", "wife"]);
|
await capturedRun(["memories", "add", "Prefers tea", "--user-id", "wife"]);
|
||||||
expect(transport.post.mock.calls[0][0]).toBe("/api/v2/memories");
|
expect(transport.post.mock.calls[0][0]).toBe("/api/v2/memories");
|
||||||
@@ -373,6 +394,48 @@ describe("sources connect for a Hermes profile", () => {
|
|||||||
}
|
}
|
||||||
expect(transport.post).not.toHaveBeenCalled();
|
expect(transport.post).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says the user id need not equal the Hermes profile name", async () => {
|
||||||
|
const result = await capturedRun([
|
||||||
|
"sources",
|
||||||
|
"connect",
|
||||||
|
"hermes",
|
||||||
|
"--user-id",
|
||||||
|
"wife:work",
|
||||||
|
"--root",
|
||||||
|
"~/.hermes/profiles/wife:work",
|
||||||
|
]);
|
||||||
|
expect(result.code).toBe(2);
|
||||||
|
expect(result.stderr).toContain("用户 ID 不必等于 Hermes profile 名");
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
"--user-id wife-work --root ~/.hermes/profiles/wife:work",
|
||||||
|
);
|
||||||
|
expect(transport.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains a refused overlapping or profiles-container root", async () => {
|
||||||
|
for (const code of [
|
||||||
|
"hermes_root_is_profiles_container",
|
||||||
|
"hermes_root_overlaps_other_user",
|
||||||
|
]) {
|
||||||
|
transport.post.mockResolvedValueOnce({
|
||||||
|
status: code.endsWith("container") ? 422 : 409,
|
||||||
|
data: { detail: code },
|
||||||
|
});
|
||||||
|
const result = await capturedRun([
|
||||||
|
"sources",
|
||||||
|
"connect",
|
||||||
|
"hermes",
|
||||||
|
"--user-id",
|
||||||
|
"kid",
|
||||||
|
"--root",
|
||||||
|
"/profiles",
|
||||||
|
]);
|
||||||
|
expect(result.code).toBe(1);
|
||||||
|
expect(result.stdout).toContain(code);
|
||||||
|
expect(result.stdout).toContain("--root");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function capturedRun(arguments_: string[]) {
|
async function capturedRun(arguments_: string[]) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ dependencies = [
|
|||||||
"fastapi>=0.141.1",
|
"fastapi>=0.141.1",
|
||||||
"filelock>=3.32.4",
|
"filelock>=3.32.4",
|
||||||
# Public SDK version validated against MemHub's permission and recall contracts.
|
# Public SDK version validated against MemHub's permission and recall contracts.
|
||||||
"hy-memory==2.0.11",
|
"hy-memory==2.0.14",
|
||||||
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
|
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
|
||||||
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
|
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
|
||||||
"chromadb==1.5.9",
|
"chromadb==1.5.9",
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,20 @@ BULK_WORKERS = 3
|
|||||||
SNAPSHOT_WORKERS = 8
|
SNAPSHOT_WORKERS = 8
|
||||||
INTERACTIVE_READ_WORKERS = 4
|
INTERACTIVE_READ_WORKERS = 4
|
||||||
SOURCE_READER_WORKERS = 4
|
SOURCE_READER_WORKERS = 4
|
||||||
|
# One per source the drain runs side by side (`max_concurrent_sources`), and
|
||||||
|
# two over for the outcome inquiries that follow them.
|
||||||
|
SDK_WRITE_WORKERS = 12
|
||||||
|
# Recall's SDK reads: one search and the `get`s behind it (`_SDK_GET_CONCURRENCY`).
|
||||||
|
SDK_READ_WORKERS = 8
|
||||||
|
# The durable write queue's own bookkeeping: claim, settle, the per-job reads
|
||||||
|
# around an SDK write, and the drain's maintenance passes. Each is one short
|
||||||
|
# statement on the single writer or a reader; the drain runs at most
|
||||||
|
# `max_concurrent_sources` jobs, and they wait for the writer, not for CPU.
|
||||||
|
DURABLE_QUEUE_WORKERS = 4
|
||||||
|
# Lease renewal alone. Renewal is the drain's ownership fence: a lease that
|
||||||
|
# lapses while its SDK write is still running hands the job to recovery, so it
|
||||||
|
# must never wait for a thread behind any other work, its own queue's included.
|
||||||
|
LEASE_RENEWAL_WORKERS = 2
|
||||||
|
|
||||||
_pool: ThreadPoolExecutor | None = None
|
_pool: ThreadPoolExecutor | None = None
|
||||||
_snapshot_pool: ThreadPoolExecutor | None = None
|
_snapshot_pool: ThreadPoolExecutor | None = None
|
||||||
@@ -22,6 +36,9 @@ _interactive_pool: ThreadPoolExecutor | None = None
|
|||||||
_raw_read_pool: ThreadPoolExecutor | None = None
|
_raw_read_pool: ThreadPoolExecutor | None = None
|
||||||
_memory_read_pool: ThreadPoolExecutor | None = None
|
_memory_read_pool: ThreadPoolExecutor | None = None
|
||||||
_source_reader_pool: ThreadPoolExecutor | None = None
|
_source_reader_pool: ThreadPoolExecutor | None = None
|
||||||
|
_sdk_write_pool: ThreadPoolExecutor | None = None
|
||||||
|
_durable_queue_pool: ThreadPoolExecutor | None = None
|
||||||
|
_lease_renewal_pool: ThreadPoolExecutor | None = None
|
||||||
_warmed_readers: weakref.WeakSet[ThreadPoolExecutor] = weakref.WeakSet()
|
_warmed_readers: weakref.WeakSet[ThreadPoolExecutor] = weakref.WeakSet()
|
||||||
_reader_warm_lock = threading.Lock()
|
_reader_warm_lock = threading.Lock()
|
||||||
_interactive_reads = 0
|
_interactive_reads = 0
|
||||||
@@ -71,15 +88,67 @@ def raw_read_pool() -> ThreadPoolExecutor:
|
|||||||
|
|
||||||
|
|
||||||
def memory_read_pool() -> ThreadPoolExecutor:
|
def memory_read_pool() -> ThreadPoolExecutor:
|
||||||
|
"""Recall's own threads: SDK searches, gets, and what decides between them.
|
||||||
|
|
||||||
|
Recall used to hand these to the default pool, which SDK writes share. A
|
||||||
|
write that times out keeps its thread until the SDK returns (#231), so a
|
||||||
|
few stuck writes left recall waiting for a thread while `/health`, which
|
||||||
|
needs none, still answered.
|
||||||
|
"""
|
||||||
global _memory_read_pool
|
global _memory_read_pool
|
||||||
if _memory_read_pool is None:
|
if _memory_read_pool is None:
|
||||||
_memory_read_pool = ThreadPoolExecutor(
|
_memory_read_pool = ThreadPoolExecutor(
|
||||||
max_workers=INTERACTIVE_READ_WORKERS,
|
max_workers=SDK_READ_WORKERS,
|
||||||
thread_name_prefix="one-memory-memory-read",
|
thread_name_prefix="one-memory-memory-read",
|
||||||
)
|
)
|
||||||
return _memory_read_pool
|
return _memory_read_pool
|
||||||
|
|
||||||
|
|
||||||
|
def sdk_write_pool() -> ThreadPoolExecutor:
|
||||||
|
"""SDK writes, bounded and apart from everything else.
|
||||||
|
|
||||||
|
Each thread here only waits for the SDK's own loop thread to answer
|
||||||
|
(`_LoopThread.run`), so any thread outside that loop may make the call; a
|
||||||
|
stuck write therefore costs a thread of this pool and nobody else's.
|
||||||
|
"""
|
||||||
|
global _sdk_write_pool
|
||||||
|
if _sdk_write_pool is None:
|
||||||
|
_sdk_write_pool = ThreadPoolExecutor(
|
||||||
|
max_workers=SDK_WRITE_WORKERS,
|
||||||
|
thread_name_prefix="one-memory-sdk-write",
|
||||||
|
)
|
||||||
|
return _sdk_write_pool
|
||||||
|
|
||||||
|
|
||||||
|
def durable_queue_pool() -> ThreadPoolExecutor:
|
||||||
|
"""The durable write queue's database work, apart from bulk work.
|
||||||
|
|
||||||
|
The queue used to hand its claims, settles and maintenance to the bulk
|
||||||
|
pool, whose three threads also carry import heartbeats and job listings.
|
||||||
|
Under an import peak all three sat in those for tens of seconds, and the
|
||||||
|
queue's next statement waited for a thread before it could even join the
|
||||||
|
writer's line, and leases lapsed while their SDK writes were still running.
|
||||||
|
"""
|
||||||
|
global _durable_queue_pool
|
||||||
|
if _durable_queue_pool is None:
|
||||||
|
_durable_queue_pool = ThreadPoolExecutor(
|
||||||
|
max_workers=DURABLE_QUEUE_WORKERS,
|
||||||
|
thread_name_prefix="one-memory-write-queue",
|
||||||
|
)
|
||||||
|
return _durable_queue_pool
|
||||||
|
|
||||||
|
|
||||||
|
def lease_renewal_pool() -> ThreadPoolExecutor:
|
||||||
|
"""Threads that only renew durable write leases (`DurableWriteDrain.renew`)."""
|
||||||
|
global _lease_renewal_pool
|
||||||
|
if _lease_renewal_pool is None:
|
||||||
|
_lease_renewal_pool = ThreadPoolExecutor(
|
||||||
|
max_workers=LEASE_RENEWAL_WORKERS,
|
||||||
|
thread_name_prefix="one-memory-lease-renewal",
|
||||||
|
)
|
||||||
|
return _lease_renewal_pool
|
||||||
|
|
||||||
|
|
||||||
def source_reader_pool() -> ThreadPoolExecutor:
|
def source_reader_pool() -> ThreadPoolExecutor:
|
||||||
"""Bounded process-wide pool for source iteration and parsing."""
|
"""Bounded process-wide pool for source iteration and parsing."""
|
||||||
global _source_reader_pool
|
global _source_reader_pool
|
||||||
@@ -99,9 +168,21 @@ def warm_bulk_pool() -> None:
|
|||||||
Starting a thread is nothing on an idle machine and 2.5 seconds on a busy
|
Starting a thread is nothing on an idle machine and 2.5 seconds on a busy
|
||||||
one, which is precisely when bulk work is being handed over. So it is
|
one, which is precisely when bulk work is being handed over. So it is
|
||||||
done here instead, at startup, before there is anything to be late for.
|
done here instead, at startup, before there is anything to be late for.
|
||||||
|
|
||||||
|
The durable write queue's pools are warmed here too, for the same reason:
|
||||||
|
their first renewal must not pay for starting a thread.
|
||||||
"""
|
"""
|
||||||
pool = bulk_pool()
|
_start_every_worker(bulk_pool(), BULK_WORKERS, "bulk_pool_warmup_timeout")
|
||||||
started = tuple(threading.Event() for _ in range(BULK_WORKERS))
|
_start_every_worker(
|
||||||
|
durable_queue_pool(), DURABLE_QUEUE_WORKERS, "durable_queue_pool_warmup_timeout"
|
||||||
|
)
|
||||||
|
_start_every_worker(
|
||||||
|
lease_renewal_pool(), LEASE_RENEWAL_WORKERS, "lease_renewal_pool_warmup_timeout"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_every_worker(pool: ThreadPoolExecutor, workers: int, timeout_code: str) -> None:
|
||||||
|
started = tuple(threading.Event() for _ in range(workers))
|
||||||
release = threading.Event()
|
release = threading.Event()
|
||||||
|
|
||||||
def occupy(marker: threading.Event) -> None:
|
def occupy(marker: threading.Event) -> None:
|
||||||
@@ -117,7 +198,7 @@ def warm_bulk_pool() -> None:
|
|||||||
try:
|
try:
|
||||||
for marker in started:
|
for marker in started:
|
||||||
if not marker.wait(10):
|
if not marker.wait(10):
|
||||||
raise RuntimeError("bulk_pool_warmup_timeout")
|
raise RuntimeError(timeout_code)
|
||||||
finally:
|
finally:
|
||||||
release.set()
|
release.set()
|
||||||
for future in futures:
|
for future in futures:
|
||||||
@@ -175,6 +256,62 @@ async def in_interactive_read[T](
|
|||||||
return await in_read_pool(interactive_read_pool(), work, *args, **kwargs)
|
return await in_read_pool(interactive_read_pool(), work, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def in_sdk_read[T](
|
||||||
|
work: Callable[..., T],
|
||||||
|
/,
|
||||||
|
*args: object,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> T:
|
||||||
|
"""A recall-path read, in recall's own pool (`memory_read_pool`)."""
|
||||||
|
return await in_read_pool(memory_read_pool(), work, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def in_sdk_write[T](
|
||||||
|
work: Callable[..., T],
|
||||||
|
/,
|
||||||
|
*args: object,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> T:
|
||||||
|
"""One SDK write call, in the write pool (`sdk_write_pool`)."""
|
||||||
|
return await asyncio.get_running_loop().run_in_executor(
|
||||||
|
sdk_write_pool(),
|
||||||
|
copy_context().run,
|
||||||
|
partial(work, *args, **kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def in_the_write_queue[T](
|
||||||
|
work: Callable[..., T],
|
||||||
|
/,
|
||||||
|
*args: object,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> T:
|
||||||
|
"""One durable write queue statement, in the queue's pool (`durable_queue_pool`).
|
||||||
|
|
||||||
|
It still goes through the one writer and its line, like every write; what
|
||||||
|
it no longer does is wait for a bulk thread first.
|
||||||
|
"""
|
||||||
|
return await asyncio.get_running_loop().run_in_executor(
|
||||||
|
durable_queue_pool(),
|
||||||
|
copy_context().run,
|
||||||
|
partial(work, *args, **kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def in_lease_renewal[T](
|
||||||
|
work: Callable[..., T],
|
||||||
|
/,
|
||||||
|
*args: object,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> T:
|
||||||
|
"""One lease renewal, in the threads kept for nothing else (`lease_renewal_pool`)."""
|
||||||
|
return await asyncio.get_running_loop().run_in_executor(
|
||||||
|
lease_renewal_pool(),
|
||||||
|
copy_context().run,
|
||||||
|
partial(work, *args, **kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _retrieve[T](future: "asyncio.Future[T]") -> None:
|
def _retrieve[T](future: "asyncio.Future[T]") -> None:
|
||||||
"""Mark a shielded read's outcome as seen.
|
"""Mark a shielded read's outcome as seen.
|
||||||
|
|
||||||
@@ -261,7 +398,16 @@ def _warm_reader_pool(reader: ThreadPoolExecutor) -> None:
|
|||||||
|
|
||||||
def shutdown_bulk_pool() -> None:
|
def shutdown_bulk_pool() -> None:
|
||||||
global _pool, _snapshot_pool, _interactive_pool, _raw_read_pool, _memory_read_pool
|
global _pool, _snapshot_pool, _interactive_pool, _raw_read_pool, _memory_read_pool
|
||||||
global _source_reader_pool
|
global _source_reader_pool, _sdk_write_pool, _durable_queue_pool, _lease_renewal_pool
|
||||||
|
if _durable_queue_pool is not None:
|
||||||
|
_durable_queue_pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
_durable_queue_pool = None
|
||||||
|
if _lease_renewal_pool is not None:
|
||||||
|
_lease_renewal_pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
_lease_renewal_pool = None
|
||||||
|
if _sdk_write_pool is not None:
|
||||||
|
_sdk_write_pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
_sdk_write_pool = None
|
||||||
if _pool is not None:
|
if _pool is not None:
|
||||||
_pool.shutdown(wait=False, cancel_futures=True)
|
_pool.shutdown(wait=False, cancel_futures=True)
|
||||||
_pool = None
|
_pool = None
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ class SdkContextSearchRequest(ContextSearchRequest, SdkSearchOptions):
|
|||||||
limit: int = Field(default=10, ge=1, le=20, strict=True)
|
limit: int = Field(default=10, ge=1, le=20, strict=True)
|
||||||
schema_limit: int = Field(default=3, ge=0, le=20, strict=True)
|
schema_limit: int = Field(default=3, ge=0, le=20, strict=True)
|
||||||
intention_limit: int = Field(default=1, ge=0, le=20, strict=True)
|
intention_limit: int = Field(default=1, ge=0, le=20, strict=True)
|
||||||
|
#: Product callers can request one item per turn instead of SDK raw batches.
|
||||||
|
raw_unit: Literal["raw", "turn"] = "raw"
|
||||||
#: Whose memories to search. The App never sends it and reads `local`.
|
#: Whose memories to search. The App never sends it and reads `local`.
|
||||||
user_id: UserId = DEFAULT_USER_ID
|
user_id: UserId = DEFAULT_USER_ID
|
||||||
|
|
||||||
@@ -123,6 +125,9 @@ class SdkRawContextItem(BaseModel):
|
|||||||
memory_at: datetime | None = None
|
memory_at: datetime | None = None
|
||||||
score: float = Field(ge=0, le=1)
|
score: float = Field(ge=0, le=1)
|
||||||
truncated: bool = False
|
truncated: bool = False
|
||||||
|
turn_id: str | None = Field(default=None, min_length=1, max_length=96)
|
||||||
|
turn_index: int | None = Field(default=None, ge=0)
|
||||||
|
turn_count: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
class SdkMemoryContextItem(BaseModel):
|
class SdkMemoryContextItem(BaseModel):
|
||||||
@@ -155,6 +160,8 @@ class SdkContextSearchResponse(BaseModel):
|
|||||||
used_characters: int = Field(ge=0, le=20000)
|
used_characters: int = Field(ge=0, le=20000)
|
||||||
truncated: bool
|
truncated: bool
|
||||||
total_ms: float = Field(ge=0)
|
total_ms: float = Field(ge=0)
|
||||||
|
#: Whose memories were searched: the request's `user_id`, else `local`.
|
||||||
|
user_id: str = Field(min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
class ContextLaneResult(BaseModel):
|
class ContextLaneResult(BaseModel):
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from one_memory.hy_sdk_types import (
|
|||||||
parse_sdk_search,
|
parse_sdk_search,
|
||||||
)
|
)
|
||||||
from one_memory.imports.storage import EvidenceStore
|
from one_memory.imports.storage import EvidenceStore
|
||||||
|
from one_memory.raw_turns import raw_display_turns, turns_for_hit
|
||||||
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
|
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
|
||||||
from one_memory.semantic_deadline import SemanticMemoryHit
|
from one_memory.semantic_deadline import SemanticMemoryHit
|
||||||
from one_memory.user_scope import owns
|
from one_memory.user_scope import owns
|
||||||
@@ -70,6 +71,18 @@ def raw_lane_overrides(value: SdkSearchFilters) -> dict[str, object]:
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def raw_lane_statuses(*, lite: bool) -> dict[str, list[str]]:
|
||||||
|
"""Which raw statuses the raw lane searches.
|
||||||
|
|
||||||
|
Lite extracts nothing, so its search is plain RAG over every raw,
|
||||||
|
including the shadow raws pro already processed before the switch to
|
||||||
|
lite. Pro and ultra keep the SDK default (active only): a shadow raw is
|
||||||
|
already represented by the memories extracted from it, and returning it
|
||||||
|
again would repeat that content in the caller's context budget.
|
||||||
|
"""
|
||||||
|
return {"statuses": ["active", "shadow"]} if lite else {}
|
||||||
|
|
||||||
|
|
||||||
def hit_namespace(hit: SemanticMemoryHit) -> dict[str, str]:
|
def hit_namespace(hit: SemanticMemoryHit) -> dict[str, str]:
|
||||||
"""The `get` namespace of a hit: nothing for chat, kind and variant otherwise."""
|
"""The `get` namespace of a hit: nothing for chat, kind and variant otherwise."""
|
||||||
if hit.kind in {"experience", "fact"} and hit.variant is not None:
|
if hit.kind in {"experience", "fact"} and hit.variant is not None:
|
||||||
@@ -120,6 +133,9 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
|||||||
sources = raw_lane_sources(authority.raw_sources, value.include_sources)
|
sources = raw_lane_sources(authority.raw_sources, value.include_sources)
|
||||||
if sources is None:
|
if sources is None:
|
||||||
return ()
|
return ()
|
||||||
|
# Plain RAG: every vector hit over the floor is an answer, ranked
|
||||||
|
# by its score. Each hit yields at least one item, so the SDK
|
||||||
|
# needs to return no more hits than the caller's limit.
|
||||||
raw = await asyncio.to_thread(
|
raw = await asyncio.to_thread(
|
||||||
client.search,
|
client.search,
|
||||||
value.query,
|
value.query,
|
||||||
@@ -135,27 +151,60 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
|||||||
"scene": "chat",
|
"scene": "chat",
|
||||||
"reader": HY_MEMORY_READER,
|
"reader": HY_MEMORY_READER,
|
||||||
"layers": ["l1_raw"],
|
"layers": ["l1_raw"],
|
||||||
|
**raw_lane_statuses(lite=lite),
|
||||||
**raw_lane_overrides(value),
|
**raw_lane_overrides(value),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
hits = tuple(
|
hits = tuple(
|
||||||
hit for hit in parse_sdk_raw_search(raw) if value.passes_floor("raw", hit.score)
|
hit for hit in parse_sdk_raw_search(raw) if value.passes_floor("raw", hit.score)
|
||||||
)
|
)
|
||||||
return tuple(
|
# One item per hit first, so a hit is never crowded out by another
|
||||||
SdkRawContextItem(
|
# hit's sibling turns; the siblings fill whatever room is left.
|
||||||
raw_id=hit.raw_id,
|
primary: list[SdkRawContextItem] = []
|
||||||
content=hit.content,
|
siblings: list[SdkRawContextItem] = []
|
||||||
source=hit.source,
|
for hit in hits:
|
||||||
session_id=hit.session_id,
|
memory_at = (
|
||||||
memory_at=(
|
datetime.fromtimestamp(hit.memory_at, tz=UTC)
|
||||||
datetime.fromtimestamp(hit.memory_at, tz=UTC)
|
if hit.memory_at is not None
|
||||||
if hit.memory_at is not None
|
else None
|
||||||
else None
|
|
||||||
),
|
|
||||||
score=hit.score,
|
|
||||||
)
|
)
|
||||||
for hit in hits
|
turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
|
||||||
)
|
if not turns:
|
||||||
|
primary.append(
|
||||||
|
SdkRawContextItem(
|
||||||
|
raw_id=hit.raw_id,
|
||||||
|
content=hit.content,
|
||||||
|
source=hit.source,
|
||||||
|
session_id=hit.session_id,
|
||||||
|
memory_at=memory_at,
|
||||||
|
score=hit.score,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
# The embedding is the batch's: the hit is shown by the turns
|
||||||
|
# that contain the query, or else by its closest turn, each
|
||||||
|
# with the hit's score.
|
||||||
|
shown = [
|
||||||
|
SdkRawContextItem(
|
||||||
|
raw_id=hit.raw_id,
|
||||||
|
turn_id=turn.turn_id,
|
||||||
|
turn_index=turn.turn_index,
|
||||||
|
turn_count=turn.turn_count,
|
||||||
|
content=turn.content,
|
||||||
|
source=hit.source,
|
||||||
|
session_id=hit.session_id,
|
||||||
|
memory_at=memory_at,
|
||||||
|
score=hit.score,
|
||||||
|
)
|
||||||
|
for turn in turns_for_hit(turns, value.query)
|
||||||
|
]
|
||||||
|
primary.append(shown[0])
|
||||||
|
siblings.extend(shown[1:])
|
||||||
|
chosen = primary[: value.limit]
|
||||||
|
chosen.extend(siblings[: value.limit - len(chosen)])
|
||||||
|
# Stable: a hit's turns stay together, in hit-score order.
|
||||||
|
chosen.sort(key=lambda item: item.score, reverse=True)
|
||||||
|
return tuple(chosen)
|
||||||
|
|
||||||
async def memory_lane() -> tuple[SdkMemoryContextItem, ...]:
|
async def memory_lane() -> tuple[SdkMemoryContextItem, ...]:
|
||||||
# lite extracts nothing: its search answers raw turns, which the
|
# lite extracts nothing: its search answers raw turns, which the
|
||||||
@@ -261,6 +310,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
|||||||
used_characters=0,
|
used_characters=0,
|
||||||
truncated=True,
|
truncated=True,
|
||||||
total_ms=(time.monotonic() - started) * 1000,
|
total_ms=(time.monotonic() - started) * 1000,
|
||||||
|
user_id=value.user_id,
|
||||||
)
|
)
|
||||||
await record(final, timed_out=True)
|
await record(final, timed_out=True)
|
||||||
return final
|
return final
|
||||||
@@ -306,6 +356,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
|||||||
used_characters=used,
|
used_characters=used,
|
||||||
truncated=truncated,
|
truncated=truncated,
|
||||||
total_ms=(time.monotonic() - started) * 1000,
|
total_ms=(time.monotonic() - started) * 1000,
|
||||||
|
user_id=value.user_id,
|
||||||
)
|
)
|
||||||
await record(final, timed_out=False)
|
await record(final, timed_out=False)
|
||||||
return final
|
return final
|
||||||
|
|||||||
@@ -111,10 +111,26 @@ MAIN_DATABASE_CHECKPOINT_WRITE_INTERVAL = 64
|
|||||||
MAIN_DATABASE_CHECKPOINT_SECONDS = 30.0
|
MAIN_DATABASE_CHECKPOINT_SECONDS = 30.0
|
||||||
|
|
||||||
# Advisory only: PASSIVE cannot force a frame past a long reader. Crossing this
|
# Advisory only: PASSIVE cannot force a frame past a long reader. Crossing this
|
||||||
# threshold is a signal that the WAL is not converging and that read snapshots
|
# threshold with frames still waiting to be copied back is a signal that the
|
||||||
# or write volume need investigation.
|
# WAL is not converging and that read snapshots or write volume need
|
||||||
|
# investigation. A large file whose frames are all checkpointed is not: the
|
||||||
|
# idle TRUNCATE below gives that space back.
|
||||||
MAIN_DATABASE_WAL_WARN_BYTES = 512 * 1024 * 1024
|
MAIN_DATABASE_WAL_WARN_BYTES = 512 * 1024 * 1024
|
||||||
|
|
||||||
|
# What a WAL file is cut back to once a checkpoint has copied every frame and
|
||||||
|
# the writer starts it over. Without a limit SQLite reuses the file in place
|
||||||
|
# and never shrinks it, so one import peak left a 639 MB WAL behind for good
|
||||||
|
# (#245). 64 MiB is about sixteen times what the default autocheckpoint (1000
|
||||||
|
# pages) lets build up between checkpoints, so ordinary write bursts reuse the
|
||||||
|
# file without growing and shrinking it each time, and it is still a tenth of
|
||||||
|
# what that user's disk was left holding.
|
||||||
|
MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
# How long an idle TRUNCATE may wait for the writer or for a reader to leave.
|
||||||
|
# It runs only when nothing holds the database; anyone arriving in the
|
||||||
|
# meantime wins, and the checkpoint is tried again on the next pass.
|
||||||
|
MAIN_DATABASE_IDLE_CHECKPOINT_WAIT_MS = 50
|
||||||
|
|
||||||
|
|
||||||
def checkpoint_write_interval() -> int:
|
def checkpoint_write_interval() -> int:
|
||||||
raw = os.environ.get(
|
raw = os.environ.get(
|
||||||
@@ -226,6 +242,75 @@ class _InTurn:
|
|||||||
self._line.remove(place)
|
self._line.remove(place)
|
||||||
|
|
||||||
|
|
||||||
|
class _Checkpointer:
|
||||||
|
"""The one thread that runs cadence checkpoints, for every main database.
|
||||||
|
|
||||||
|
A database asks for at most one checkpoint at a time: a request made
|
||||||
|
while one is already waiting is the same request. The checkpoint goes
|
||||||
|
through the database's own writer, in its line, like any other write.
|
||||||
|
A waiting request holds the database weakly: it must not keep alive a
|
||||||
|
database nobody else holds any more.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._state = threading.Condition()
|
||||||
|
self._due: OrderedDict[int, weakref.ref[MainDatabase]] = OrderedDict()
|
||||||
|
self._running = 0
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
def request(self, database: "MainDatabase") -> None:
|
||||||
|
with self._state:
|
||||||
|
self._due.setdefault(id(database), weakref.ref(database))
|
||||||
|
if self._thread is None or not self._thread.is_alive():
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run,
|
||||||
|
name="one-memory-checkpoint",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
self._state.notify_all()
|
||||||
|
|
||||||
|
def forget(self, database: "MainDatabase") -> None:
|
||||||
|
with self._state:
|
||||||
|
self._due.pop(id(database), None)
|
||||||
|
self._state.notify_all()
|
||||||
|
|
||||||
|
def wait_idle(self, timeout: float) -> bool:
|
||||||
|
"""Whether every requested checkpoint has run within `timeout` seconds."""
|
||||||
|
with self._state:
|
||||||
|
return self._state.wait_for(lambda: not self._due and not self._running, timeout)
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
while True:
|
||||||
|
with self._state:
|
||||||
|
self._state.wait_for(lambda: bool(self._due))
|
||||||
|
_key, reference = self._due.popitem(last=False)
|
||||||
|
database = reference()
|
||||||
|
if database is None:
|
||||||
|
self._state.notify_all()
|
||||||
|
continue
|
||||||
|
self._running += 1
|
||||||
|
try:
|
||||||
|
database._cadence_checkpoint()
|
||||||
|
except Exception: # noqa: BLE001 - maintenance must not end the thread
|
||||||
|
logger.warning(
|
||||||
|
"main_database_checkpoint_failed file=%s", database.path.name, exc_info=True
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
del database
|
||||||
|
with self._state:
|
||||||
|
self._running -= 1
|
||||||
|
self._state.notify_all()
|
||||||
|
|
||||||
|
|
||||||
|
_checkpointer = _Checkpointer()
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_cadence_checkpoints(timeout: float = 5.0) -> bool:
|
||||||
|
"""Wait for the cadence checkpoints already requested; for tests and shutdown."""
|
||||||
|
return _checkpointer.wait_idle(timeout)
|
||||||
|
|
||||||
|
|
||||||
def _locked() -> sqlite3.OperationalError:
|
def _locked() -> sqlite3.OperationalError:
|
||||||
# Callers tell a busy database from a broken one by these, as they would
|
# Callers tell a busy database from a broken one by these, as they would
|
||||||
# for SQLite's own refusal.
|
# for SQLite's own refusal.
|
||||||
@@ -276,6 +361,8 @@ class MainDatabase:
|
|||||||
self._writes_since_checkpoint = 0
|
self._writes_since_checkpoint = 0
|
||||||
self._last_checkpoint_at = time.monotonic()
|
self._last_checkpoint_at = time.monotonic()
|
||||||
self._wal_warned_at = 0.0
|
self._wal_warned_at = 0.0
|
||||||
|
#: Learned from the first checkpoint; turns WAL frames into bytes.
|
||||||
|
self._page_size = 4096
|
||||||
self._reader_started_at: dict[int, float] = {}
|
self._reader_started_at: dict[int, float] = {}
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -542,24 +629,77 @@ class MainDatabase:
|
|||||||
def _checkpoint_from_maintenance(self, mode: str) -> tuple[int, int, int]:
|
def _checkpoint_from_maintenance(self, mode: str) -> tuple[int, int, int]:
|
||||||
return self._checkpoint(mode, maintenance=True)
|
return self._checkpoint(mode, maintenance=True)
|
||||||
|
|
||||||
def _checkpoint(self, mode: str, *, maintenance: bool) -> tuple[int, int, int]:
|
def _checkpoint(
|
||||||
|
self,
|
||||||
|
mode: str,
|
||||||
|
*,
|
||||||
|
maintenance: bool,
|
||||||
|
busy_timeout_ms: int = WRITE_LOCK_WAIT_MS,
|
||||||
|
) -> tuple[int, int, int]:
|
||||||
normalized = mode.strip().upper()
|
normalized = mode.strip().upper()
|
||||||
if normalized not in {"PASSIVE", "FULL", "RESTART", "TRUNCATE"}:
|
if normalized not in {"PASSIVE", "FULL", "RESTART", "TRUNCATE"}:
|
||||||
raise ValueError("unsupported WAL checkpoint mode")
|
raise ValueError("unsupported WAL checkpoint mode")
|
||||||
with self.write(autocommit=True, maintenance=maintenance) as connection:
|
with self.write(
|
||||||
|
autocommit=True, maintenance=maintenance, busy_timeout_ms=busy_timeout_ms
|
||||||
|
) as connection:
|
||||||
row = connection.execute(f"PRAGMA wal_checkpoint({normalized})").fetchone()
|
row = connection.execute(f"PRAGMA wal_checkpoint({normalized})").fetchone()
|
||||||
|
page_size = connection.execute("PRAGMA page_size").fetchone()
|
||||||
if row is None or len(row) != 3:
|
if row is None or len(row) != 3:
|
||||||
raise sqlite3.DatabaseError("SQLite returned an invalid checkpoint result")
|
raise sqlite3.DatabaseError("SQLite returned an invalid checkpoint result")
|
||||||
|
if page_size is not None:
|
||||||
|
self._page_size = int(page_size[0])
|
||||||
return int(row[0]), int(row[1]), int(row[2])
|
return int(row[0]), int(row[1]), int(row[2])
|
||||||
|
|
||||||
|
def idle(self) -> bool:
|
||||||
|
"""Nobody holds or waits for a connection to this database right now."""
|
||||||
|
with self._state:
|
||||||
|
return (
|
||||||
|
self._writer_owner is None
|
||||||
|
and not self._reader_holders
|
||||||
|
and not len(self._write_line)
|
||||||
|
and not len(self._read_line)
|
||||||
|
)
|
||||||
|
|
||||||
|
def checkpoint_if_idle(self) -> tuple[int, int, int] | None:
|
||||||
|
"""Give the WAL file back to the disk while nothing is using the database.
|
||||||
|
|
||||||
|
PASSIVE copies frames back but leaves the file its size; only TRUNCATE
|
||||||
|
empties it, and TRUNCATE has to wait for every reader to finish, so it
|
||||||
|
is attempted only when this process holds no checkout at all, and with
|
||||||
|
a wait of a few milliseconds. A busy result, or someone arriving in
|
||||||
|
between, is not a failure: the next idle pass tries again. Returns the
|
||||||
|
checkpoint result, or None when it was not attempted or not admitted.
|
||||||
|
"""
|
||||||
|
if not self.idle() or self._wal_bytes() == 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return self._checkpoint(
|
||||||
|
"TRUNCATE",
|
||||||
|
maintenance=True,
|
||||||
|
busy_timeout_ms=MAIN_DATABASE_IDLE_CHECKPOINT_WAIT_MS,
|
||||||
|
)
|
||||||
|
except sqlite3.Error:
|
||||||
|
logger.debug("main_database_idle_checkpoint_skipped file=%s", self.path.name)
|
||||||
|
return None
|
||||||
|
|
||||||
def _maybe_checkpoint_after_write(self) -> None:
|
def _maybe_checkpoint_after_write(self) -> None:
|
||||||
"""Checkpoint on a write cadence without opening another connection.
|
"""Count a write, and hand a due cadence checkpoint to the checkpointer.
|
||||||
|
|
||||||
SQLite's default autocheckpoint only runs when the writer itself
|
SQLite's default autocheckpoint only runs when the writer itself
|
||||||
crosses the page threshold. Production hit a state where the WAL grew
|
crosses the page threshold. Production hit a state where the WAL grew
|
||||||
to 13GB, so the main database needs its own bounded, observable
|
to 13GB, so the main database needs its own bounded, observable
|
||||||
maintenance path. This runs after the caller's checkout has been reset
|
maintenance path: every `checkpoint_write_interval()` writes or
|
||||||
and only re-enters the same serialized writer queue.
|
`MAIN_DATABASE_CHECKPOINT_SECONDS`, whichever comes first.
|
||||||
|
|
||||||
|
The checkpoint itself used to run right here, on the thread that had
|
||||||
|
just written, as one more turn in the writer's line. That thread was
|
||||||
|
an import heartbeat or a durable lease renewal as often as not, and
|
||||||
|
it then waited behind the whole line a second time before it could
|
||||||
|
return. The idle TRUNCATE (#278) cannot replace the cadence: under a
|
||||||
|
sustained import the database is never idle, and that is exactly when
|
||||||
|
the WAL grows. So the cadence stays, and one checkpointer thread runs
|
||||||
|
it (`_Checkpointer`); the writer stays the only connection that
|
||||||
|
writes, and no caller waits for maintenance it did not ask for.
|
||||||
"""
|
"""
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
with self._state:
|
with self._state:
|
||||||
@@ -571,25 +711,35 @@ class MainDatabase:
|
|||||||
if due:
|
if due:
|
||||||
self._writes_since_checkpoint = 0
|
self._writes_since_checkpoint = 0
|
||||||
self._last_checkpoint_at = now
|
self._last_checkpoint_at = now
|
||||||
if not due:
|
if due:
|
||||||
return
|
_checkpointer.request(self)
|
||||||
|
|
||||||
|
def _cadence_checkpoint(self) -> None:
|
||||||
|
"""One PASSIVE checkpoint through the writer, and the WAL alert after it."""
|
||||||
|
now = time.monotonic()
|
||||||
try:
|
try:
|
||||||
busy, log_frames, checkpointed = self._checkpoint_from_maintenance("PASSIVE")
|
busy, log_frames, checkpointed = self._checkpoint_from_maintenance("PASSIVE")
|
||||||
except sqlite3.Error:
|
except sqlite3.Error:
|
||||||
logger.warning("main_database_checkpoint_failed file=%s", self.path.name, exc_info=True)
|
logger.warning("main_database_checkpoint_failed file=%s", self.path.name, exc_info=True)
|
||||||
return
|
return
|
||||||
wal_bytes = self._wal_bytes()
|
wal_bytes = self._wal_bytes()
|
||||||
if wal_bytes >= MAIN_DATABASE_WAL_WARN_BYTES:
|
# Frames the checkpoint could not copy back yet: what a reader is
|
||||||
|
# holding up. The file size alone also counts space already
|
||||||
|
# checkpointed and only waiting to be reused or truncated, which is
|
||||||
|
# not the WAL failing to converge.
|
||||||
|
pending_bytes = max(0, log_frames - checkpointed) * self._page_size
|
||||||
|
if pending_bytes >= MAIN_DATABASE_WAL_WARN_BYTES:
|
||||||
with self._state:
|
with self._state:
|
||||||
should_warn = now - self._wal_warned_at >= MAIN_DATABASE_REPORT_SECONDS
|
should_warn = now - self._wal_warned_at >= MAIN_DATABASE_REPORT_SECONDS
|
||||||
if should_warn:
|
if should_warn:
|
||||||
self._wal_warned_at = now
|
self._wal_warned_at = now
|
||||||
if should_warn:
|
if should_warn:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"main_database_wal_high file=%s wal_bytes=%d busy=%d "
|
"main_database_wal_high file=%s wal_bytes=%d pending_bytes=%d busy=%d "
|
||||||
"log_frames=%d checkpointed=%d readers_in_use=%d",
|
"log_frames=%d checkpointed=%d readers_in_use=%d",
|
||||||
self.path.name,
|
self.path.name,
|
||||||
wal_bytes,
|
wal_bytes,
|
||||||
|
pending_bytes,
|
||||||
busy,
|
busy,
|
||||||
log_frames,
|
log_frames,
|
||||||
checkpointed,
|
checkpointed,
|
||||||
@@ -640,6 +790,8 @@ class MainDatabase:
|
|||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Close every idle connection now; ones in use close when handed back."""
|
"""Close every idle connection now; ones in use close when handed back."""
|
||||||
|
# A checkpoint still waiting would open the writer again after this.
|
||||||
|
_checkpointer.forget(self)
|
||||||
with self._state:
|
with self._state:
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
self._identity = None
|
self._identity = None
|
||||||
@@ -784,6 +936,10 @@ class MainDatabase:
|
|||||||
try:
|
try:
|
||||||
if wal:
|
if wal:
|
||||||
enable_write_ahead_log(connection, wait_ms=busy_timeout_ms)
|
enable_write_ahead_log(connection, wait_ms=busy_timeout_ms)
|
||||||
|
# Only the writer restarts the WAL, so only the writer needs the cap.
|
||||||
|
connection.execute(
|
||||||
|
f"PRAGMA journal_size_limit = {int(MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES)}"
|
||||||
|
)
|
||||||
_seal(connection)
|
_seal(connection)
|
||||||
if busy_timeout_ms != WRITE_LOCK_WAIT_MS:
|
if busy_timeout_ms != WRITE_LOCK_WAIT_MS:
|
||||||
# Opened at the caller's wait; every checkout starts at the default.
|
# Opened at the caller's wait; every checkout starts at the default.
|
||||||
@@ -1027,6 +1183,18 @@ def main_database_transaction(
|
|||||||
yield connection
|
yield connection
|
||||||
|
|
||||||
|
|
||||||
|
def checkpoint_idle_main_databases() -> int:
|
||||||
|
"""TRUNCATE the WAL of every database nobody is using; how many were emptied."""
|
||||||
|
with _databases_lock:
|
||||||
|
databases = list(_alive.values())
|
||||||
|
emptied = 0
|
||||||
|
for database in databases:
|
||||||
|
result = database.checkpoint_if_idle()
|
||||||
|
if result is not None and result[0] == 0:
|
||||||
|
emptied += 1
|
||||||
|
return emptied
|
||||||
|
|
||||||
|
|
||||||
def close_main_databases() -> None:
|
def close_main_databases() -> None:
|
||||||
"""Close every connection this process holds; used when the Core stops."""
|
"""Close every connection this process holds; used when the Core stops."""
|
||||||
with _databases_lock:
|
with _databases_lock:
|
||||||
@@ -1050,6 +1218,9 @@ async def report_main_database(seconds: float) -> None:
|
|||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(seconds)
|
await asyncio.sleep(seconds)
|
||||||
log_main_database_stats("periodic")
|
log_main_database_stats("periodic")
|
||||||
|
# The idle signal is this process's own checkouts: none held, none
|
||||||
|
# waiting. Off the loop, since a checkpoint is disk work however short.
|
||||||
|
await asyncio.to_thread(checkpoint_idle_main_databases)
|
||||||
|
|
||||||
|
|
||||||
def main_database_stats() -> dict[str, float | int]:
|
def main_database_stats() -> dict[str, float | int]:
|
||||||
|
|||||||
@@ -126,15 +126,23 @@ def embedding_switch_router(dependencies: Sequence[Depends]) -> APIRouter:
|
|||||||
|
|
||||||
@router.post("/api/v1/embedding-switches", status_code=202, response_model=None)
|
@router.post("/api/v1/embedding-switches", status_code=202, response_model=None)
|
||||||
async def start_embedding_switch(value: EmbeddingSwitchStartRequest, request: Request) -> Any:
|
async def start_embedding_switch(value: EmbeddingSwitchStartRequest, request: Request) -> Any:
|
||||||
try:
|
# One lifecycle lock orders mode/evolve/backfill and embedding
|
||||||
job = await _service(request).start_switch(
|
# maintenance. Without it each endpoint could pass its own preflight
|
||||||
value.profile_id,
|
# and then begin concurrently.
|
||||||
expected_revision=value.expected_revision,
|
async with request.app.state.memory_mode_lock:
|
||||||
plan_token=value.plan_token,
|
evolve = request.app.state.evolve_service
|
||||||
requested_by=value.requested_by,
|
backfill = request.app.state.memory_graph_backfill
|
||||||
)
|
if evolve.running() or backfill.running:
|
||||||
except EmbeddingSwitchError as error:
|
return refusal("memory_lifecycle_busy")
|
||||||
return refusal(error.code, plan=error.plan)
|
try:
|
||||||
|
job = await _service(request).start_switch(
|
||||||
|
value.profile_id,
|
||||||
|
expected_revision=value.expected_revision,
|
||||||
|
plan_token=value.plan_token,
|
||||||
|
requested_by=value.requested_by,
|
||||||
|
)
|
||||||
|
except EmbeddingSwitchError as error:
|
||||||
|
return refusal(error.code, plan=error.plan)
|
||||||
return job.model_dump(mode="json")
|
return job.model_dump(mode="json")
|
||||||
|
|
||||||
@router.get("/api/v1/embedding-switches/current")
|
@router.get("/api/v1/embedding-switches/current")
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
|||||||
from typing import Literal, cast
|
from typing import Literal, cast
|
||||||
|
|
||||||
HY_MEMORY_DISTRIBUTION = "hy-memory"
|
HY_MEMORY_DISTRIBUTION = "hy-memory"
|
||||||
HY_MEMORY_VERSION = "2.0.11"
|
HY_MEMORY_VERSION = "2.0.14"
|
||||||
HY_MEMORY_NAMESPACE = "governed-v2"
|
HY_MEMORY_NAMESPACE = "governed-v2"
|
||||||
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
|
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
|
||||||
|
|
||||||
|
|||||||
@@ -503,7 +503,7 @@ def _ensure_coding_schema(path: Path) -> None:
|
|||||||
CodingMemoryStore,
|
CodingMemoryStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relies on the pinned SDK's internals (hy-memory==2.0.11 is pinned exactly).
|
# Relies on the pinned SDK's internals (hy-memory==2.0.14 is pinned exactly).
|
||||||
store = CodingMemoryStore.__new__(CodingMemoryStore)
|
store = CodingMemoryStore.__new__(CodingMemoryStore)
|
||||||
store._db_path = str(path)
|
store._db_path = str(path)
|
||||||
store._conn = None
|
store._conn = None
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ from one_memory.local_embedding import LocalEmbedder
|
|||||||
from one_memory.log_file import confine_hy_sdk_logging
|
from one_memory.log_file import confine_hy_sdk_logging
|
||||||
from one_memory.memory_features import (
|
from one_memory.memory_features import (
|
||||||
FEATURES,
|
FEATURES,
|
||||||
FEATURES_BY_NAME,
|
PARAMETERS,
|
||||||
|
SETTINGS_BY_NAME,
|
||||||
FeatureOverrides,
|
FeatureOverrides,
|
||||||
|
SettingValue,
|
||||||
feature_overrides,
|
feature_overrides,
|
||||||
)
|
)
|
||||||
from one_memory.model_profiles import (
|
from one_memory.model_profiles import (
|
||||||
@@ -321,21 +323,27 @@ def sdk_relevance_floor() -> float:
|
|||||||
return min(1.0, max(0.0, value))
|
return min(1.0, max(0.0, value))
|
||||||
|
|
||||||
|
|
||||||
def sdk_feature_defaults() -> dict[str, bool]:
|
def sdk_feature_defaults() -> dict[str, SettingValue]:
|
||||||
"""Each `memory_features` feature's SDK default, as a fresh config has it.
|
"""Each `memory_features` setting's SDK default, as a fresh config has it.
|
||||||
|
|
||||||
That is the SDK's code default unless the process environment sets the
|
That is the SDK's code default unless the process environment sets the
|
||||||
SDK's own variable (`FeatureSpec.env`), exactly what an unset feature
|
SDK's own variable (`FeatureSpec.env`), exactly what an unset feature
|
||||||
leaves on a client.
|
leaves on a client.
|
||||||
"""
|
"""
|
||||||
config = MemoryConfig()
|
config = MemoryConfig()
|
||||||
return {
|
defaults: dict[str, SettingValue] = {
|
||||||
spec.name: bool(getattr(getattr(config, spec.section), spec.field)) for spec in FEATURES
|
spec.name: bool(getattr(getattr(config, spec.section), spec.field)) for spec in FEATURES
|
||||||
}
|
}
|
||||||
|
for parameter in PARAMETERS:
|
||||||
|
defaults[parameter.name] = float(
|
||||||
|
getattr(getattr(config, parameter.section), parameter.field)
|
||||||
|
)
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
def apply_sdk_features(
|
def apply_sdk_features(
|
||||||
config: MemoryConfig, features: Mapping[str, bool] | Iterable[tuple[str, bool]]
|
config: MemoryConfig,
|
||||||
|
features: Mapping[str, SettingValue] | Iterable[tuple[str, SettingValue]],
|
||||||
) -> MemoryConfig:
|
) -> MemoryConfig:
|
||||||
"""Set each feature a person chose on its `MemoryConfig` field.
|
"""Set each feature a person chose on its `MemoryConfig` field.
|
||||||
|
|
||||||
@@ -346,7 +354,7 @@ def apply_sdk_features(
|
|||||||
"""
|
"""
|
||||||
values = dict(features.items() if isinstance(features, Mapping) else features)
|
values = dict(features.items() if isinstance(features, Mapping) else features)
|
||||||
for name, value in feature_overrides(values):
|
for name, value in feature_overrides(values):
|
||||||
spec = FEATURES_BY_NAME[name]
|
spec = SETTINGS_BY_NAME[name]
|
||||||
setattr(getattr(config, spec.section), spec.field, value)
|
setattr(getattr(config, spec.section), spec.field, value)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
@@ -367,8 +375,8 @@ def language_kwargs(language: str | None) -> LanguageKwargs:
|
|||||||
def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConfig:
|
def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConfig:
|
||||||
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
|
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
|
||||||
|
|
||||||
None (zh-Hans, or no choice) sets nothing: the SDK detects zh/en as it
|
None (no language set) sets nothing: the SDK detects zh/en as it always
|
||||||
always did. Like a feature, the language never touches `vector_store`, so
|
did. Every display language, zh-Hans included, is set explicitly. Like a feature, the language never touches `vector_store`, so
|
||||||
the collection identity never depends on it.
|
the collection identity never depends on it.
|
||||||
"""
|
"""
|
||||||
if language is not None:
|
if language is not None:
|
||||||
@@ -383,7 +391,7 @@ def build_sdk_config(
|
|||||||
*,
|
*,
|
||||||
api_key: str | None,
|
api_key: str | None,
|
||||||
data_dir: Path,
|
data_dir: Path,
|
||||||
features: Mapping[str, bool] | Iterable[tuple[str, bool]] = (),
|
features: Mapping[str, SettingValue] | Iterable[tuple[str, SettingValue]] = (),
|
||||||
language: str | None = None,
|
language: str | None = None,
|
||||||
) -> MemoryConfig:
|
) -> MemoryConfig:
|
||||||
"""Map a model snapshot and its embedding space to an SDK configuration.
|
"""Map a model snapshot and its embedding space to an SDK configuration.
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ class HySdkMemoryRecord(BaseModel):
|
|||||||
# Epoch seconds. `updated_at` falls back to `created_at`.
|
# Epoch seconds. `updated_at` falls back to `created_at`.
|
||||||
created_at: int | None = None
|
created_at: int | None = None
|
||||||
updated_at: int | None = None
|
updated_at: int | None = None
|
||||||
|
# Actual scene=all ordering timestamp before the public fallback above.
|
||||||
|
# Internal-only: callers keep the existing SdkMemoryRecord wire shape.
|
||||||
|
sort_updated_at: int | None = Field(default=None, exclude=True, repr=False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_sdk(cls, raw: object) -> HySdkMemoryRecord:
|
def from_sdk(cls, raw: object) -> HySdkMemoryRecord:
|
||||||
@@ -95,6 +98,10 @@ class HySdkMemoryRecord(BaseModel):
|
|||||||
if created_at is None:
|
if created_at is None:
|
||||||
created_at = gmt_created
|
created_at = gmt_created
|
||||||
updated_at = _epoch(raw.get("updated_at"))
|
updated_at = _epoch(raw.get("updated_at"))
|
||||||
|
modified_at = _epoch(raw.get("gmt_modified"))
|
||||||
|
sort_updated_at = (
|
||||||
|
modified_at if modified_at is not None else updated_at
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
memory_id=str(raw["memory_id"]),
|
memory_id=str(raw["memory_id"]),
|
||||||
content=str(raw["content"]),
|
content=str(raw["content"]),
|
||||||
@@ -116,6 +123,7 @@ class HySdkMemoryRecord(BaseModel):
|
|||||||
gmt_created=gmt_created,
|
gmt_created=gmt_created,
|
||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
updated_at=updated_at if updated_at is not None else created_at,
|
updated_at=updated_at if updated_at is not None else created_at,
|
||||||
|
sort_updated_at=sort_updated_at,
|
||||||
)
|
)
|
||||||
except (KeyError, TypeError, ValueError) as error:
|
except (KeyError, TypeError, ValueError) as error:
|
||||||
raise HySdkReadError("hy_memory_record_shape_unknown") from error
|
raise HySdkReadError("hy_memory_record_shape_unknown") from error
|
||||||
@@ -180,6 +188,7 @@ class HySdkMemoryRecord(BaseModel):
|
|||||||
solution=solution,
|
solution=solution,
|
||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
updated_at=updated_at if updated_at is not None else created_at,
|
updated_at=updated_at if updated_at is not None else created_at,
|
||||||
|
sort_updated_at=updated_at,
|
||||||
)
|
)
|
||||||
except (KeyError, TypeError, ValueError) as error:
|
except (KeyError, TypeError, ValueError) as error:
|
||||||
raise HySdkReadError("hy_memory_record_shape_unknown") from error
|
raise HySdkReadError("hy_memory_record_shape_unknown") from error
|
||||||
|
|||||||
@@ -436,6 +436,11 @@ def root_identity_matches(root: Path, expected: str) -> bool:
|
|||||||
return _root_identity_matches_details(resolved, details, expected)
|
return _root_identity_matches_details(resolved, details, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_source_root(root: Path) -> Path:
|
||||||
|
"""The real directory a root names (symlinks followed), as a grant records it."""
|
||||||
|
return _root_details(root)[0]
|
||||||
|
|
||||||
|
|
||||||
def _check_root_identity(root: Path, expected: str | None) -> None:
|
def _check_root_identity(root: Path, expected: str | None) -> None:
|
||||||
if expected is not None and not root_identity_matches(root, expected):
|
if expected is not None and not root_identity_matches(root, expected):
|
||||||
raise SourceAccessError(SourceAccessErrorCode.SOURCE_IDENTITY_CHANGED)
|
raise SourceAccessError(SourceAccessErrorCode.SOURCE_IDENTITY_CHANGED)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def discovered_off_loop(
|
|||||||
*,
|
*,
|
||||||
suffixes: frozenset[str],
|
suffixes: frozenset[str],
|
||||||
max_targets: int,
|
max_targets: int,
|
||||||
excluded_top_directories: frozenset[str] = frozenset(),
|
excluded_directory_names: frozenset[str] = frozenset(),
|
||||||
) -> AsyncIterator[SourceFileDiscoveryEvent]:
|
) -> AsyncIterator[SourceFileDiscoveryEvent]:
|
||||||
"""The same walk, for callers that have an event loop to keep free.
|
"""The same walk, for callers that have an event loop to keep free.
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ def discovered_off_loop(
|
|||||||
grant,
|
grant,
|
||||||
suffixes=suffixes,
|
suffixes=suffixes,
|
||||||
max_targets=max_targets,
|
max_targets=max_targets,
|
||||||
excluded_top_directories=excluded_top_directories,
|
excluded_directory_names=excluded_directory_names,
|
||||||
),
|
),
|
||||||
scope_key=grant.source_id,
|
scope_key=grant.source_id,
|
||||||
)
|
)
|
||||||
@@ -53,15 +53,17 @@ def discover_source_files(
|
|||||||
*,
|
*,
|
||||||
suffixes: frozenset[str],
|
suffixes: frozenset[str],
|
||||||
max_targets: int,
|
max_targets: int,
|
||||||
excluded_top_directories: frozenset[str] = frozenset(),
|
excluded_directory_names: frozenset[str] = frozenset(),
|
||||||
) -> Iterator[SourceFileDiscoveryEvent]:
|
) -> Iterator[SourceFileDiscoveryEvent]:
|
||||||
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
|
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
|
||||||
|
|
||||||
The grant and root identity are checked once before the walk. Each listed
|
The grant and root identity are checked once before the walk. Each listed
|
||||||
file keeps the grant's check, which runs again when that file is opened;
|
file keeps the grant's check, which runs again when that file is opened;
|
||||||
the open itself refuses symlinks and any inode other than the one listed.
|
the open itself refuses symlinks and any inode other than the one listed.
|
||||||
`excluded_top_directories` are directories directly under the root that
|
`excluded_directory_names` are directory names never entered, at any
|
||||||
are never entered, such as another user's Hermes profiles.
|
depth under the root, such as the `profiles` container that holds other
|
||||||
|
users' Hermes profiles. Any depth, because the granted root may sit above
|
||||||
|
the Hermes home (`~/.hermes` inside it is then `.hermes/profiles`).
|
||||||
"""
|
"""
|
||||||
grant.check_root()
|
grant.check_root()
|
||||||
inspected = 0
|
inspected = 0
|
||||||
@@ -69,12 +71,10 @@ def discover_source_files(
|
|||||||
grant.root,
|
grant.root,
|
||||||
followlinks=False,
|
followlinks=False,
|
||||||
):
|
):
|
||||||
at_root = Path(directory) == grant.root
|
|
||||||
child_directories[:] = sorted(
|
child_directories[:] = sorted(
|
||||||
child
|
child
|
||||||
for child in child_directories
|
for child in child_directories
|
||||||
if not (Path(directory) / child).is_symlink()
|
if not (Path(directory) / child).is_symlink() and child not in excluded_directory_names
|
||||||
and not (at_root and child in excluded_top_directories)
|
|
||||||
)
|
)
|
||||||
for filename in sorted(filenames):
|
for filename in sorted(filenames):
|
||||||
if Path(filename).suffix.lower() not in suffixes:
|
if Path(filename).suffix.lower() not in suffixes:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from collections.abc import Iterable
|
||||||
from contextlib import AbstractContextManager, nullcontext
|
from contextlib import AbstractContextManager, nullcontext
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -18,13 +19,27 @@ from one_memory.imports.adapters import ScanPhase, SourceCursor
|
|||||||
#: the drift would only surface on a fresh database.
|
#: the drift would only surface on a fresh database.
|
||||||
#:
|
#:
|
||||||
#: This runs on SQLite today, so the SQLite rendering is used.
|
#: This runs on SQLite today, so the SQLite rendering is used.
|
||||||
from one_memory.memory_server.migrations import import_jobs_ddl
|
from one_memory.memory_server.migrations import (
|
||||||
|
import_job_lookup_indexes_ddl,
|
||||||
|
import_jobs_ddl,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("one_memory.imports.jobs")
|
logger = logging.getLogger("one_memory.imports.jobs")
|
||||||
|
|
||||||
JOB_SCHEMA = import_jobs_ddl("sqlite")
|
JOB_SCHEMA = import_jobs_ddl("sqlite")
|
||||||
|
#: Migration 63, applied by the store as well so a running home gets it.
|
||||||
|
JOB_LOOKUP_INDEXES = import_job_lookup_indexes_ddl("sqlite")
|
||||||
MAX_LIVE_BACKLOG_BYTES = 256 * 1024
|
MAX_LIVE_BACKLOG_BYTES = 256 * 1024
|
||||||
|
|
||||||
|
#: Finished live syncs kept per (user, source). Live sync opens a job for every
|
||||||
|
#: change it imports, and nothing removed them: hundreds a day on an active
|
||||||
|
#: machine. The App shows the latest job per source and the active ones; twenty is
|
||||||
|
#: that plus enough recent history to read a failure back.
|
||||||
|
SYNC_JOBS_KEPT_PER_SOURCE = 20
|
||||||
|
#: Rows one pruning transaction may delete, so it holds the single writer for
|
||||||
|
#: a few milliseconds at most however large the table grew.
|
||||||
|
PRUNE_BATCH_ROWS = 200
|
||||||
|
|
||||||
|
|
||||||
class ImportStatus(StrEnum):
|
class ImportStatus(StrEnum):
|
||||||
PENDING = "pending"
|
PENDING = "pending"
|
||||||
@@ -36,6 +51,12 @@ class ImportStatus(StrEnum):
|
|||||||
CANCELLED = "cancelled"
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
#: States no job ever leaves (`ImportJobStore._TRANSITIONS`). Failed and
|
||||||
|
#: interrupted are not among them: both can be resumed, and both still hold the
|
||||||
|
#: source's one active slot.
|
||||||
|
FINISHED_STATUSES = (ImportStatus.COMPLETED, ImportStatus.CANCELLED)
|
||||||
|
|
||||||
|
|
||||||
class ImportJob(BaseModel):
|
class ImportJob(BaseModel):
|
||||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||||
|
|
||||||
@@ -272,6 +293,9 @@ class ImportJobStore:
|
|||||||
WHERE status IN ('pending', 'running', 'paused', 'interrupted', 'failed')
|
WHERE status IN ('pending', 'running', 'paused', 'interrupted', 'failed')
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
for statement in JOB_LOOKUP_INDEXES.split(";"):
|
||||||
|
if statement.strip():
|
||||||
|
connection.execute(statement)
|
||||||
|
|
||||||
def create(
|
def create(
|
||||||
self,
|
self,
|
||||||
@@ -334,8 +358,8 @@ class ImportJobStore:
|
|||||||
read again.
|
read again.
|
||||||
"""
|
"""
|
||||||
opened: list[ImportJob] = []
|
opened: list[ImportJob] = []
|
||||||
for job in self.list_jobs(status=ImportStatus.FAILED):
|
for job in self.find_jobs(operation="history", statuses=(ImportStatus.FAILED,)):
|
||||||
if job.operation != "history" or job.stored != 0:
|
if job.stored != 0:
|
||||||
continue
|
continue
|
||||||
reasons = {issue.reason for issue in self.list_issues(job.job_id)}
|
reasons = {issue.reason for issue in self.list_issues(job.job_id)}
|
||||||
if not reasons or not reasons <= self._UNPUBLISHED_SNAPSHOT_FAILURES:
|
if not reasons or not reasons <= self._UNPUBLISHED_SNAPSHOT_FAILURES:
|
||||||
@@ -799,16 +823,172 @@ class ImportJobStore:
|
|||||||
return self.require(job_id)
|
return self.require(job_id)
|
||||||
|
|
||||||
def list_jobs(self, *, status: ImportStatus | None = None) -> list[ImportJob]:
|
def list_jobs(self, *, status: ImportStatus | None = None) -> list[ImportJob]:
|
||||||
|
"""Every job, oldest first. Hot paths ask `find_jobs` a narrower question."""
|
||||||
|
return self.find_jobs(statuses=(status,) if status is not None else None)
|
||||||
|
|
||||||
|
def find_jobs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str | None = None,
|
||||||
|
source_id: str | None = None,
|
||||||
|
operation: Literal["history", "sync"] | None = None,
|
||||||
|
statuses: Iterable[ImportStatus] | None = None,
|
||||||
|
newest_first: bool = False,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> list[ImportJob]:
|
||||||
|
"""The jobs matching every given filter, oldest first unless asked otherwise.
|
||||||
|
|
||||||
|
The filters are SQL, served by `import_jobs_lane` / `import_jobs_status`
|
||||||
|
(migration 63). Reading the whole table and filtering here was what
|
||||||
|
three bulk threads and three request threads were all inside, for tens
|
||||||
|
of seconds, while the loop waited on them.
|
||||||
|
"""
|
||||||
|
clauses: list[str] = []
|
||||||
|
parameters: list[str | int] = []
|
||||||
|
if user_id is not None:
|
||||||
|
clauses.append("user_id = ?")
|
||||||
|
parameters.append(user_id)
|
||||||
|
if source_id is not None:
|
||||||
|
clauses.append("source_id = ?")
|
||||||
|
parameters.append(source_id)
|
||||||
|
if operation is not None:
|
||||||
|
clauses.append("operation = ?")
|
||||||
|
parameters.append(operation)
|
||||||
|
if statuses is not None:
|
||||||
|
wanted = tuple(dict.fromkeys(ImportStatus(status) for status in statuses))
|
||||||
|
if not wanted:
|
||||||
|
return []
|
||||||
|
clauses.append(f"status IN ({', '.join('?' for _ in wanted)})")
|
||||||
|
parameters.extend(wanted)
|
||||||
query = "SELECT * FROM import_jobs"
|
query = "SELECT * FROM import_jobs"
|
||||||
parameters: tuple[str, ...] = ()
|
if clauses:
|
||||||
if status is not None:
|
query += " WHERE " + " AND ".join(clauses)
|
||||||
query += " WHERE status = ?"
|
query += (
|
||||||
parameters = (status,)
|
" ORDER BY created_at DESC, job_id DESC"
|
||||||
query += " ORDER BY created_at, job_id"
|
if newest_first
|
||||||
|
else " ORDER BY created_at, job_id"
|
||||||
|
)
|
||||||
|
if limit is not None:
|
||||||
|
if limit < 1:
|
||||||
|
raise ValueError("limit must be positive")
|
||||||
|
query += " LIMIT ?"
|
||||||
|
parameters.append(limit)
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
rows = connection.execute(query, parameters).fetchall()
|
rows = connection.execute(query, parameters).fetchall()
|
||||||
return [self._to_job(row) for row in rows]
|
return [self._to_job(row) for row in rows]
|
||||||
|
|
||||||
|
def latest_job(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
source_id: str,
|
||||||
|
operation: Literal["history", "sync"] | None = None,
|
||||||
|
statuses: Iterable[ImportStatus] | None = None,
|
||||||
|
) -> ImportJob | None:
|
||||||
|
jobs = self.find_jobs(
|
||||||
|
user_id=user_id,
|
||||||
|
source_id=source_id,
|
||||||
|
operation=operation,
|
||||||
|
statuses=statuses,
|
||||||
|
newest_first=True,
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
return jobs[0] if jobs else None
|
||||||
|
|
||||||
|
def prune_finished_sync_jobs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
keep: int = SYNC_JOBS_KEPT_PER_SOURCE,
|
||||||
|
max_rows: int = PRUNE_BATCH_ROWS,
|
||||||
|
) -> int:
|
||||||
|
"""Delete finished live-sync jobs beyond the newest `keep` of each source.
|
||||||
|
|
||||||
|
One bounded transaction: at most `max_rows` jobs, with their issues and
|
||||||
|
skip counts. Returns how many jobs went; fewer than `max_rows` means
|
||||||
|
nothing is left to prune, so calling again until then finishes the job.
|
||||||
|
|
||||||
|
Only `sync` jobs in a state nothing leaves (completed, cancelled) are
|
||||||
|
ever deleted. Failed and interrupted jobs hold the source's active slot
|
||||||
|
and can be resumed; history jobs are named by connection receipts, by
|
||||||
|
the onboarding gate and by the first-import review, and there are a
|
||||||
|
handful per source, so they are all kept. Cursors live in their own
|
||||||
|
tables keyed by file, not by job, so a pruned job takes no position
|
||||||
|
with it. The newest job of every source is always among those kept,
|
||||||
|
which is the one the App and live sync health name.
|
||||||
|
"""
|
||||||
|
if keep < 1:
|
||||||
|
raise ValueError("keep must be positive")
|
||||||
|
if max_rows < 1:
|
||||||
|
raise ValueError("max_rows must be positive")
|
||||||
|
finished = tuple(FINISHED_STATUSES)
|
||||||
|
placeholders = ", ".join("?" for _ in finished)
|
||||||
|
deleted = 0
|
||||||
|
with self._connect(writing=True) as connection:
|
||||||
|
lanes = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT user_id, source_id, COUNT(*) AS finished
|
||||||
|
FROM import_jobs
|
||||||
|
WHERE operation = 'sync' AND status IN ({placeholders})
|
||||||
|
GROUP BY user_id, source_id
|
||||||
|
HAVING COUNT(*) > ?
|
||||||
|
""",
|
||||||
|
(*finished, keep),
|
||||||
|
).fetchall()
|
||||||
|
for lane in lanes:
|
||||||
|
if deleted >= max_rows:
|
||||||
|
break
|
||||||
|
# The oldest job still kept: everything strictly older goes.
|
||||||
|
boundary = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT created_at, job_id FROM import_jobs
|
||||||
|
WHERE user_id = ? AND source_id = ? AND operation = 'sync'
|
||||||
|
AND status IN ({placeholders})
|
||||||
|
ORDER BY created_at DESC, job_id DESC
|
||||||
|
LIMIT 1 OFFSET ?
|
||||||
|
""",
|
||||||
|
(lane["user_id"], lane["source_id"], *finished, keep - 1),
|
||||||
|
).fetchone()
|
||||||
|
if boundary is None:
|
||||||
|
continue
|
||||||
|
victims = [
|
||||||
|
str(row["job_id"])
|
||||||
|
for row in connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT job_id FROM import_jobs
|
||||||
|
WHERE user_id = ? AND source_id = ? AND operation = 'sync'
|
||||||
|
AND status IN ({placeholders})
|
||||||
|
AND (created_at < ? OR (created_at = ? AND job_id < ?))
|
||||||
|
ORDER BY created_at, job_id
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
lane["user_id"],
|
||||||
|
lane["source_id"],
|
||||||
|
*finished,
|
||||||
|
boundary["created_at"],
|
||||||
|
boundary["created_at"],
|
||||||
|
boundary["job_id"],
|
||||||
|
max_rows - deleted,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if not victims:
|
||||||
|
continue
|
||||||
|
marks = ", ".join("?" for _ in victims)
|
||||||
|
# Foreign keys cascade these too; deleting them by name keeps
|
||||||
|
# the outcome the same on a connection that has them off.
|
||||||
|
connection.execute(f"DELETE FROM import_issues WHERE job_id IN ({marks})", victims)
|
||||||
|
connection.execute(
|
||||||
|
f"DELETE FROM import_skip_reason_counts WHERE job_id IN ({marks})", victims
|
||||||
|
)
|
||||||
|
removed = connection.execute(
|
||||||
|
f"DELETE FROM import_jobs WHERE job_id IN ({marks}) "
|
||||||
|
f"AND operation = 'sync' AND status IN ({placeholders})",
|
||||||
|
(*victims, *finished),
|
||||||
|
)
|
||||||
|
deleted += removed.rowcount
|
||||||
|
return deleted
|
||||||
|
|
||||||
def get_source_cursors(
|
def get_source_cursors(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from one_memory.imports.adapters import (
|
|||||||
)
|
)
|
||||||
from one_memory.imports.jobs import ImportJob, ImportJobStore, ImportStatus
|
from one_memory.imports.jobs import ImportJob, ImportJobStore, ImportStatus
|
||||||
from one_memory.imports.supervisor import ImportSupervisor
|
from one_memory.imports.supervisor import ImportSupervisor
|
||||||
from one_memory.user_scope import DEFAULT_USER_ID, lane_key, split_lane
|
from one_memory.user_scope import DEFAULT_USER_ID, split_lane
|
||||||
|
|
||||||
#: How long an automatic retry will wait for a wedged worker to notice cancel.
|
#: How long an automatic retry will wait for a wedged worker to notice cancel.
|
||||||
ABANDON_TIMEOUT_SECONDS = 5.0
|
ABANDON_TIMEOUT_SECONDS = 5.0
|
||||||
@@ -249,13 +249,15 @@ class PollingLiveSyncSupervisor:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
source_jobs = await in_the_background(self.job_store.list_jobs)
|
# This lane's unfinished jobs only: the newest unfinished sync, and
|
||||||
|
# whatever history shares its cursor. Asked in SQL; listing every
|
||||||
|
# job ever run and filtering here was the poll's biggest read.
|
||||||
|
source_jobs = await in_the_background(self._lane_jobs, source_id)
|
||||||
active_sync = next(
|
active_sync = next(
|
||||||
(
|
(
|
||||||
job
|
job
|
||||||
for job in reversed(source_jobs)
|
for job in reversed(source_jobs)
|
||||||
if lane_key(job.user_id, job.source_id) == source_id
|
if job.operation == "sync"
|
||||||
and job.operation == "sync"
|
|
||||||
and job.status
|
and job.status
|
||||||
in {
|
in {
|
||||||
ImportStatus.PENDING,
|
ImportStatus.PENDING,
|
||||||
@@ -275,7 +277,9 @@ class PollingLiveSyncSupervisor:
|
|||||||
active_sync.job_id
|
active_sync.job_id
|
||||||
):
|
):
|
||||||
attempted_at = datetime.now(UTC)
|
attempted_at = datetime.now(UTC)
|
||||||
self.import_supervisor.enqueue(active_sync.job_id)
|
await self.import_supervisor.enqueue_off_loop(
|
||||||
|
active_sync.job_id, job=active_sync
|
||||||
|
)
|
||||||
if not wait_for_completion:
|
if not wait_for_completion:
|
||||||
self._observe_sync(source_id, attempted_at, active_sync.job_id)
|
self._observe_sync(source_id, attempted_at, active_sync.job_id)
|
||||||
return active_sync
|
return active_sync
|
||||||
@@ -295,22 +299,20 @@ class PollingLiveSyncSupervisor:
|
|||||||
# by a process restart between yield and claim. Enqueue is
|
# by a process restart between yield and claim. Enqueue is
|
||||||
# the operation that actually creates the task.
|
# the operation that actually creates the task.
|
||||||
attempted_at = datetime.now(UTC)
|
attempted_at = datetime.now(UTC)
|
||||||
self.import_supervisor.resume(active_sync.job_id)
|
resumed = await self.import_supervisor.resume_off_loop(active_sync.job_id)
|
||||||
self.import_supervisor.enqueue(active_sync.job_id)
|
await self.import_supervisor.enqueue_off_loop(active_sync.job_id, job=resumed)
|
||||||
completed = await self.import_supervisor.wait(active_sync.job_id)
|
completed = await self.import_supervisor.wait(active_sync.job_id)
|
||||||
self._record_outcome(source_id, attempted_at, completed)
|
self._record_outcome(source_id, attempted_at, completed)
|
||||||
return completed
|
return completed
|
||||||
competing = [
|
competing = [
|
||||||
job
|
job
|
||||||
for job in source_jobs
|
for job in source_jobs
|
||||||
if lane_key(job.user_id, job.source_id) == source_id
|
if job.operation != "sync"
|
||||||
and job.operation != "sync"
|
|
||||||
and job.status
|
and job.status
|
||||||
in {ImportStatus.PENDING, ImportStatus.RUNNING, ImportStatus.INTERRUPTED}
|
in {ImportStatus.PENDING, ImportStatus.RUNNING, ImportStatus.INTERRUPTED}
|
||||||
]
|
]
|
||||||
bounded_history_incomplete = any(
|
bounded_history_incomplete = any(
|
||||||
lane_key(job.user_id, job.source_id) == source_id
|
job.operation == "history"
|
||||||
and job.operation == "history"
|
|
||||||
and job.history_after is not None
|
and job.history_after is not None
|
||||||
and job.status is not ImportStatus.COMPLETED
|
and job.status is not ImportStatus.COMPLETED
|
||||||
for job in source_jobs
|
for job in source_jobs
|
||||||
@@ -354,6 +356,38 @@ class PollingLiveSyncSupervisor:
|
|||||||
# automatically after the realtime slice completes.
|
# automatically after the realtime slice completes.
|
||||||
return await self._run_sync(source_id, wait_for_completion=wait_for_completion)
|
return await self._run_sync(source_id, wait_for_completion=wait_for_completion)
|
||||||
|
|
||||||
|
def _lane_jobs(self, source_id: str) -> list[ImportJob]:
|
||||||
|
"""The lane's jobs `_sync` decides on, oldest first.
|
||||||
|
|
||||||
|
Everything not completed, of either operation: an unfinished sync to
|
||||||
|
pick up, a history that competes for the cursor, and a bounded history
|
||||||
|
that has not completed yet (which includes a cancelled one). Finished
|
||||||
|
syncs, the rows that grow without end, are never read.
|
||||||
|
"""
|
||||||
|
user_id, bare_source_id = split_lane(source_id)
|
||||||
|
unfinished = tuple(
|
||||||
|
status for status in ImportStatus if status is not ImportStatus.COMPLETED
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
*self.job_store.find_jobs(
|
||||||
|
user_id=user_id,
|
||||||
|
source_id=bare_source_id,
|
||||||
|
operation="history",
|
||||||
|
statuses=unfinished,
|
||||||
|
),
|
||||||
|
*self.job_store.find_jobs(
|
||||||
|
user_id=user_id,
|
||||||
|
source_id=bare_source_id,
|
||||||
|
operation="sync",
|
||||||
|
statuses=(
|
||||||
|
ImportStatus.PENDING,
|
||||||
|
ImportStatus.RUNNING,
|
||||||
|
ImportStatus.PAUSED,
|
||||||
|
ImportStatus.INTERRUPTED,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
async def _has_work(self, source_id: str) -> bool:
|
async def _has_work(self, source_id: str) -> bool:
|
||||||
"""Ask the source whether a scan would write anything, before taking a job out.
|
"""Ask the source whether a scan would write anything, before taking a job out.
|
||||||
|
|
||||||
@@ -452,7 +486,7 @@ class PollingLiveSyncSupervisor:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
self._active_jobs[source_id] = job.job_id
|
self._active_jobs[source_id] = job.job_id
|
||||||
self.import_supervisor.enqueue(job.job_id)
|
await self.import_supervisor.enqueue_off_loop(job.job_id, job=job)
|
||||||
if not wait_for_completion:
|
if not wait_for_completion:
|
||||||
self._observe_sync(source_id, attempted_at, job.job_id)
|
self._observe_sync(source_id, attempted_at, job.job_id)
|
||||||
return job
|
return job
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from watchfiles import awatch
|
from watchfiles import awatch
|
||||||
|
|
||||||
|
from one_memory.background_work import in_the_background
|
||||||
from one_memory.diagnostic_log import diagnostic_event
|
from one_memory.diagnostic_log import diagnostic_event
|
||||||
from one_memory.imports.jobs import ImportStatus
|
from one_memory.imports.jobs import ImportStatus
|
||||||
from one_memory.imports.live_sync import PollingLiveSyncSupervisor
|
from one_memory.imports.live_sync import PollingLiveSyncSupervisor
|
||||||
@@ -90,7 +91,8 @@ class FileWatchLiveSync:
|
|||||||
# turn that caused setup. The watcher cannot observe a change
|
# turn that caused setup. The watcher cannot observe a change
|
||||||
# from before it existed, so immediately run the same bounded
|
# from before it existed, so immediately run the same bounded
|
||||||
# trigger once instead of waiting for the 60-second backstop.
|
# trigger once instead of waiting for the 60-second backstop.
|
||||||
self._queue_changes(set(self.roots.values()))
|
# The roots are this watcher's own sources: nothing to resolve.
|
||||||
|
self._queue_sources(tuple(self.roots), count=len(self.roots))
|
||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
task = self._task
|
task = self._task
|
||||||
@@ -139,28 +141,54 @@ class FileWatchLiveSync:
|
|||||||
observed_at: datetime | None = None,
|
observed_at: datetime | None = None,
|
||||||
) -> tuple[str, ...]:
|
) -> tuple[str, ...]:
|
||||||
"""Queue affected sources and wait for their coalesced work to settle."""
|
"""Queue affected sources and wait for their coalesced work to settle."""
|
||||||
workers = self._queue_changes(changed, observed_at=observed_at)
|
workers = await self._queue_changes(changed, observed_at=observed_at)
|
||||||
await asyncio.gather(*(asyncio.shield(worker) for worker in workers.values()))
|
await asyncio.gather(*(asyncio.shield(worker) for worker in workers.values()))
|
||||||
return tuple(workers)
|
return tuple(workers)
|
||||||
|
|
||||||
def _queue_changes(
|
def sources_for(self, changed: set[Path]) -> tuple[str, ...]:
|
||||||
|
"""The sources owning `changed`, in path order. Resolves every path."""
|
||||||
|
return tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
source
|
||||||
|
for source in (self.source_for(path) for path in sorted(changed))
|
||||||
|
if source is not None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _queue_changes(
|
||||||
self, changed: set[Path], *, observed_at: datetime | None = None
|
self, changed: set[Path], *, observed_at: datetime | None = None
|
||||||
|
) -> dict[str, asyncio.Task[None]]:
|
||||||
|
"""Attribute a batch of changed paths to sources and queue them.
|
||||||
|
|
||||||
|
Attributing resolves each path, a `stat` per component, and a busy
|
||||||
|
host reports hundreds of paths at once. On the loop that was the main
|
||||||
|
thread inside `source_for` while nothing else was answered, so the
|
||||||
|
resolving happens off it and only the queueing here.
|
||||||
|
"""
|
||||||
|
if self._stop.is_set():
|
||||||
|
return {}
|
||||||
|
observed = observed_at or datetime.now(UTC)
|
||||||
|
sources = await in_the_background(self.sources_for, changed)
|
||||||
|
return self._queue_sources(sources, count=len(changed), observed_at=observed)
|
||||||
|
|
||||||
|
def _queue_sources(
|
||||||
|
self,
|
||||||
|
sources: tuple[str, ...],
|
||||||
|
*,
|
||||||
|
count: int,
|
||||||
|
observed_at: datetime | None = None,
|
||||||
) -> dict[str, asyncio.Task[None]]:
|
) -> dict[str, asyncio.Task[None]]:
|
||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
return {}
|
return {}
|
||||||
observed = observed_at or datetime.now(UTC)
|
observed = observed_at or datetime.now(UTC)
|
||||||
workers: dict[str, asyncio.Task[None]] = {}
|
workers: dict[str, asyncio.Task[None]] = {}
|
||||||
for source_id in dict.fromkeys(
|
for source_id in sources:
|
||||||
source
|
|
||||||
for source in (self.source_for(path) for path in sorted(changed))
|
|
||||||
if source is not None
|
|
||||||
):
|
|
||||||
diagnostic_event(
|
diagnostic_event(
|
||||||
level="debug",
|
level="debug",
|
||||||
component="import.live_watch",
|
component="import.live_watch",
|
||||||
event="live_sync.hook_received",
|
event="live_sync.hook_received",
|
||||||
outcome="queued",
|
outcome="queued",
|
||||||
details={"source_id": source_id, "count": len(changed)},
|
details={"source_id": source_id, "count": count},
|
||||||
)
|
)
|
||||||
self._pending[source_id] = max(self._pending.get(source_id, observed), observed)
|
self._pending[source_id] = max(self._pending.get(source_id, observed), observed)
|
||||||
worker = self._workers.get(source_id)
|
worker = self._workers.get(source_id)
|
||||||
@@ -239,7 +267,7 @@ class FileWatchLiveSync:
|
|||||||
stop_event=self._stop,
|
stop_event=self._stop,
|
||||||
ignore_permission_denied=True,
|
ignore_permission_denied=True,
|
||||||
):
|
):
|
||||||
self._queue_changes({Path(path) for _change, path in changes})
|
await self._queue_changes({Path(path) for _change, path in changes})
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ class JsonlSourceAdapter:
|
|||||||
file_predicate: Callable[[AuthorizedSourceFile], bool] | None = None,
|
file_predicate: Callable[[AuthorizedSourceFile], bool] | None = None,
|
||||||
suffixes: frozenset[str] = frozenset({".jsonl"}),
|
suffixes: frozenset[str] = frozenset({".jsonl"}),
|
||||||
header_selector: Callable[[dict[str, JsonValue]], bool] | None = None,
|
header_selector: Callable[[dict[str, JsonValue]], bool] | None = None,
|
||||||
|
header_gate: Callable[[dict[str, JsonValue] | None], bool] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if descriptor.source_id != grant.source_id:
|
if descriptor.source_id != grant.source_id:
|
||||||
raise ValueError("Descriptor and authorized root source IDs must match")
|
raise ValueError("Descriptor and authorized root source IDs must match")
|
||||||
@@ -219,6 +220,10 @@ class JsonlSourceAdapter:
|
|||||||
self.file_predicate = file_predicate or (lambda _: True)
|
self.file_predicate = file_predicate or (lambda _: True)
|
||||||
self.suffixes = suffixes
|
self.suffixes = suffixes
|
||||||
self.header_selector = header_selector
|
self.header_selector = header_selector
|
||||||
|
# False refuses the whole file before any record is read. It reports
|
||||||
|
# and leaves no cursor, so a release that learns the format reads the
|
||||||
|
# file from the top instead of finding it already behind a checkpoint.
|
||||||
|
self.header_gate = header_gate
|
||||||
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
|
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
|
||||||
self._history_listing_cache: SourceListing | None = None
|
self._history_listing_cache: SourceListing | None = None
|
||||||
|
|
||||||
@@ -371,6 +376,9 @@ class JsonlSourceAdapter:
|
|||||||
source_file, checkpoint, header = await asyncio.to_thread(
|
source_file, checkpoint, header = await asyncio.to_thread(
|
||||||
self._prepare, listed, options
|
self._prepare, listed, options
|
||||||
)
|
)
|
||||||
|
if self.header_gate is not None and not self.header_gate(header):
|
||||||
|
yield ScanFailure(reason_code=ScanIssueCode.UNSUPPORTED_SCHEMA)
|
||||||
|
continue
|
||||||
_log.debug(
|
_log.debug(
|
||||||
"import_file_read source=%s file=%d/%d file_bytes=%d planned_bytes=%d",
|
"import_file_read source=%s file=%d/%d file_bytes=%d planned_bytes=%d",
|
||||||
self.descriptor.source_id,
|
self.descriptor.source_id,
|
||||||
@@ -574,6 +582,9 @@ class JsonlSourceAdapter:
|
|||||||
# resumed scan starts past the header, and the records after it need
|
# resumed scan starts past the header, and the records after it need
|
||||||
# it just as much as the first batch did.
|
# it just as much as the first batch did.
|
||||||
header = await _read_header(reader, source_file, self.header_selector)
|
header = await _read_header(reader, source_file, self.header_selector)
|
||||||
|
if self.header_gate is not None and not self.header_gate(header):
|
||||||
|
yield ScanFailure(reason_code=ScanIssueCode.UNSUPPORTED_SCHEMA)
|
||||||
|
continue
|
||||||
last: SourceCursor = _start_cursor(source_file, options, checkpoint)
|
last: SourceCursor = _start_cursor(source_file, options, checkpoint)
|
||||||
async for raw in iterate_in_worker(
|
async for raw in iterate_in_worker(
|
||||||
partial(reader, source_file, checkpoint=checkpoint),
|
partial(reader, source_file, checkpoint=checkpoint),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
from pydantic import JsonValue
|
from pydantic import JsonValue
|
||||||
@@ -37,6 +38,19 @@ DEEPSEEK_HARNESS_DESCRIPTOR = SourceDescriptor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: One session is one file, and the name carries the format generation it was
|
||||||
|
#: written in: `session.jsonl[.zstd]` for the first format, then
|
||||||
|
#: `session.v3.jsonl.zstd` and `session.v4.jsonl.zstd`. The version segment is
|
||||||
|
#: part of the published on-disk contract, so discovery must accept it — the
|
||||||
|
#: earlier exact-name match imported none of the versioned files.
|
||||||
|
SESSION_FILE = re.compile(r"^session(?:\.v[0-9]+)?\.jsonl(?:\.zstd)?$")
|
||||||
|
|
||||||
|
#: Session generations this parser has been verified against: the first format
|
||||||
|
#: (header `version: 0`), v3 and v4. Discovery is intentionally wider than
|
||||||
|
#: parsing: a future generation is found by pattern and then skipped from its
|
||||||
|
#: header, so it is reported as unsupported instead of silently vanishing.
|
||||||
|
SUPPORTED_SESSION_VERSIONS = frozenset({0, 3, 4})
|
||||||
|
|
||||||
|
|
||||||
def create_deepseek_harness_adapter(
|
def create_deepseek_harness_adapter(
|
||||||
grant: AuthorizedSourceRoot,
|
grant: AuthorizedSourceRoot,
|
||||||
@@ -49,25 +63,43 @@ def create_deepseek_harness_adapter(
|
|||||||
grant=grant,
|
grant=grant,
|
||||||
parser=parse_deepseek_harness_record,
|
parser=parse_deepseek_harness_record,
|
||||||
source_version=source_version,
|
source_version=source_version,
|
||||||
file_predicate=lambda source_file: (
|
file_predicate=lambda source_file: bool(SESSION_FILE.match(source_file.relative_path.name)),
|
||||||
source_file.relative_path.name in {"session.jsonl", "session.jsonl.zstd"}
|
|
||||||
),
|
|
||||||
suffixes=frozenset({".jsonl", ".zstd"}),
|
suffixes=frozenset({".jsonl", ".zstd"}),
|
||||||
header_selector=_is_session_header,
|
header_selector=_is_session_header,
|
||||||
|
header_gate=_supported_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_session_header(record: dict[str, JsonValue]) -> bool:
|
def _is_session_header(record: dict[str, JsonValue]) -> bool:
|
||||||
"""Harness writes the session's `cwd` once, in the first line only.
|
"""Harness writes the session's `cwd` once, in the first line only.
|
||||||
|
|
||||||
Verified against a session this machine produced: the header is
|
Verified against sessions this machine produced: the header is
|
||||||
`{"type":"session","version":0,"id":...,"cwd":"/private/tmp/dsh-verify"}`
|
`{"type":"session","version":0|4,"id":...,"cwd":...}` and no later record
|
||||||
and no later record repeats it. Read per-record, `cwd` is therefore always
|
repeats it. Read per-record, `cwd` is therefore always absent and every turn
|
||||||
absent and every turn imports with no workspace.
|
imports with no workspace.
|
||||||
"""
|
"""
|
||||||
return record.get("type") == "session"
|
return record.get("type") == "session"
|
||||||
|
|
||||||
|
|
||||||
|
def _supported_session(header: dict[str, JsonValue] | None) -> bool:
|
||||||
|
"""Whether the session's declared format generation is one we can read.
|
||||||
|
|
||||||
|
A missing header, or a header without `version`, is the first format — the
|
||||||
|
earliest files this adapter already read. Any declared version outside
|
||||||
|
`SUPPORTED_SESSION_VERSIONS` is refused rather than interpreted with today's
|
||||||
|
field rules: a future format may change field semantics, and importing it
|
||||||
|
wrongly is harder to undo than not importing it yet.
|
||||||
|
"""
|
||||||
|
if header is None:
|
||||||
|
return True
|
||||||
|
version = header.get("version")
|
||||||
|
if version is None:
|
||||||
|
return True
|
||||||
|
if not isinstance(version, int) or isinstance(version, bool):
|
||||||
|
return False
|
||||||
|
return version in SUPPORTED_SESSION_VERSIONS
|
||||||
|
|
||||||
|
|
||||||
def parse_deepseek_harness_record(
|
def parse_deepseek_harness_record(
|
||||||
record: dict[str, JsonValue],
|
record: dict[str, JsonValue],
|
||||||
context: SourceRecordContext,
|
context: SourceRecordContext,
|
||||||
@@ -80,6 +112,11 @@ def parse_deepseek_harness_record(
|
|||||||
)
|
)
|
||||||
event_type = record.get("type")
|
event_type = record.get("type")
|
||||||
data = as_record(record.get("data"))
|
data = as_record(record.get("data"))
|
||||||
|
if not _supported_session(context.header):
|
||||||
|
return ParsedSkip(
|
||||||
|
reason_code=ScanIssueCode.UNSUPPORTED_SCHEMA,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
)
|
||||||
if event_type == "session":
|
if event_type == "session":
|
||||||
return ParsedSkip(reason_code=ScanIssueCode.UNKNOWN_RECORD)
|
return ParsedSkip(reason_code=ScanIssueCode.UNKNOWN_RECORD)
|
||||||
if data is None:
|
if data is None:
|
||||||
@@ -148,11 +185,18 @@ def _tool_message(
|
|||||||
"""`tool/call` carries name and arguments; `tool/result` a user-role message.
|
"""`tool/call` carries name and arguments; `tool/result` a user-role message.
|
||||||
|
|
||||||
The same call also appears as a `tool-call` block in `assistant/message`;
|
The same call also appears as a `tool-call` block in `assistant/message`;
|
||||||
only the event is read, so it is stored once.
|
only the event is read, so it is stored once. The first format and v3 wrap
|
||||||
|
the result in `tool-result` blocks; v4 writes plain `text` blocks, so both
|
||||||
|
shapes are read through the same clipping and credential-scrubbing path.
|
||||||
"""
|
"""
|
||||||
message = as_record(data.get("message"))
|
message = as_record(data.get("message"))
|
||||||
if record.get("type") == "tool/result" and message is not None:
|
if record.get("type") == "tool/result" and message is not None:
|
||||||
text = tool_blocks_evidence(message.get("content"), block_types=frozenset({"tool-result"}))
|
text = tool_blocks_evidence(message.get("content"), block_types=frozenset({"tool-result"}))
|
||||||
|
if text is None:
|
||||||
|
text = tool_blocks_evidence(
|
||||||
|
[{"type": "tool-result", "content": message.get("content")}],
|
||||||
|
block_types=frozenset({"tool-result"}),
|
||||||
|
)
|
||||||
message_id = first_string(message.get("id"), data.get("callId"))
|
message_id = first_string(message.get("id"), data.get("callId"))
|
||||||
else:
|
else:
|
||||||
text = tool_evidence_text(data)
|
text = tool_evidence_text(data)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from collections.abc import AsyncIterator, Iterator
|
from collections.abc import AsyncIterator, Iterable, Iterator
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
from pydantic import JsonValue
|
from pydantic import JsonValue
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ class HermesSourceAdapter:
|
|||||||
self.grant,
|
self.grant,
|
||||||
suffixes=frozenset({".jsonl", ".db"}),
|
suffixes=frozenset({".jsonl", ".db"}),
|
||||||
max_targets=10_000,
|
max_targets=10_000,
|
||||||
excluded_top_directories=frozenset({HERMES_PROFILES_DIRECTORY}),
|
excluded_directory_names=frozenset({HERMES_PROFILES_DIRECTORY}),
|
||||||
):
|
):
|
||||||
if isinstance(item, AuthorizedSourceFile) and _is_hermes_target(
|
if isinstance(item, AuthorizedSourceFile) and _is_hermes_target(
|
||||||
item.relative_path.as_posix()
|
item.relative_path.as_posix()
|
||||||
@@ -102,7 +102,7 @@ class HermesSourceAdapter:
|
|||||||
self.grant,
|
self.grant,
|
||||||
suffixes=frozenset({".jsonl", ".db"}),
|
suffixes=frozenset({".jsonl", ".db"}),
|
||||||
max_targets=options.max_scan_targets or 100_000,
|
max_targets=options.max_scan_targets or 100_000,
|
||||||
excluded_top_directories=frozenset({HERMES_PROFILES_DIRECTORY}),
|
excluded_directory_names=frozenset({HERMES_PROFILES_DIRECTORY}),
|
||||||
):
|
):
|
||||||
yield ScanProgressEvent(
|
yield ScanProgressEvent(
|
||||||
phase=ScanPhase.DISCOVER,
|
phase=ScanPhase.DISCOVER,
|
||||||
@@ -253,16 +253,62 @@ def parse_hermes_rollout_record(
|
|||||||
#: own `state.db` and sessions. A profile is another person's history: it is
|
#: own `state.db` and sessions. A profile is another person's history: it is
|
||||||
#: read only through its own grant (its own MemHub user), never as part of the
|
#: read only through its own grant (its own MemHub user), never as part of the
|
||||||
#: root above it. Hermes' own export skips this directory for the same reason.
|
#: root above it. Hermes' own export skips this directory for the same reason.
|
||||||
|
#:
|
||||||
|
#: Skipped at any depth, not only directly under the root: a root granted
|
||||||
|
#: above the Hermes home holds `.hermes/profiles/`. Hermes keeps nothing of
|
||||||
|
#: its own under a directory of that name (its history is `state.db` and
|
||||||
|
#: `sessions/`), so nothing real is lost.
|
||||||
HERMES_PROFILES_DIRECTORY = "profiles"
|
HERMES_PROFILES_DIRECTORY = "profiles"
|
||||||
|
|
||||||
|
|
||||||
def _is_hermes_target(relative_path: str) -> bool:
|
def _is_hermes_target(relative_path: str) -> bool:
|
||||||
path = PurePosixPath(relative_path)
|
path = PurePosixPath(relative_path)
|
||||||
if path.parts and path.parts[0] == HERMES_PROFILES_DIRECTORY:
|
if HERMES_PROFILES_DIRECTORY in path.parts[:-1]:
|
||||||
return False
|
return False
|
||||||
return path.name == "state.db" or (path.suffix == ".jsonl" and "sessions" in path.parts)
|
return path.name == "state.db" or (path.suffix == ".jsonl" and "sessions" in path.parts)
|
||||||
|
|
||||||
|
|
||||||
|
def hermes_root_is_profiles_container(root: Path) -> bool:
|
||||||
|
"""`profiles` itself holds everyone's profiles; it is never one person's history."""
|
||||||
|
return root.name == HERMES_PROFILES_DIRECTORY
|
||||||
|
|
||||||
|
|
||||||
|
def hermes_root_conflict(mine: Path, other_users_roots: Iterable[Path]) -> str | None:
|
||||||
|
"""Why the resolved root `mine` may not be granted beside other users' Hermes roots.
|
||||||
|
|
||||||
|
Paths are compared resolved (symlinks followed; the other users' roots are
|
||||||
|
resolved here). Two roots that are equal or nest would read the same files
|
||||||
|
for two users, except when one is exactly the other's `profiles/<name>`:
|
||||||
|
the scan above never enters `profiles/`, so the two stay apart (`local` on
|
||||||
|
`~/.hermes`, a user on `~/.hermes/profiles/<name>`). None when it may.
|
||||||
|
"""
|
||||||
|
if hermes_root_is_profiles_container(mine):
|
||||||
|
return HERMES_ROOT_IS_PROFILES_CONTAINER
|
||||||
|
for other in other_users_roots:
|
||||||
|
theirs = _real(other)
|
||||||
|
if mine == theirs:
|
||||||
|
return HERMES_ROOT_OVERLAPS_OTHER_USER
|
||||||
|
for parent, child in ((mine, theirs), (theirs, mine)):
|
||||||
|
if parent in child.parents:
|
||||||
|
relative = child.relative_to(parent).parts
|
||||||
|
if len(relative) != 2 or relative[0] != HERMES_PROFILES_DIRECTORY:
|
||||||
|
return HERMES_ROOT_OVERLAPS_OTHER_USER
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
#: 422: the root is the `profiles` container, not a profile.
|
||||||
|
HERMES_ROOT_IS_PROFILES_CONTAINER = "hermes_root_is_profiles_container"
|
||||||
|
#: 409: the root is, holds or sits inside another user's Hermes root.
|
||||||
|
HERMES_ROOT_OVERLAPS_OTHER_USER = "hermes_root_overlaps_other_user"
|
||||||
|
|
||||||
|
|
||||||
|
def _real(path: Path) -> Path:
|
||||||
|
try:
|
||||||
|
return path.expanduser().resolve(strict=False)
|
||||||
|
except (OSError, RuntimeError):
|
||||||
|
return path.expanduser().absolute()
|
||||||
|
|
||||||
|
|
||||||
def _read_rollout(
|
def _read_rollout(
|
||||||
source_file: AuthorizedSourceFile,
|
source_file: AuthorizedSourceFile,
|
||||||
options: ScanOptions,
|
options: ScanOptions,
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ from pydantic import JsonValue
|
|||||||
from one_memory.input_sanitization import sanitize_model_input
|
from one_memory.input_sanitization import sanitize_model_input
|
||||||
|
|
||||||
_REMINDER_TAGS = r"additional_data|system[-_]reminder|working_memory_reminder"
|
_REMINDER_TAGS = r"additional_data|system[-_]reminder|working_memory_reminder"
|
||||||
_CONTEXT_TAGS = r"user_info|rules|agent_transcripts|system[-_]notification|pipeline_log"
|
_CONTEXT_TAGS = (
|
||||||
|
r"user_info|rules|agent_transcripts|attached_files|image_local_path|"
|
||||||
|
r"system[-_]notification|pipeline_log"
|
||||||
|
)
|
||||||
_TAG_ATTRIBUTES = r"""(?:\s+[\w:-]+\s*=\s*(?:"[^"<>]*"|'[^'<>]*'))*\s*"""
|
_TAG_ATTRIBUTES = r"""(?:\s+[\w:-]+\s*=\s*(?:"[^"<>]*"|'[^'<>]*'))*\s*"""
|
||||||
_HOST_PREFIX = re.compile(rf"<(?P<tag>{_REMINDER_TAGS}|{_CONTEXT_TAGS}){_TAG_ATTRIBUTES}>")
|
_HOST_PREFIX = re.compile(rf"<(?P<tag>{_REMINDER_TAGS}|{_CONTEXT_TAGS}){_TAG_ATTRIBUTES}>")
|
||||||
_REMINDER_START = re.compile(rf"<(?P<tag>{_REMINDER_TAGS}){_TAG_ATTRIBUTES}>")
|
_REMINDER_START = re.compile(rf"<(?P<tag>{_REMINDER_TAGS}){_TAG_ATTRIBUTES}>")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Awaitable, Callable, Mapping
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from functools import partial
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
@@ -82,6 +83,10 @@ class ImportSupervisor:
|
|||||||
)
|
)
|
||||||
self._tasks: dict[str, asyncio.Task[None]] = {}
|
self._tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
self._drains: dict[str, asyncio.Task[None]] = {}
|
self._drains: dict[str, asyncio.Task[None]] = {}
|
||||||
|
#: Pause and cancel settle a stopped job with a read and a transition.
|
||||||
|
#: Made on the loop, two of them could not interleave; made off it,
|
||||||
|
#: they are serialized here so they still land in the order they woke.
|
||||||
|
self._settling = asyncio.Lock()
|
||||||
|
|
||||||
def register_adapter(self, adapter: SourceAdapter, *, user_id: str = "local") -> None:
|
def register_adapter(self, adapter: SourceAdapter, *, user_id: str = "local") -> None:
|
||||||
"""Adapters are keyed by lane: the source id for `local`, `source@user`
|
"""Adapters are keyed by lane: the source id for `local`, `source@user`
|
||||||
@@ -116,11 +121,44 @@ class ImportSupervisor:
|
|||||||
raise UnknownSourceError(lane)
|
raise UnknownSourceError(lane)
|
||||||
if self.source_enabled is not None and not self.source_enabled(lane):
|
if self.source_enabled is not None and not self.source_enabled(lane):
|
||||||
return job
|
return job
|
||||||
existing = self._tasks.get(job_id)
|
self._start(job)
|
||||||
|
return job
|
||||||
|
|
||||||
|
async def enqueue_off_loop(self, job_id: str, *, job: ImportJob | None = None) -> ImportJob:
|
||||||
|
"""`enqueue`, with its job read and its grant check made off the loop.
|
||||||
|
|
||||||
|
Both are database reads, and the grant check resolves the source root
|
||||||
|
on disk. Live sync enqueues from its poll, so on the loop they were
|
||||||
|
the main thread waiting for a reader behind an import peak. `job` is
|
||||||
|
the row the caller has just read, when it has one.
|
||||||
|
"""
|
||||||
|
if job_id in self._drains:
|
||||||
|
raise InvalidJobTransitionError("import_cleanup_in_progress")
|
||||||
|
|
||||||
|
def admit() -> tuple[ImportJob, bool]:
|
||||||
|
current = job if job is not None and job.job_id == job_id else None
|
||||||
|
if current is None:
|
||||||
|
current = self.job_store.require(job_id)
|
||||||
|
lane = self.lane_of(current)
|
||||||
|
return current, self.source_enabled is None or self.source_enabled(lane)
|
||||||
|
|
||||||
|
current, enabled = await in_the_background(admit)
|
||||||
|
lane = self.lane_of(current)
|
||||||
|
if lane not in self.adapters:
|
||||||
|
raise UnknownSourceError(lane)
|
||||||
|
if job_id in self._drains:
|
||||||
|
raise InvalidJobTransitionError("import_cleanup_in_progress")
|
||||||
|
if enabled:
|
||||||
|
self._start(current)
|
||||||
|
return current
|
||||||
|
|
||||||
|
def _start(self, job: ImportJob) -> None:
|
||||||
|
lane = self.lane_of(job)
|
||||||
|
existing = self._tasks.get(job.job_id)
|
||||||
if existing is None or existing.done():
|
if existing is None or existing.done():
|
||||||
self._tasks[job_id] = asyncio.create_task(
|
self._tasks[job.job_id] = asyncio.create_task(
|
||||||
self._execute(
|
self._execute(
|
||||||
job_id,
|
job.job_id,
|
||||||
source_id=lane,
|
source_id=lane,
|
||||||
operation=job.operation,
|
operation=job.operation,
|
||||||
)
|
)
|
||||||
@@ -130,10 +168,24 @@ class ImportSupervisor:
|
|||||||
component="import.supervisor",
|
component="import.supervisor",
|
||||||
event="import.queued",
|
event="import.queued",
|
||||||
outcome="queued",
|
outcome="queued",
|
||||||
job_id=job_id,
|
job_id=job.job_id,
|
||||||
details={"source_id": job.source_id, "mode": job.operation},
|
details={"source_id": job.source_id, "mode": job.operation},
|
||||||
)
|
)
|
||||||
return job
|
|
||||||
|
async def resume_interrupted_off_loop(self) -> None:
|
||||||
|
"""`resume_interrupted` for the running Core: every read and write off the loop."""
|
||||||
|
for status in (ImportStatus.INTERRUPTED, ImportStatus.PENDING):
|
||||||
|
jobs = await in_the_background(partial(self.job_store.find_jobs, statuses=(status,)))
|
||||||
|
for job in jobs:
|
||||||
|
if self.lane_of(job) not in self.adapters:
|
||||||
|
continue
|
||||||
|
if job.status is not ImportStatus.PENDING:
|
||||||
|
if self.source_enabled is not None and not await in_the_background(
|
||||||
|
self.source_enabled, self.lane_of(job)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
job = await in_the_background(self.job_store.queue_for_resume, job.job_id)
|
||||||
|
await self.enqueue_off_loop(job.job_id, job=job)
|
||||||
|
|
||||||
def resume_interrupted(self) -> None:
|
def resume_interrupted(self) -> None:
|
||||||
# A process restart must not place recoverable work behind every newly
|
# A process restart must not place recoverable work behind every newly
|
||||||
@@ -166,40 +218,64 @@ class ImportSupervisor:
|
|||||||
self.enqueue(job_id)
|
self.enqueue(job_id)
|
||||||
return queued
|
return queued
|
||||||
|
|
||||||
|
async def resume_off_loop(self, job_id: str) -> ImportJob:
|
||||||
|
"""`resume`, with its reads and its write made off the event loop."""
|
||||||
|
if job_id in self._drains:
|
||||||
|
raise InvalidJobTransitionError("import_cleanup_in_progress")
|
||||||
|
queued = await in_the_background(self._queue_resumable, job_id)
|
||||||
|
if queued.status is not ImportStatus.PENDING:
|
||||||
|
return queued
|
||||||
|
return await self.enqueue_off_loop(job_id, job=queued)
|
||||||
|
|
||||||
|
def _queue_resumable(self, job_id: str) -> ImportJob:
|
||||||
|
job = self.job_store.require(job_id)
|
||||||
|
if job.status not in {
|
||||||
|
ImportStatus.PENDING,
|
||||||
|
ImportStatus.PAUSED,
|
||||||
|
ImportStatus.INTERRUPTED,
|
||||||
|
ImportStatus.FAILED,
|
||||||
|
}:
|
||||||
|
return job
|
||||||
|
return self.job_store.queue_for_resume(job_id)
|
||||||
|
|
||||||
async def wait(self, job_id: str) -> ImportJob:
|
async def wait(self, job_id: str) -> ImportJob:
|
||||||
task = self._tasks.get(job_id)
|
task = self._tasks.get(job_id)
|
||||||
if task is not None:
|
if task is not None:
|
||||||
await asyncio.shield(task)
|
await asyncio.shield(task)
|
||||||
return self.job_store.require(job_id)
|
return await in_the_background(self.job_store.require, job_id)
|
||||||
|
|
||||||
async def pause(self, job_id: str) -> ImportJob:
|
async def pause(self, job_id: str) -> ImportJob:
|
||||||
job = self.job_store.require(job_id)
|
job = await in_the_background(self.job_store.require, job_id)
|
||||||
if job.status in {ImportStatus.PAUSED, ImportStatus.INTERRUPTED}:
|
if job.status in {ImportStatus.PAUSED, ImportStatus.INTERRUPTED}:
|
||||||
return job
|
return job
|
||||||
await self._cancel_task(job_id)
|
await self._cancel_task(job_id)
|
||||||
current = self.job_store.require(job_id)
|
async with self._settling:
|
||||||
if current.status is ImportStatus.COMPLETED:
|
return await in_the_background(self._settle_stopped, job_id, ImportStatus.PAUSED)
|
||||||
return current
|
|
||||||
return self.job_store.transition(job_id, ImportStatus.PAUSED)
|
|
||||||
|
|
||||||
async def cancel(self, job_id: str) -> ImportJob:
|
async def cancel(self, job_id: str) -> ImportJob:
|
||||||
job = self.job_store.require(job_id)
|
job = await in_the_background(self.job_store.require, job_id)
|
||||||
if job.status is ImportStatus.CANCELLED:
|
if job.status is ImportStatus.CANCELLED:
|
||||||
return job
|
return job
|
||||||
await self._cancel_task(job_id)
|
await self._cancel_task(job_id)
|
||||||
|
async with self._settling:
|
||||||
|
return await in_the_background(self._settle_stopped, job_id, ImportStatus.CANCELLED)
|
||||||
|
|
||||||
|
def _settle_stopped(self, job_id: str, target: ImportStatus) -> ImportJob:
|
||||||
|
"""A stopped runner's job moves to `target`, unless it completed first."""
|
||||||
current = self.job_store.require(job_id)
|
current = self.job_store.require(job_id)
|
||||||
if current.status is ImportStatus.COMPLETED:
|
if current.status is ImportStatus.COMPLETED:
|
||||||
return current
|
return current
|
||||||
return self.job_store.transition(job_id, ImportStatus.CANCELLED)
|
return self.job_store.transition(job_id, target)
|
||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
running = [
|
mine = frozenset(self._tasks)
|
||||||
job
|
|
||||||
for job in self.job_store.list_jobs(status=ImportStatus.RUNNING)
|
def interrupt_mine() -> None:
|
||||||
if job.job_id in self._tasks
|
for job in self.job_store.find_jobs(statuses=(ImportStatus.RUNNING,)):
|
||||||
]
|
if job.job_id in mine:
|
||||||
for job in running:
|
self.job_store.interrupt_running(job.job_id)
|
||||||
self.job_store.interrupt_running(job.job_id)
|
|
||||||
|
await in_the_background(interrupt_mine)
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
*(self._cancel_task(job_id) for job_id in tuple(self._tasks)),
|
*(self._cancel_task(job_id) for job_id in tuple(self._tasks)),
|
||||||
)
|
)
|
||||||
@@ -355,9 +431,7 @@ class ImportSupervisor:
|
|||||||
try:
|
try:
|
||||||
await asyncio.wait_for(asyncio.shield(task), timeout)
|
await asyncio.wait_for(asyncio.shield(task), timeout)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
current = self.job_store.require(job_id)
|
await in_the_background(self._interrupt_if_running, job_id)
|
||||||
if current.status is ImportStatus.RUNNING:
|
|
||||||
self.job_store.interrupt_running(job_id)
|
|
||||||
return False
|
return False
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# The worker noticed cancel and exited. A cancel of this
|
# The worker noticed cancel and exited. A cancel of this
|
||||||
@@ -365,10 +439,16 @@ class ImportSupervisor:
|
|||||||
caller = asyncio.current_task()
|
caller = asyncio.current_task()
|
||||||
if caller is not None and caller.cancelling():
|
if caller is not None and caller.cancelling():
|
||||||
raise
|
raise
|
||||||
|
# A read and a write on the single writer: off the loop. Live sync
|
||||||
|
# calls this from its poll, and on the loop it was the main thread
|
||||||
|
# waiting its turn for the writer behind an import peak.
|
||||||
|
await in_the_background(self._interrupt_if_running, job_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _interrupt_if_running(self, job_id: str) -> None:
|
||||||
current = self.job_store.require(job_id)
|
current = self.job_store.require(job_id)
|
||||||
if current.status is ImportStatus.RUNNING:
|
if current.status is ImportStatus.RUNNING:
|
||||||
self.job_store.interrupt_running(job_id)
|
self.job_store.interrupt_running(job_id)
|
||||||
return True
|
|
||||||
|
|
||||||
async def _cancel_task(self, job_id: str) -> None:
|
async def _cancel_task(self, job_id: str) -> None:
|
||||||
drain = self._drains.get(job_id)
|
drain = self._drains.get(job_id)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from one_memory.memory_governance import MemoryGovernanceStore, MemoryStatus
|
|||||||
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
||||||
from one_memory.memory_server.unified_entry import UnifiedWriteIntake
|
from one_memory.memory_server.unified_entry import UnifiedWriteIntake
|
||||||
from one_memory.memory_tasks import MemoryTaskStatus, MemoryTaskStore
|
from one_memory.memory_tasks import MemoryTaskStatus, MemoryTaskStore
|
||||||
|
from one_memory.model_backoff import next_retry_at
|
||||||
from one_memory.secrets import SecretStoreUnavailableError
|
from one_memory.secrets import SecretStoreUnavailableError
|
||||||
from one_memory.user_scope import DEFAULT_USER_ID, USER_ID_PATTERN, UserId
|
from one_memory.user_scope import DEFAULT_USER_ID, USER_ID_PATTERN, UserId
|
||||||
|
|
||||||
@@ -77,6 +78,12 @@ class MemoryAddReceipt(BaseModel):
|
|||||||
task_id: str | None = None
|
task_id: str | None = None
|
||||||
memory_ids: list[str] = Field(default_factory=list)
|
memory_ids: list[str] = Field(default_factory=list)
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
#: Why a note still being worked on last failed (`model_auth_failed`, ...),
|
||||||
|
#: or why a failed one failed. A provider failure keeps it `pending`.
|
||||||
|
error_code: str | None = None
|
||||||
|
#: When a pending note is next tried: its own retry wait, or the model
|
||||||
|
#: provider's pause while the provider is failing.
|
||||||
|
next_retry_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class LegacyMemoryAdditionReader:
|
class LegacyMemoryAdditionReader:
|
||||||
@@ -258,6 +265,8 @@ class MemoryAdditionStore:
|
|||||||
durable_state = durable.state if durable is not None else "failed"
|
durable_state = durable.state if durable is not None else "failed"
|
||||||
mapped: MemoryAddState = {
|
mapped: MemoryAddState = {
|
||||||
"pending": "pending",
|
"pending": "pending",
|
||||||
|
# Re-run automatically within seconds (`recover_unknown`).
|
||||||
|
"unknown": "pending",
|
||||||
"running": "running",
|
"running": "running",
|
||||||
"waiting_for_model": "waiting_for_model",
|
"waiting_for_model": "waiting_for_model",
|
||||||
"waiting_for_permission": "waiting_for_permission",
|
"waiting_for_permission": "waiting_for_permission",
|
||||||
@@ -272,6 +281,12 @@ class MemoryAdditionStore:
|
|||||||
state=mapped,
|
state=mapped,
|
||||||
memory_ids=(list(durable.sdk_memory_ids) if durable is not None else []),
|
memory_ids=(list(durable.sdk_memory_ids) if durable is not None else []),
|
||||||
error=durable.detail if durable is not None else "write_missing",
|
error=durable.detail if durable is not None else "write_missing",
|
||||||
|
error_code=(
|
||||||
|
durable.error_code
|
||||||
|
if durable is not None and durable.state != "completed"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
next_retry_at=durable.retry_at if durable is not None else None,
|
||||||
)
|
)
|
||||||
if self.processor is None:
|
if self.processor is None:
|
||||||
raise RuntimeError("legacy_memory_add_processor_unavailable")
|
raise RuntimeError("legacy_memory_add_processor_unavailable")
|
||||||
@@ -402,10 +417,15 @@ def memory_add_router(dependencies: Sequence[Depends]) -> APIRouter:
|
|||||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||||
) -> MemoryAddReceipt:
|
) -> MemoryAddReceipt:
|
||||||
try:
|
try:
|
||||||
return store(request).receipt_for_request(request_id, user_id=user_id)
|
receipt = store(request).receipt_for_request(request_id, user_id=user_id)
|
||||||
except KeyError as error:
|
except KeyError as error:
|
||||||
raise HTTPException(404, "memory_add_not_found") from error
|
raise HTTPException(404, "memory_add_not_found") from error
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise HTTPException(409, str(error)) from error
|
raise HTTPException(409, str(error)) from error
|
||||||
|
return receipt.model_copy(
|
||||||
|
update={
|
||||||
|
"next_retry_at": next_retry_at(request.app, receipt.state, receipt.next_retry_at)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
@@ -465,12 +465,16 @@ class EvolveScheduler:
|
|||||||
service: EvolveService,
|
service: EvolveService,
|
||||||
schedule: Callable[[], EvolveSchedule],
|
schedule: Callable[[], EvolveSchedule],
|
||||||
users_with_memories: Callable[[], Awaitable[Iterable[str]]],
|
users_with_memories: Callable[[], Awaitable[Iterable[str]]],
|
||||||
|
lifecycle_lock: asyncio.Lock | None = None,
|
||||||
|
lifecycle_open: Callable[[], bool] = lambda: True,
|
||||||
now: Callable[[], datetime] = lambda: datetime.now().astimezone(),
|
now: Callable[[], datetime] = lambda: datetime.now().astimezone(),
|
||||||
check_seconds: float = SCHEDULE_CHECK_SECONDS,
|
check_seconds: float = SCHEDULE_CHECK_SECONDS,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.service = service
|
self.service = service
|
||||||
self.schedule = schedule
|
self.schedule = schedule
|
||||||
self.users_with_memories = users_with_memories
|
self.users_with_memories = users_with_memories
|
||||||
|
self.lifecycle_lock = lifecycle_lock
|
||||||
|
self.lifecycle_open = lifecycle_open
|
||||||
self._now = now
|
self._now = now
|
||||||
self.check_seconds = check_seconds
|
self.check_seconds = check_seconds
|
||||||
self._task: asyncio.Task[None] | None = None
|
self._task: asyncio.Task[None] | None = None
|
||||||
@@ -492,7 +496,15 @@ class EvolveScheduler:
|
|||||||
if not evolve_due(now, schedule, last, anchor):
|
if not evolve_due(now, schedule, last, anchor):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
receipt = self.service.start(user_id, trigger="scheduled")
|
if self.lifecycle_lock is None:
|
||||||
|
if not self.lifecycle_open():
|
||||||
|
break
|
||||||
|
receipt = self.service.start(user_id, trigger="scheduled")
|
||||||
|
else:
|
||||||
|
async with self.lifecycle_lock:
|
||||||
|
if not self.lifecycle_open():
|
||||||
|
break
|
||||||
|
receipt = self.service.start(user_id, trigger="scheduled")
|
||||||
except EvolveModeError:
|
except EvolveModeError:
|
||||||
break
|
break
|
||||||
await self.service.wait(receipt.run_id)
|
await self.service.wait(receipt.run_id)
|
||||||
@@ -533,10 +545,12 @@ class MemoryGraphBackfill:
|
|||||||
*,
|
*,
|
||||||
coordinator: EvolveCoordinator,
|
coordinator: EvolveCoordinator,
|
||||||
state: EvolveStateStore,
|
state: EvolveStateStore,
|
||||||
|
lifecycle_open: Callable[[], bool] = lambda: True,
|
||||||
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
||||||
) -> None:
|
) -> None:
|
||||||
self.coordinator = coordinator
|
self.coordinator = coordinator
|
||||||
self.state = state
|
self.state = state
|
||||||
|
self.lifecycle_open = lifecycle_open
|
||||||
self._now = now
|
self._now = now
|
||||||
self._task: asyncio.Task[None] | None = None
|
self._task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
@@ -557,6 +571,35 @@ class MemoryGraphBackfill:
|
|||||||
self._task = asyncio.create_task(self._run(users), name="one-memory-graph-backfill")
|
self._task = asyncio.create_task(self._run(users), name="one-memory-graph-backfill")
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
def recover(self, user_ids: Iterable[str]) -> GraphBuildState:
|
||||||
|
"""Resume an interrupted backfill without repeating completed users."""
|
||||||
|
if self.running:
|
||||||
|
return self.state.graph_build()
|
||||||
|
saved = self.state.graph_build()
|
||||||
|
if saved.state != "running":
|
||||||
|
return saved
|
||||||
|
users = tuple(dict.fromkeys((*saved.users.keys(), *user_ids)))
|
||||||
|
pending = tuple(
|
||||||
|
user_id
|
||||||
|
for user_id in users
|
||||||
|
if saved.users.get(user_id, GraphBuildUser()).state != "succeeded"
|
||||||
|
)
|
||||||
|
resumed = saved.model_copy(deep=True)
|
||||||
|
for user_id in pending:
|
||||||
|
resumed.users[user_id] = GraphBuildUser()
|
||||||
|
if not pending:
|
||||||
|
resumed.state = "succeeded"
|
||||||
|
resumed.finished_at = self._now()
|
||||||
|
self.state.set_graph_build(resumed)
|
||||||
|
return resumed
|
||||||
|
resumed.state = "running"
|
||||||
|
resumed.finished_at = None
|
||||||
|
self.state.set_graph_build(resumed)
|
||||||
|
self._task = asyncio.create_task(
|
||||||
|
self._run(pending), name="one-memory-graph-backfill-recovery"
|
||||||
|
)
|
||||||
|
return resumed
|
||||||
|
|
||||||
async def wait(self) -> None:
|
async def wait(self) -> None:
|
||||||
if self._task is not None:
|
if self._task is not None:
|
||||||
await asyncio.shield(self._task)
|
await asyncio.shield(self._task)
|
||||||
@@ -564,6 +607,8 @@ class MemoryGraphBackfill:
|
|||||||
async def _run(self, users: tuple[str, ...]) -> None:
|
async def _run(self, users: tuple[str, ...]) -> None:
|
||||||
state = self.state.graph_build()
|
state = self.state.graph_build()
|
||||||
for user_id in users:
|
for user_id in users:
|
||||||
|
while not self.lifecycle_open():
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
state.users[user_id] = GraphBuildUser(state="running")
|
state.users[user_id] = GraphBuildUser(state="running")
|
||||||
await asyncio.to_thread(self.state.set_graph_build, state.model_copy(deep=True))
|
await asyncio.to_thread(self.state.set_graph_build, state.model_copy(deep=True))
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ exercises (query rewrite needs `queries=[...]`, session narratives need
|
|||||||
cross-encoder download, access write-back); and everything MemHub already
|
cross-encoder download, access write-back); and everything MemHub already
|
||||||
controls elsewhere (pipeline log, trace, memory operations, telemetry, mode).
|
controls elsewhere (pipeline log, trace, memory operations, telemetry, mode).
|
||||||
|
|
||||||
|
Beside the on/off features there is one numeric parameter,
|
||||||
|
`intention_dedup_threshold` (`PARAMETERS`): the cosine similarity at which the
|
||||||
|
read-time intention dedup treats two intentions as copies. It is set, stored,
|
||||||
|
reset and keyed exactly like a feature.
|
||||||
|
|
||||||
No SDK import here: only `hy_sdk_runtime` and `hy_sdk_migration` import it.
|
No SDK import here: only `hy_sdk_runtime` and `hy_sdk_migration` import it.
|
||||||
`config_state` imports this module, so it imports nothing from there.
|
`config_state` imports this module, so it imports nothing from there.
|
||||||
"""
|
"""
|
||||||
@@ -31,7 +36,7 @@ from collections.abc import Mapping
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, StrictBool
|
from pydantic import BaseModel, ConfigDict, Field, StrictBool
|
||||||
|
|
||||||
MemoryFeatureName = Literal[
|
MemoryFeatureName = Literal[
|
||||||
"graph_rerank",
|
"graph_rerank",
|
||||||
@@ -40,8 +45,13 @@ MemoryFeatureName = Literal[
|
|||||||
"session_episodes",
|
"session_episodes",
|
||||||
"episode_summary",
|
"episode_summary",
|
||||||
"intention",
|
"intention",
|
||||||
|
"intention_dedup",
|
||||||
"few_shot",
|
"few_shot",
|
||||||
]
|
]
|
||||||
|
#: Numeric settings beside the on/off features.
|
||||||
|
MemoryParameterName = Literal["intention_dedup_threshold"]
|
||||||
|
#: Every key of the setting: a feature or a parameter.
|
||||||
|
MemorySettingName = MemoryFeatureName | MemoryParameterName
|
||||||
#: Read features change search; write features change what extraction stores.
|
#: Read features change search; write features change what extraction stores.
|
||||||
FeatureSide = Literal["read", "write"]
|
FeatureSide = Literal["read", "write"]
|
||||||
#: none: no extra call. extra_query: one more store query per search.
|
#: none: no extra call. extra_query: one more store query per search.
|
||||||
@@ -49,9 +59,25 @@ FeatureSide = Literal["read", "write"]
|
|||||||
#: a longer extraction prompt.
|
#: a longer extraction prompt.
|
||||||
FeatureCost = Literal["none", "extra_query", "llm_call", "prompt_tokens"]
|
FeatureCost = Literal["none", "extra_query", "llm_call", "prompt_tokens"]
|
||||||
#: Why an enabled feature does nothing right now (`active: false`).
|
#: Why an enabled feature does nothing right now (`active: false`).
|
||||||
FeatureReason = Literal["disabled", "requires_pro", "requires_ultra", "requires_graph_rerank"]
|
FeatureReason = Literal[
|
||||||
|
"disabled",
|
||||||
|
"requires_pro",
|
||||||
|
"requires_ultra",
|
||||||
|
"requires_graph_rerank",
|
||||||
|
"requires_intention_dedup",
|
||||||
|
]
|
||||||
|
#: A feature is on or off; a parameter is a number.
|
||||||
|
type SettingValue = bool | float
|
||||||
#: A client's features as a hashable key: sorted (name, value) pairs, set ones only.
|
#: A client's features as a hashable key: sorted (name, value) pairs, set ones only.
|
||||||
type FeatureOverrides = tuple[tuple[str, bool], ...]
|
type FeatureOverrides = tuple[tuple[str, SettingValue], ...]
|
||||||
|
|
||||||
|
#: The range a person may set `intention_dedup_threshold` to. With dedup on,
|
||||||
|
#: the SDK deletes every intention at or above it from the store at read time,
|
||||||
|
#: which cannot be undone; below 0.8 the embedding models MemHub uses score
|
||||||
|
#: different plans on one topic as that close, so a lower value would delete
|
||||||
|
#: intentions that are not copies. 1.0 keeps only exact copies' dedup.
|
||||||
|
INTENTION_DEDUP_THRESHOLD_MIN = 0.8
|
||||||
|
INTENTION_DEDUP_THRESHOLD_MAX = 1.0
|
||||||
|
|
||||||
_MODE_RANK: dict[str, int] = {"lite": 0, "pro": 1, "ultra": 2}
|
_MODE_RANK: dict[str, int] = {"lite": 0, "pro": 1, "ultra": 2}
|
||||||
|
|
||||||
@@ -169,6 +195,23 @@ FEATURES: tuple[FeatureSpec, ...] = (
|
|||||||
"No extra call."
|
"No extra call."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
FeatureSpec(
|
||||||
|
name="intention_dedup",
|
||||||
|
section="recall",
|
||||||
|
field="intention_dedup_enabled",
|
||||||
|
env="MEMORY_INTENTION_DEDUP_ENABLED",
|
||||||
|
side="read",
|
||||||
|
requires_mode="pro",
|
||||||
|
requires_feature=None,
|
||||||
|
cost="none",
|
||||||
|
description=(
|
||||||
|
"Treat same-day intentions (L7) with the same expiry and a cosine "
|
||||||
|
"similarity at or above intention_dedup_threshold as copies: a "
|
||||||
|
"search returns the earliest and deletes the others from the store. "
|
||||||
|
"On by default; off hides, drops and deletes no intention, at read "
|
||||||
|
"time or in a write batch. No extra call."
|
||||||
|
),
|
||||||
|
),
|
||||||
FeatureSpec(
|
FeatureSpec(
|
||||||
name="few_shot",
|
name="few_shot",
|
||||||
section="llm",
|
section="llm",
|
||||||
@@ -189,12 +232,57 @@ FEATURE_NAMES: tuple[MemoryFeatureName, ...] = tuple(spec.name for spec in FEATU
|
|||||||
FEATURES_BY_NAME: dict[str, FeatureSpec] = {spec.name: spec for spec in FEATURES}
|
FEATURES_BY_NAME: dict[str, FeatureSpec] = {spec.name: spec for spec in FEATURES}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParameterSpec:
|
||||||
|
"""A numeric setting; it acts only while `requires_feature` acts."""
|
||||||
|
|
||||||
|
name: MemoryParameterName
|
||||||
|
section: Literal["recall"]
|
||||||
|
field: str
|
||||||
|
env: str
|
||||||
|
minimum: float
|
||||||
|
maximum: float
|
||||||
|
requires_feature: MemoryFeatureName
|
||||||
|
description: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sdk_field(self) -> str:
|
||||||
|
return f"{self.section}.{self.field}"
|
||||||
|
|
||||||
|
|
||||||
|
PARAMETERS: tuple[ParameterSpec, ...] = (
|
||||||
|
ParameterSpec(
|
||||||
|
name="intention_dedup_threshold",
|
||||||
|
section="recall",
|
||||||
|
field="intention_dedup_threshold",
|
||||||
|
env="MEMORY_INTENTION_DEDUP_THRESHOLD",
|
||||||
|
minimum=INTENTION_DEDUP_THRESHOLD_MIN,
|
||||||
|
maximum=INTENTION_DEDUP_THRESHOLD_MAX,
|
||||||
|
requires_feature="intention_dedup",
|
||||||
|
description=(
|
||||||
|
"Cosine similarity at or above which intention_dedup treats two "
|
||||||
|
"intentions as copies. Higher keeps more. The SDK default is 0.95 "
|
||||||
|
"(MEMORY_DEDUP_THRESHOLD also sets it when its own env var is unset)."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
PARAMETER_NAMES: tuple[MemoryParameterName, ...] = tuple(spec.name for spec in PARAMETERS)
|
||||||
|
PARAMETERS_BY_NAME: dict[str, ParameterSpec] = {spec.name: spec for spec in PARAMETERS}
|
||||||
|
#: Every key of the setting, features first, each in its catalog order.
|
||||||
|
SETTING_NAMES: tuple[MemorySettingName, ...] = (*FEATURE_NAMES, *PARAMETER_NAMES)
|
||||||
|
SETTINGS_BY_NAME: dict[str, FeatureSpec | ParameterSpec] = {
|
||||||
|
**FEATURES_BY_NAME,
|
||||||
|
**PARAMETERS_BY_NAME,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class MemoryFeatures(BaseModel):
|
class MemoryFeatures(BaseModel):
|
||||||
"""`config.json` `memory_features`, and the `PUT` body.
|
"""`config.json` `memory_features`, and the `PUT` body.
|
||||||
|
|
||||||
Every key is optional; absent (or null) means the SDK default. In a `PUT`
|
Every key is optional; absent (or null) means the SDK default. In a `PUT`
|
||||||
an absent key is left as it is and null resets it. Unknown keys and
|
an absent key is left as it is and null resets it. Unknown keys,
|
||||||
non-boolean values are refused (422).
|
non-boolean feature values and a threshold that is not a number in its
|
||||||
|
range are refused (422).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
|
model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
|
||||||
@@ -205,7 +293,14 @@ class MemoryFeatures(BaseModel):
|
|||||||
session_episodes: StrictBool | None = None
|
session_episodes: StrictBool | None = None
|
||||||
episode_summary: StrictBool | None = None
|
episode_summary: StrictBool | None = None
|
||||||
intention: StrictBool | None = None
|
intention: StrictBool | None = None
|
||||||
|
intention_dedup: StrictBool | None = None
|
||||||
few_shot: StrictBool | None = None
|
few_shot: StrictBool | None = None
|
||||||
|
intention_dedup_threshold: float | None = Field(
|
||||||
|
default=None,
|
||||||
|
ge=INTENTION_DEDUP_THRESHOLD_MIN,
|
||||||
|
le=INTENTION_DEDUP_THRESHOLD_MAX,
|
||||||
|
allow_inf_nan=False,
|
||||||
|
)
|
||||||
|
|
||||||
def overrides(self) -> FeatureOverrides:
|
def overrides(self) -> FeatureOverrides:
|
||||||
"""The features a person set, as the client key."""
|
"""The features a person set, as the client key."""
|
||||||
@@ -221,24 +316,47 @@ class MemoryFeatures(BaseModel):
|
|||||||
return MemoryFeatures(**{key: value for key, value in values.items() if value is not None})
|
return MemoryFeatures(**{key: value for key, value in values.items() if value is not None})
|
||||||
|
|
||||||
|
|
||||||
def feature_overrides(values: Mapping[str, bool]) -> FeatureOverrides:
|
def feature_overrides(values: Mapping[str, SettingValue]) -> FeatureOverrides:
|
||||||
unknown = set(values) - set(FEATURES_BY_NAME)
|
unknown = set(values) - set(SETTINGS_BY_NAME)
|
||||||
if unknown:
|
if unknown:
|
||||||
raise ValueError("unknown_memory_feature")
|
raise ValueError("unknown_memory_feature")
|
||||||
return tuple(sorted((name, bool(value)) for name, value in values.items()))
|
return tuple(sorted((name, _setting_value(name, value)) for name, value in values.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def _setting_value(name: str, value: SettingValue) -> SettingValue:
|
||||||
|
parameter = PARAMETERS_BY_NAME.get(name)
|
||||||
|
if parameter is None:
|
||||||
|
return bool(value)
|
||||||
|
if isinstance(value, bool) or not parameter.minimum <= float(value) <= parameter.maximum:
|
||||||
|
raise ValueError("invalid_memory_feature_value")
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
def effective_features(
|
def effective_features(
|
||||||
overrides: FeatureOverrides, defaults: Mapping[str, bool]
|
overrides: FeatureOverrides, defaults: Mapping[str, SettingValue]
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, SettingValue]:
|
||||||
"""Every feature's value: what a person set, else the SDK default."""
|
"""Every setting's value: what a person set, else the SDK default."""
|
||||||
values: dict[str, bool] = {spec.name: bool(defaults.get(spec.name, False)) for spec in FEATURES}
|
values: dict[str, SettingValue] = {
|
||||||
|
spec.name: bool(defaults.get(spec.name, False)) for spec in FEATURES
|
||||||
|
}
|
||||||
|
for parameter in PARAMETERS:
|
||||||
|
if parameter.name in defaults:
|
||||||
|
values[parameter.name] = float(defaults[parameter.name])
|
||||||
values.update(dict(overrides))
|
values.update(dict(overrides))
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
def feature_activity(name: str, effective: Mapping[str, bool], mode: str) -> FeatureReason | None:
|
def feature_activity(
|
||||||
"""None when the feature acts in this mode; else why it does not."""
|
name: str, effective: Mapping[str, SettingValue], mode: str
|
||||||
|
) -> FeatureReason | None:
|
||||||
|
"""None when the feature acts in this mode; else why it does not.
|
||||||
|
|
||||||
|
A parameter acts exactly when the feature it tunes acts.
|
||||||
|
"""
|
||||||
|
parameter = PARAMETERS_BY_NAME.get(name)
|
||||||
|
if parameter is not None:
|
||||||
|
reason = feature_activity(parameter.requires_feature, effective, mode)
|
||||||
|
return "requires_intention_dedup" if reason == "disabled" else reason
|
||||||
spec = FEATURES_BY_NAME[name]
|
spec = FEATURES_BY_NAME[name]
|
||||||
if not effective.get(name, False):
|
if not effective.get(name, False):
|
||||||
return "disabled"
|
return "disabled"
|
||||||
@@ -252,11 +370,12 @@ def feature_activity(name: str, effective: Mapping[str, bool], mode: str) -> Fea
|
|||||||
|
|
||||||
|
|
||||||
def non_default_features(
|
def non_default_features(
|
||||||
overrides: FeatureOverrides, defaults: Mapping[str, bool]
|
overrides: FeatureOverrides, defaults: Mapping[str, SettingValue]
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, SettingValue]:
|
||||||
"""The features whose value differs from the SDK default, for status."""
|
"""The settings whose value differs from the SDK default, for status."""
|
||||||
|
baseline = effective_features((), defaults)
|
||||||
return {
|
return {
|
||||||
name: value
|
name: value
|
||||||
for name, value in effective_features(overrides, defaults).items()
|
for name, value in effective_features(overrides, defaults).items()
|
||||||
if value != bool(defaults.get(name, False))
|
if value != baseline.get(name)
|
||||||
}
|
}
|
||||||
@@ -74,9 +74,9 @@ class ConfigDocument(BaseModel):
|
|||||||
#: SDK retrieval and extraction features a person set; an unset one keeps
|
#: SDK retrieval and extraction features a person set; an unset one keeps
|
||||||
#: the SDK default. See `one_memory.memory_features`.
|
#: the SDK default. See `one_memory.memory_features`.
|
||||||
memory_features: MemoryFeatures = Field(default_factory=MemoryFeatures)
|
memory_features: MemoryFeatures = Field(default_factory=MemoryFeatures)
|
||||||
#: The display language a person chose in the App; None = never chosen or
|
#: The display language the App shows (its choice, or the system language
|
||||||
#: "follow the system". See `one_memory.ui_language` for what it does to
|
#: it follows); None = none set. See `one_memory.ui_language` for what it
|
||||||
#: the SDK.
|
#: does to the SDK.
|
||||||
ui_language: UiLanguage | None = None
|
ui_language: UiLanguage | None = None
|
||||||
|
|
||||||
@field_validator("sources")
|
@field_validator("sources")
|
||||||
@@ -153,6 +153,11 @@ class ConfigStore:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self._document.memory_mode
|
return self._document.memory_mode
|
||||||
|
|
||||||
|
def memory_mode_explicitly_configured(self) -> bool:
|
||||||
|
"""Whether a person chose the mode, including choosing the default."""
|
||||||
|
with self._lock:
|
||||||
|
return "memory_mode" in self._document.model_fields_set
|
||||||
|
|
||||||
def set_memory_mode(self, mode: MemoryMode) -> ConfigDocument:
|
def set_memory_mode(self, mode: MemoryMode) -> ConfigDocument:
|
||||||
if mode not in MEMORY_MODES:
|
if mode not in MEMORY_MODES:
|
||||||
raise ValueError("invalid_memory_mode")
|
raise ValueError("invalid_memory_mode")
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -3163,6 +3163,137 @@ async def _060_memory_note_requests(cursor: Any) -> None:
|
|||||||
await cursor.execute(statement)
|
await cursor.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
|
def write_job_retries_ddl(dialect: str = "mysql") -> str:
|
||||||
|
"""Per-job retry state: which SDK request id is next, and retries spent.
|
||||||
|
|
||||||
|
The SDK replays a request id's first outcome forever, so every re-run of
|
||||||
|
a job needs an id of its own (`sdk_attempt`). `retries` counts automatic
|
||||||
|
retries after failures of the job itself; provider failures spend none.
|
||||||
|
"""
|
||||||
|
if dialect == "sqlite":
|
||||||
|
return """
|
||||||
|
ALTER TABLE memory_write_jobs
|
||||||
|
ADD COLUMN sdk_attempt INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE memory_write_jobs
|
||||||
|
ADD COLUMN retries INTEGER NOT NULL DEFAULT 0;
|
||||||
|
"""
|
||||||
|
return """
|
||||||
|
ALTER TABLE memory_write_jobs
|
||||||
|
ADD COLUMN sdk_attempt INT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE memory_write_jobs
|
||||||
|
ADD COLUMN retries INT NOT NULL DEFAULT 0;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _061_write_job_retries(cursor: Any) -> None:
|
||||||
|
for statement in _split_statements(write_job_retries_ddl(_dialect(cursor))):
|
||||||
|
await cursor.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
|
ONBOARDING_PROGRESS_MIGRATION_VERSION = 62
|
||||||
|
|
||||||
|
|
||||||
|
def onboarding_progress_ddl(dialect: str = "mysql") -> str:
|
||||||
|
"""Append resumable onboarding choices without rewriting migration 12."""
|
||||||
|
if dialect == "sqlite":
|
||||||
|
return """
|
||||||
|
ALTER TABLE onboarding_state
|
||||||
|
ADD COLUMN mode_intent TEXT NULL;
|
||||||
|
ALTER TABLE onboarding_state
|
||||||
|
ADD COLUMN setup_step TEXT NULL;
|
||||||
|
ALTER TABLE onboarding_state
|
||||||
|
ADD COLUMN model_profile_id TEXT NULL;
|
||||||
|
"""
|
||||||
|
return """
|
||||||
|
ALTER TABLE onboarding_state
|
||||||
|
ADD COLUMN mode_intent VARCHAR(16) NULL AFTER updated_at;
|
||||||
|
ALTER TABLE onboarding_state
|
||||||
|
ADD COLUMN setup_step VARCHAR(16) NULL AFTER mode_intent;
|
||||||
|
ALTER TABLE onboarding_state
|
||||||
|
ADD COLUMN model_profile_id VARCHAR(36) NULL AFTER setup_step;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _062_onboarding_progress(cursor: Any) -> None:
|
||||||
|
if _dialect(cursor) == "sqlite":
|
||||||
|
existing = await cursor.columns("onboarding_state")
|
||||||
|
statements = _split_statements(onboarding_progress_ddl("sqlite"))
|
||||||
|
for column, statement in zip(
|
||||||
|
("mode_intent", "setup_step", "model_profile_id"),
|
||||||
|
statements,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
if column not in existing:
|
||||||
|
await cursor.execute(statement)
|
||||||
|
return
|
||||||
|
for statement in _split_statements(onboarding_progress_ddl("mysql")):
|
||||||
|
try:
|
||||||
|
await cursor.execute(statement)
|
||||||
|
except Exception as error: # noqa: BLE001 - MySQL driver error type varies
|
||||||
|
if _is_duplicate_column(error):
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION = 63
|
||||||
|
|
||||||
|
|
||||||
|
def import_job_lookup_indexes_ddl(dialect: str = "mysql") -> str:
|
||||||
|
"""Index the per-source and per-status import job lookups.
|
||||||
|
|
||||||
|
Migration 7 gave SQLite `import_jobs` no index beyond its primary key (the
|
||||||
|
one-active-job index is partial), so every "this source's jobs" or "the
|
||||||
|
jobs in this state" question read and sorted the whole table. Live sync
|
||||||
|
adds a row per change it imports and nothing removed them, so on a machine
|
||||||
|
a few weeks old that table was thousands of rows read several times a poll.
|
||||||
|
|
||||||
|
`import_jobs_lane` serves the lane lookups (one user, one source, one
|
||||||
|
operation, a status, newest first) and the pruning of finished syncs;
|
||||||
|
`import_jobs_status` serves restart recovery and the status filters.
|
||||||
|
MySQL's migration 7 already has a status index and no `operation` column,
|
||||||
|
so it only gains the ordered source index.
|
||||||
|
"""
|
||||||
|
if dialect == "sqlite":
|
||||||
|
return """
|
||||||
|
CREATE INDEX IF NOT EXISTS import_jobs_lane
|
||||||
|
ON import_jobs(user_id, source_id, operation, status, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS import_jobs_status
|
||||||
|
ON import_jobs(status, created_at);
|
||||||
|
"""
|
||||||
|
return """
|
||||||
|
CREATE INDEX idx_import_jobs_source_created
|
||||||
|
ON import_jobs(user_id, source_id, created_at);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate_index(error: Exception) -> bool:
|
||||||
|
"""MySQL's "index name already there" (1061): the index is in place."""
|
||||||
|
args: tuple[object, ...] = getattr(error, "args", ())
|
||||||
|
return bool(args) and args[0] == 1061
|
||||||
|
|
||||||
|
|
||||||
|
async def _063_import_job_lookup_indexes(cursor: Any) -> None:
|
||||||
|
dialect = _dialect(cursor)
|
||||||
|
if dialect == "sqlite":
|
||||||
|
# `operation` was added by the job store, not by migration 7, so a
|
||||||
|
# database built from the catalog alone may not have it yet. Same
|
||||||
|
# declaration as the store's own addition.
|
||||||
|
if "operation" not in await cursor.columns("import_jobs"):
|
||||||
|
await cursor.execute(
|
||||||
|
"ALTER TABLE import_jobs ADD COLUMN operation TEXT NOT NULL DEFAULT 'history'"
|
||||||
|
)
|
||||||
|
for statement in _split_statements(import_job_lookup_indexes_ddl(dialect)):
|
||||||
|
await cursor.execute(statement)
|
||||||
|
return
|
||||||
|
for statement in _split_statements(import_job_lookup_indexes_ddl(dialect)):
|
||||||
|
try:
|
||||||
|
await cursor.execute(statement)
|
||||||
|
except Exception as error: # noqa: BLE001 - MySQL driver error type varies
|
||||||
|
if _is_duplicate_index(error):
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _split_statements(sql: str) -> list[str]:
|
def _split_statements(sql: str) -> list[str]:
|
||||||
"""Split a multi-statement script, dropping blanks, PRAGMA and comments.
|
"""Split a multi-statement script, dropping blanks, PRAGMA and comments.
|
||||||
|
|
||||||
@@ -3462,4 +3593,16 @@ MIGRATIONS: tuple[Migration, ...] = (
|
|||||||
_060_memory_note_requests,
|
_060_memory_note_requests,
|
||||||
("memory_note_requests",),
|
("memory_note_requests",),
|
||||||
),
|
),
|
||||||
|
Migration(61, "write_job_retries", _061_write_job_retries),
|
||||||
|
Migration(
|
||||||
|
ONBOARDING_PROGRESS_MIGRATION_VERSION,
|
||||||
|
"onboarding_progress",
|
||||||
|
_062_onboarding_progress,
|
||||||
|
("onboarding_state",),
|
||||||
|
),
|
||||||
|
Migration(
|
||||||
|
IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION,
|
||||||
|
"import_job_lookup_indexes",
|
||||||
|
_063_import_job_lookup_indexes,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -57,6 +57,11 @@ class WriteRecord:
|
|||||||
detail: str = ""
|
detail: str = ""
|
||||||
raw_ids: tuple[str, ...] = ()
|
raw_ids: tuple[str, ...] = ()
|
||||||
memory_ids: tuple[str, ...] = ()
|
memory_ids: tuple[str, ...] = ()
|
||||||
|
#: The stable code of the last failure of a job still being worked on
|
||||||
|
#: (or of a failed one).
|
||||||
|
error_code: str | None = None
|
||||||
|
#: When a waiting job is next tried, if a retry wait holds it.
|
||||||
|
next_retry_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from one_memory.memory_server.durable_queue import (
|
|||||||
)
|
)
|
||||||
from one_memory.memory_server.registry import WriteRegistry, WriteState
|
from one_memory.memory_server.registry import WriteRegistry, WriteState
|
||||||
from one_memory.memory_server.submit import WriteConflictError
|
from one_memory.memory_server.submit import WriteConflictError
|
||||||
|
from one_memory.model_backoff import next_retry_at
|
||||||
from one_memory.user_scope import DEFAULT_USER_ID, USER_ID_PATTERN, UserId
|
from one_memory.user_scope import DEFAULT_USER_ID, USER_ID_PATTERN, UserId
|
||||||
|
|
||||||
#: Durable keys of another user's writes start with this. A `local` request
|
#: Durable keys of another user's writes start with this. A `local` request
|
||||||
@@ -72,6 +73,12 @@ class MemoryWriteReceipt(BaseModel):
|
|||||||
detail: str = ""
|
detail: str = ""
|
||||||
raw_ids: tuple[str, ...] = ()
|
raw_ids: tuple[str, ...] = ()
|
||||||
memory_ids: tuple[str, ...] = ()
|
memory_ids: tuple[str, ...] = ()
|
||||||
|
#: Why a job still being worked on last failed (`model_auth_failed`, ...),
|
||||||
|
#: or why a failed one failed. A provider failure keeps `state` queued.
|
||||||
|
error_code: str | None = None
|
||||||
|
#: When it is next tried: the job's own retry wait, or the model
|
||||||
|
#: provider's pause (`model_backoff`) while the provider is failing.
|
||||||
|
next_retry_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class OpenAIMessage(BaseModel):
|
class OpenAIMessage(BaseModel):
|
||||||
@@ -235,6 +242,8 @@ def sdk_router(dependencies: list[Any] | None = None) -> APIRouter:
|
|||||||
detail=record.detail,
|
detail=record.detail,
|
||||||
raw_ids=record.raw_ids,
|
raw_ids=record.raw_ids,
|
||||||
memory_ids=record.memory_ids,
|
memory_ids=record.memory_ids,
|
||||||
|
error_code=record.error_code,
|
||||||
|
next_retry_at=next_retry_at(request.app, record.state, record.next_retry_at),
|
||||||
)
|
)
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""One pause for every model request while the provider keeps failing (#245).
|
||||||
|
|
||||||
|
A broken model configuration (a revoked key, a wrong base URL, a model that
|
||||||
|
does not exist, a provider that is down) used to fail each queued job on its
|
||||||
|
own: the drain claimed the next job the moment the last one failed, so a
|
||||||
|
backlog of hundreds of jobs turned into requests as fast as the provider could
|
||||||
|
refuse them. The pause is global, not per job: a failure says something about
|
||||||
|
the provider, not about the conversation that happened to hit it.
|
||||||
|
|
||||||
|
After the first failure no further model work is claimed for 5 seconds; each
|
||||||
|
failed probe after that doubles the pause, up to 3 minutes. A pause admits one
|
||||||
|
probe job at a time. The first success, or any change the user makes to the
|
||||||
|
model configuration, ends it at once. Jobs not yet sent stay `pending`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BACKOFF_FIRST_SECONDS = 5.0
|
||||||
|
BACKOFF_CAP_SECONDS = 180.0
|
||||||
|
#: How long one probe may hold the pause open before another is admitted. A
|
||||||
|
#: probe job that never reaches the provider (nothing to remember, a refusal
|
||||||
|
#: before the SDK) reports nothing, and the queue must not wait on it forever.
|
||||||
|
PROBE_HOLD_SECONDS = 30.0
|
||||||
|
#: Requests already in flight when the pause began are refused without going
|
||||||
|
#: out once this many failures in a row confirm it is not a one-off.
|
||||||
|
REFUSE_IN_FLIGHT_AFTER = 2
|
||||||
|
|
||||||
|
#: Statuses that say the configuration or the provider is wrong, not the one
|
||||||
|
#: request: authentication, a missing model or route, throttling, a timeout
|
||||||
|
#: and the provider's own failures. A 400/413/422 is about the request's
|
||||||
|
#: content and is left to that job.
|
||||||
|
_PROVIDER_STATUSES = {401: "model_auth_failed", 403: "model_auth_failed", 404: "model_not_found"}
|
||||||
|
|
||||||
|
#: Every code a provider failure is recorded under: a job that failed with one
|
||||||
|
#: of these waits for the provider instead of spending a retry.
|
||||||
|
MODEL_FAILURE_CODES = frozenset(
|
||||||
|
{
|
||||||
|
"model_auth_failed",
|
||||||
|
"model_not_found",
|
||||||
|
"model_timeout",
|
||||||
|
"model_rate_limited",
|
||||||
|
"model_server_error",
|
||||||
|
"model_unreachable",
|
||||||
|
"model_transport_failed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def backoff_seconds(level: int) -> float:
|
||||||
|
"""The pause after the `level`-th failed attempt in a row (1-based)."""
|
||||||
|
if level < 1:
|
||||||
|
return 0.0
|
||||||
|
return min(BACKOFF_FIRST_SECONDS * float(2 ** min(level - 1, 16)), BACKOFF_CAP_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def failure_code(status: int) -> str | None:
|
||||||
|
"""The stable code for a provider HTTP status, or None if it is no provider failure."""
|
||||||
|
if status in _PROVIDER_STATUSES:
|
||||||
|
return _PROVIDER_STATUSES[status]
|
||||||
|
if status in {408, 504}:
|
||||||
|
return "model_timeout"
|
||||||
|
if status == 429:
|
||||||
|
return "model_rate_limited"
|
||||||
|
if status >= 500:
|
||||||
|
return "model_server_error"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def transport_failure_code(error: BaseException) -> str:
|
||||||
|
if isinstance(error, httpx.TimeoutException):
|
||||||
|
return "model_timeout"
|
||||||
|
if isinstance(error, httpx.ConnectError | httpx.NetworkError):
|
||||||
|
return "model_unreachable"
|
||||||
|
return "model_transport_failed"
|
||||||
|
|
||||||
|
|
||||||
|
def request_kind(request: httpx.Request) -> str:
|
||||||
|
"""`embedding` for the embeddings route, `chat` for everything else."""
|
||||||
|
return "embedding" if request.url.path.rstrip("/").endswith("/embeddings") else "chat"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ModelBackoffState:
|
||||||
|
#: When the pause ends; None when no pause is in force.
|
||||||
|
until: datetime | None
|
||||||
|
#: Failed attempts in a row.
|
||||||
|
failures: int
|
||||||
|
#: The last failure's stable code (`model_auth_failed`, ...).
|
||||||
|
last_error: str | None
|
||||||
|
profile_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class ModelBackoffRefusal(httpx.TransportError):
|
||||||
|
"""A request refused locally because the provider is being given a rest."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(eq=False)
|
||||||
|
class ModelAttempt:
|
||||||
|
"""One job's write, as the provider saw it.
|
||||||
|
|
||||||
|
`failure` is the code of the first provider failure (or local refusal)
|
||||||
|
that happened while this write was in flight, None if there was none.
|
||||||
|
"""
|
||||||
|
|
||||||
|
failure: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModelBackoff:
|
||||||
|
"""Process-wide circuit breaker for the active model profile.
|
||||||
|
|
||||||
|
Thread-safe: the transport records from the SDK's own loop thread, the
|
||||||
|
drain asks from its claim thread, and status reads from the event loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
wall_clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
||||||
|
) -> None:
|
||||||
|
self._clock = clock
|
||||||
|
self._wall_clock = wall_clock
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
#: Writes in flight (`attempt`); never cleared by a reset.
|
||||||
|
self._attempts: set[ModelAttempt] = set()
|
||||||
|
self._reset_locked()
|
||||||
|
|
||||||
|
def _reset_locked(self) -> None:
|
||||||
|
#: Which kinds of request are failing (`chat`, `embedding`): a working
|
||||||
|
#: embedding endpoint says nothing about a chat model that is gone.
|
||||||
|
self._failing: set[str] = set()
|
||||||
|
self._level = 0
|
||||||
|
self._failures = 0
|
||||||
|
self._until = 0.0
|
||||||
|
self._probe_until = 0.0
|
||||||
|
self._last_error: str | None = None
|
||||||
|
self._profile_id: str | None = None
|
||||||
|
|
||||||
|
def reset(self, reason: str = "model_configuration_changed") -> None:
|
||||||
|
"""Forget every failure: the user changed what is being called."""
|
||||||
|
with self._lock:
|
||||||
|
was_paused = self._failures > 0
|
||||||
|
self._reset_locked()
|
||||||
|
if was_paused:
|
||||||
|
logger.info("model_backoff_reset reason=%s", reason)
|
||||||
|
|
||||||
|
def record_success(self, profile_id: str | None = None, kind: str = "model") -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._failures == 0:
|
||||||
|
return
|
||||||
|
if profile_id is not None and self._profile_id not in {None, profile_id}:
|
||||||
|
return
|
||||||
|
self._failing.discard(kind)
|
||||||
|
if self._failing:
|
||||||
|
return
|
||||||
|
self._reset_locked()
|
||||||
|
logger.info("model_backoff_cleared profile_id=%s", profile_id)
|
||||||
|
|
||||||
|
def record_failure(self, code: str, profile_id: str | None = None, kind: str = "model") -> None:
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
if profile_id is not None and self._profile_id not in {None, profile_id}:
|
||||||
|
# Another profile's history says nothing about this one.
|
||||||
|
self._reset_locked()
|
||||||
|
self._profile_id = profile_id
|
||||||
|
self._failing.add(kind)
|
||||||
|
self._mark_attempts_locked(code)
|
||||||
|
self._failures += 1
|
||||||
|
self._last_error = code
|
||||||
|
self._probe_until = 0.0
|
||||||
|
# Requests that were already out when the pause began fail too;
|
||||||
|
# they are the same outage, not a reason to wait longer.
|
||||||
|
if now >= self._until:
|
||||||
|
self._level += 1
|
||||||
|
self._until = now + backoff_seconds(self._level)
|
||||||
|
level, pause = self._level, self._until - now
|
||||||
|
logger.warning(
|
||||||
|
"model_backoff_failure error_code=%s level=%d pause_s=%.0f profile_id=%s",
|
||||||
|
code,
|
||||||
|
level,
|
||||||
|
pause,
|
||||||
|
profile_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def attempt(self) -> Iterator[ModelAttempt]:
|
||||||
|
"""Hold one job's write open, and learn whether the provider failed it.
|
||||||
|
|
||||||
|
Every provider failure and every local refusal while the write is in
|
||||||
|
flight is charged to it. A request cannot be traced to the job that
|
||||||
|
made it: the SDK sends embeddings from one shared batching worker, so
|
||||||
|
neither the calling thread nor a context variable identifies the job.
|
||||||
|
Charging every write then in flight can name a write the provider did
|
||||||
|
not fail (another job's request failed beside it); that write waits
|
||||||
|
for the provider and is retried with the rest. It can never miss the
|
||||||
|
write the provider did fail, which would spend that job's retries on
|
||||||
|
an outage.
|
||||||
|
"""
|
||||||
|
record = ModelAttempt()
|
||||||
|
with self._lock:
|
||||||
|
self._attempts.add(record)
|
||||||
|
try:
|
||||||
|
yield record
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._attempts.discard(record)
|
||||||
|
|
||||||
|
def record_refusal(self) -> str:
|
||||||
|
"""A request refused locally; charged to the writes in flight like a failure."""
|
||||||
|
with self._lock:
|
||||||
|
code = self._last_error or "model_transport_failed"
|
||||||
|
self._mark_attempts_locked(code)
|
||||||
|
return code
|
||||||
|
|
||||||
|
def _mark_attempts_locked(self, code: str) -> None:
|
||||||
|
for record in self._attempts:
|
||||||
|
if record.failure is None:
|
||||||
|
record.failure = code
|
||||||
|
|
||||||
|
def paused(self) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
return self._failures > 0 and self._clock() < self._until
|
||||||
|
|
||||||
|
def refuses_requests(self, kind: str = "model") -> bool:
|
||||||
|
"""Whether a request of this kind should not go out at all right now.
|
||||||
|
|
||||||
|
Only the failing kind is refused: a chat model that is gone must not
|
||||||
|
take recall's embedding calls down with it.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
return (
|
||||||
|
kind in self._failing
|
||||||
|
and self._failures >= REFUSE_IN_FLIGHT_AFTER
|
||||||
|
and self._clock() < self._until
|
||||||
|
)
|
||||||
|
|
||||||
|
def allowance(self, *, kind: str | None = None) -> int | None:
|
||||||
|
"""How many matching provider jobs may be claimed now.
|
||||||
|
|
||||||
|
0 while paused; once the pause is over, one probe at a time until it
|
||||||
|
reports or `PROBE_HOLD_SECONDS` pass. ``kind`` keeps a failed chat
|
||||||
|
model from pausing raw-only work that needs embeddings, while an
|
||||||
|
embedding outage still paces that work.
|
||||||
|
"""
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
if self._failures == 0 or (kind is not None and kind not in self._failing):
|
||||||
|
return None
|
||||||
|
if now < self._until or now < self._probe_until:
|
||||||
|
return 0
|
||||||
|
self._probe_until = now + PROBE_HOLD_SECONDS
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def state(self) -> ModelBackoffState:
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
remaining = self._until - now if self._failures else 0.0
|
||||||
|
return ModelBackoffState(
|
||||||
|
until=(
|
||||||
|
self._wall_clock() + timedelta(seconds=remaining) if remaining > 0 else None
|
||||||
|
),
|
||||||
|
failures=self._failures,
|
||||||
|
last_error=self._last_error,
|
||||||
|
profile_id=self._profile_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def next_retry_at(app: Any, state: str, own: str | None) -> str | None:
|
||||||
|
"""When a queued job is tried next: its own retry wait, or the provider's pause.
|
||||||
|
|
||||||
|
While the breaker pauses, nothing is claimed at all, so the pause holds
|
||||||
|
every queued job, whatever it last failed with.
|
||||||
|
"""
|
||||||
|
if state not in {"accepted", "pending"}:
|
||||||
|
return None
|
||||||
|
backoff = getattr(getattr(app, "state", None), "model_backoff", None)
|
||||||
|
until = backoff.state().until if isinstance(backoff, ModelBackoff) else None
|
||||||
|
paused = until.isoformat() if until is not None else None
|
||||||
|
candidates = [value for value in (own, paused) if value]
|
||||||
|
return max(candidates) if candidates else None
|
||||||
|
|
||||||
|
|
||||||
|
class BackedOffModelTransport(httpx.AsyncBaseTransport):
|
||||||
|
"""Report each provider exchange to the breaker; refuse while it confirms a pause."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
backoff: ModelBackoff,
|
||||||
|
*,
|
||||||
|
profile_id: str,
|
||||||
|
inner: httpx.AsyncBaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.backoff = backoff
|
||||||
|
self.profile_id = profile_id
|
||||||
|
self.inner = inner or httpx.AsyncHTTPTransport(retries=0, trust_env=False)
|
||||||
|
|
||||||
|
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
kind = request_kind(request)
|
||||||
|
if self.backoff.refuses_requests(kind):
|
||||||
|
self.backoff.record_refusal()
|
||||||
|
raise ModelBackoffRefusal("model_backoff_active", request=request)
|
||||||
|
try:
|
||||||
|
response = await self.inner.handle_async_request(request)
|
||||||
|
except ModelBackoffRefusal:
|
||||||
|
raise
|
||||||
|
except httpx.TransportError as error:
|
||||||
|
self.backoff.record_failure(transport_failure_code(error), self.profile_id, kind)
|
||||||
|
raise
|
||||||
|
code = failure_code(response.status_code)
|
||||||
|
if code is not None:
|
||||||
|
self.backoff.record_failure(code, self.profile_id, kind)
|
||||||
|
elif response.status_code < 400:
|
||||||
|
self.backoff.record_success(self.profile_id, kind)
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self.inner.aclose()
|
||||||
@@ -4,9 +4,10 @@ from collections.abc import Iterator
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
from one_memory.database import (
|
from one_memory.database import (
|
||||||
take_the_write_lock_first,
|
take_the_write_lock_first,
|
||||||
@@ -14,11 +15,25 @@ from one_memory.database import (
|
|||||||
from one_memory.database_access import main_database
|
from one_memory.database_access import main_database
|
||||||
|
|
||||||
#: Schema owned by `memory_server.migrations` (migration 12). No local copy.
|
#: Schema owned by `memory_server.migrations` (migration 12). No local copy.
|
||||||
from one_memory.memory_server.migrations import onboarding_state_ddl
|
from one_memory.memory_server.migrations import (
|
||||||
from one_memory.memory_server.sql_compat import execute, is_mysql
|
onboarding_progress_ddl,
|
||||||
|
onboarding_state_ddl,
|
||||||
|
)
|
||||||
|
from one_memory.memory_server.sql_compat import execute, is_mysql, row_to_dict
|
||||||
from one_memory.memory_server.store_binding import store_for
|
from one_memory.memory_server.store_binding import store_for
|
||||||
|
|
||||||
SCHEMA = onboarding_state_ddl("sqlite")
|
SCHEMA = onboarding_state_ddl("sqlite")
|
||||||
|
ONBOARDING_COLUMNS = (
|
||||||
|
"singleton",
|
||||||
|
"status",
|
||||||
|
"selected_source_ids_json",
|
||||||
|
"started_at",
|
||||||
|
"completed_at",
|
||||||
|
"updated_at",
|
||||||
|
"mode_intent",
|
||||||
|
"setup_step",
|
||||||
|
"model_profile_id",
|
||||||
|
)
|
||||||
|
|
||||||
#: The start/complete upserts, per engine.
|
#: The start/complete upserts, per engine.
|
||||||
#:
|
#:
|
||||||
@@ -51,11 +66,13 @@ START_MYSQL = """
|
|||||||
updated_at = VALUES(updated_at)
|
updated_at = VALUES(updated_at)
|
||||||
"""
|
"""
|
||||||
COMPLETE_UPDATE_SQLITE = (
|
COMPLETE_UPDATE_SQLITE = (
|
||||||
"UPDATE onboarding_state SET status = 'completed', completed_at = ?, updated_at = ? "
|
"UPDATE onboarding_state SET status = 'completed', completed_at = ?, updated_at = ?, "
|
||||||
|
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL "
|
||||||
"WHERE singleton = 1"
|
"WHERE singleton = 1"
|
||||||
)
|
)
|
||||||
COMPLETE_UPDATE_MYSQL = (
|
COMPLETE_UPDATE_MYSQL = (
|
||||||
"UPDATE onboarding_state SET status = 'completed', completed_at = %s, updated_at = %s "
|
"UPDATE onboarding_state SET status = 'completed', completed_at = %s, updated_at = %s, "
|
||||||
|
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL "
|
||||||
"WHERE singleton = 1"
|
"WHERE singleton = 1"
|
||||||
)
|
)
|
||||||
COMPLETE_INSERT_SQLITE = (
|
COMPLETE_INSERT_SQLITE = (
|
||||||
@@ -71,13 +88,27 @@ COMPLETE_INSERT_MYSQL = (
|
|||||||
RESET_UPDATE_SQLITE = (
|
RESET_UPDATE_SQLITE = (
|
||||||
"UPDATE onboarding_state SET status = 'not_started', "
|
"UPDATE onboarding_state SET status = 'not_started', "
|
||||||
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
|
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
|
||||||
|
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL, "
|
||||||
"updated_at = ? WHERE singleton = 1"
|
"updated_at = ? WHERE singleton = 1"
|
||||||
)
|
)
|
||||||
RESET_UPDATE_MYSQL = (
|
RESET_UPDATE_MYSQL = (
|
||||||
"UPDATE onboarding_state SET status = 'not_started', "
|
"UPDATE onboarding_state SET status = 'not_started', "
|
||||||
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
|
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
|
||||||
|
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL, "
|
||||||
"updated_at = %s WHERE singleton = 1"
|
"updated_at = %s WHERE singleton = 1"
|
||||||
)
|
)
|
||||||
|
ENSURE_PROGRESS_SQLITE = (
|
||||||
|
"INSERT INTO onboarding_state ("
|
||||||
|
"singleton,status,selected_source_ids_json,started_at,completed_at,updated_at"
|
||||||
|
") VALUES (1,'not_started','[]',NULL,NULL,?) "
|
||||||
|
"ON CONFLICT(singleton) DO NOTHING"
|
||||||
|
)
|
||||||
|
ENSURE_PROGRESS_MYSQL = (
|
||||||
|
"INSERT INTO onboarding_state ("
|
||||||
|
"singleton,status,selected_source_ids_json,started_at,completed_at,updated_at"
|
||||||
|
") VALUES (1,'not_started','[]',NULL,NULL,%s) "
|
||||||
|
"ON DUPLICATE KEY UPDATE singleton=VALUES(singleton)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OnboardingState(BaseModel):
|
class OnboardingState(BaseModel):
|
||||||
@@ -87,6 +118,35 @@ class OnboardingState(BaseModel):
|
|||||||
selected_source_ids: tuple[str, ...]
|
selected_source_ids: tuple[str, ...]
|
||||||
started_at: datetime | None
|
started_at: datetime | None
|
||||||
completed_at: datetime | None
|
completed_at: datetime | None
|
||||||
|
mode_intent: Literal["lite", "pro"] | None
|
||||||
|
setup_step: Literal["review", "mode", "model"] | None
|
||||||
|
model_profile_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingProgressUpdate(BaseModel):
|
||||||
|
"""Only non-sensitive, resumable choices accepted from the onboarding UI."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||||
|
|
||||||
|
mode_intent: Literal["lite", "pro"] | None = None
|
||||||
|
setup_step: Literal["review", "mode", "model"] | None = None
|
||||||
|
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
|
||||||
|
|
||||||
|
@field_validator("model_profile_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_model_profile_id(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if str(UUID(value)) != value:
|
||||||
|
raise ValueError
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
raise ValueError("invalid_model_profile_id") from None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingCompletedError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class OnboardingStore:
|
class OnboardingStore:
|
||||||
@@ -103,12 +163,31 @@ class OnboardingStore:
|
|||||||
"""
|
"""
|
||||||
with self._connect(initialize=True) as connection:
|
with self._connect(initialize=True) as connection:
|
||||||
execute(connection, is_mysql(self._store), SCHEMA)
|
execute(connection, is_mysql(self._store), SCHEMA)
|
||||||
|
if not is_mysql(self._store):
|
||||||
|
existing = {
|
||||||
|
str(row[1]) for row in connection.execute("PRAGMA table_info(onboarding_state)")
|
||||||
|
}
|
||||||
|
statements = tuple(
|
||||||
|
statement.strip()
|
||||||
|
for statement in onboarding_progress_ddl("sqlite").split(";")
|
||||||
|
if statement.strip()
|
||||||
|
)
|
||||||
|
for column, statement in zip(
|
||||||
|
("mode_intent", "setup_step", "model_profile_id"),
|
||||||
|
statements,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
if column not in existing:
|
||||||
|
connection.execute(statement)
|
||||||
|
|
||||||
def get(self) -> OnboardingState:
|
def get(self) -> OnboardingState:
|
||||||
mysql = is_mysql(self._store)
|
mysql = is_mysql(self._store)
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
row = execute(
|
row = execute(
|
||||||
connection, mysql, "SELECT * FROM onboarding_state WHERE singleton = 1"
|
connection,
|
||||||
|
mysql,
|
||||||
|
"SELECT " + ", ".join(ONBOARDING_COLUMNS) + " "
|
||||||
|
"FROM onboarding_state WHERE singleton = 1",
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return OnboardingState(
|
return OnboardingState(
|
||||||
@@ -116,8 +195,11 @@ class OnboardingStore:
|
|||||||
selected_source_ids=(),
|
selected_source_ids=(),
|
||||||
started_at=None,
|
started_at=None,
|
||||||
completed_at=None,
|
completed_at=None,
|
||||||
|
mode_intent=None,
|
||||||
|
setup_step=None,
|
||||||
|
model_profile_id=None,
|
||||||
)
|
)
|
||||||
return self._to_state(row)
|
return self._to_state(row, mysql=mysql)
|
||||||
|
|
||||||
def start(self, source_ids: tuple[str, ...]) -> OnboardingState:
|
def start(self, source_ids: tuple[str, ...]) -> OnboardingState:
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
@@ -150,6 +232,43 @@ class OnboardingStore:
|
|||||||
)
|
)
|
||||||
return self.get()
|
return self.get()
|
||||||
|
|
||||||
|
def update_progress(self, update: OnboardingProgressUpdate) -> OnboardingState:
|
||||||
|
fields = tuple(
|
||||||
|
field
|
||||||
|
for field in ("mode_intent", "setup_step", "model_profile_id")
|
||||||
|
if field in update.model_fields_set
|
||||||
|
)
|
||||||
|
if not fields:
|
||||||
|
state = self.get()
|
||||||
|
if state.status == "completed":
|
||||||
|
raise OnboardingCompletedError("onboarding_completed")
|
||||||
|
return state
|
||||||
|
mysql = is_mysql(self._store)
|
||||||
|
placeholder = "%s" if mysql else "?"
|
||||||
|
assignments = [f"{field} = {placeholder}" for field in fields]
|
||||||
|
assignments.append(f"updated_at = {placeholder}")
|
||||||
|
values = [getattr(update, field) for field in fields]
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
values.append(now)
|
||||||
|
with self._connect(writing=True) as connection:
|
||||||
|
execute(
|
||||||
|
connection,
|
||||||
|
mysql,
|
||||||
|
ENSURE_PROGRESS_MYSQL if mysql else ENSURE_PROGRESS_SQLITE,
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
updated = execute(
|
||||||
|
connection,
|
||||||
|
mysql,
|
||||||
|
"UPDATE onboarding_state SET "
|
||||||
|
+ ", ".join(assignments)
|
||||||
|
+ " WHERE singleton = 1 AND status != 'completed'",
|
||||||
|
tuple(values),
|
||||||
|
)
|
||||||
|
if updated.rowcount != 1:
|
||||||
|
raise OnboardingCompletedError("onboarding_completed")
|
||||||
|
return self.get()
|
||||||
|
|
||||||
def reset(self) -> OnboardingState:
|
def reset(self) -> OnboardingState:
|
||||||
"""Return onboarding to its first-run state, touching nothing else.
|
"""Return onboarding to its first-run state, touching nothing else.
|
||||||
|
|
||||||
@@ -207,33 +326,53 @@ class OnboardingStore:
|
|||||||
yield connection
|
yield connection
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _to_state(row: Any) -> OnboardingState:
|
def _to_state(row: Any, *, mysql: bool) -> OnboardingState:
|
||||||
"""Read the row by position, so it works for `sqlite3.Row` and tuples.
|
"""Map SQLite rows and MySQL tuples through the shared database adapter."""
|
||||||
|
if mysql:
|
||||||
`SELECT *` makes the column order part of the contract here, which is
|
if not isinstance(row, tuple):
|
||||||
why the query is `SELECT *` and the indices are named: relying on
|
raise ValueError("onboarding_row_shape_invalid")
|
||||||
`row["..."]` would only work on SQLite, and relying on position alone
|
elif not isinstance(row, sqlite3.Row):
|
||||||
without saying which column is which would be unreadable.
|
raise ValueError("onboarding_row_shape_invalid")
|
||||||
|
if len(row) != len(ONBOARDING_COLUMNS):
|
||||||
Timestamps are a further engine difference: SQLite hands back the stored
|
raise ValueError("onboarding_row_shape_invalid")
|
||||||
string, MySQL hands back a `datetime`. Accepting both is not padding —
|
if mysql:
|
||||||
parsing a `datetime` raises, and treating a string as one would be
|
values = dict(zip(ONBOARDING_COLUMNS, tuple(row), strict=True))
|
||||||
silently wrong.
|
else:
|
||||||
"""
|
values = row_to_dict(row, ONBOARDING_COLUMNS, mysql=False)
|
||||||
# singleton, status, selected_source_ids_json, started_at, completed_at, updated_at
|
if set(values) != set(ONBOARDING_COLUMNS):
|
||||||
raw_ids = json.loads(row[2])
|
raise ValueError("onboarding_row_shape_invalid")
|
||||||
|
_as_datetime(values["updated_at"], mysql=mysql, nullable=False)
|
||||||
|
raw_ids = json.loads(values["selected_source_ids_json"])
|
||||||
return OnboardingState(
|
return OnboardingState(
|
||||||
status=row[1],
|
status=values["status"],
|
||||||
selected_source_ids=tuple(value for value in raw_ids if isinstance(value, str)),
|
selected_source_ids=tuple(value for value in raw_ids if isinstance(value, str)),
|
||||||
started_at=_as_datetime(row[3]),
|
started_at=_as_datetime(values["started_at"], mysql=mysql),
|
||||||
completed_at=_as_datetime(row[4]),
|
completed_at=_as_datetime(values["completed_at"], mysql=mysql),
|
||||||
|
mode_intent=values["mode_intent"],
|
||||||
|
setup_step=values["setup_step"],
|
||||||
|
model_profile_id=values["model_profile_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _as_datetime(value: object) -> datetime | None:
|
def _as_datetime(
|
||||||
"""SQLite stores these as text; MySQL returns `datetime` objects."""
|
value: object,
|
||||||
if value is None or value == "":
|
*,
|
||||||
|
mysql: bool,
|
||||||
|
nullable: bool = True,
|
||||||
|
) -> datetime | None:
|
||||||
|
"""Validate the engine's timestamp shape and normalize it to UTC."""
|
||||||
|
if value is None:
|
||||||
|
if not nullable:
|
||||||
|
raise ValueError("onboarding_datetime_invalid")
|
||||||
return None
|
return None
|
||||||
if isinstance(value, datetime):
|
if mysql:
|
||||||
return value
|
if not isinstance(value, datetime):
|
||||||
return datetime.fromisoformat(str(value))
|
raise ValueError("onboarding_datetime_invalid")
|
||||||
|
parsed = value
|
||||||
|
else:
|
||||||
|
if not isinstance(value, str) or value == "":
|
||||||
|
raise ValueError("onboarding_datetime_invalid")
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=UTC)
|
||||||
|
return parsed.astimezone(UTC)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Turn-level read projection for SDK raw batches.
|
||||||
|
|
||||||
|
HY keeps several complete conversation turns in one L1 raw node so model
|
||||||
|
writes stay batched. Product readers must not expose that storage batch as
|
||||||
|
one conversation item: this module gives every contained turn a stable
|
||||||
|
identity while retaining the parent raw id for traceability.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from one_memory.hy_sdk_types import HySdkRawRecord, raw_turn_messages
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RawDisplayTurn:
|
||||||
|
turn_id: str
|
||||||
|
turn_index: int
|
||||||
|
turn_count: int
|
||||||
|
content: str
|
||||||
|
messages: tuple[tuple[str, str], ...]
|
||||||
|
|
||||||
|
|
||||||
|
def raw_display_turns(record: HySdkRawRecord) -> tuple[RawDisplayTurn, ...]:
|
||||||
|
"""Split one stored raw batch into user-opened turns.
|
||||||
|
|
||||||
|
A user message opens a new turn; assistant/tool/system messages stay with
|
||||||
|
that user message until the next user message. Legacy content with no user
|
||||||
|
marker remains one turn so it never disappears from the product.
|
||||||
|
"""
|
||||||
|
messages = raw_turn_messages(record.content)
|
||||||
|
grouped: list[list[tuple[str, str]]] = []
|
||||||
|
for message in messages:
|
||||||
|
role, _ = message
|
||||||
|
has_user = bool(grouped) and any(item[0] == "user" for item in grouped[-1])
|
||||||
|
if not grouped or (role == "user" and has_user):
|
||||||
|
grouped.append([message])
|
||||||
|
else:
|
||||||
|
grouped[-1].append(message)
|
||||||
|
if not grouped:
|
||||||
|
grouped = [[("", record.content)]]
|
||||||
|
|
||||||
|
digest = hashlib.sha256(record.raw_id.encode("utf-8")).hexdigest()
|
||||||
|
count = len(grouped)
|
||||||
|
return tuple(
|
||||||
|
RawDisplayTurn(
|
||||||
|
turn_id=f"raw-turn:{digest}:{index}",
|
||||||
|
turn_index=index,
|
||||||
|
turn_count=count,
|
||||||
|
content=_turn_content(turn),
|
||||||
|
messages=tuple(turn),
|
||||||
|
)
|
||||||
|
for index, turn in enumerate(grouped)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _turn_content(messages: list[tuple[str, str]]) -> str:
|
||||||
|
return "\n".join(f"[{role}]: {content}" if role else content for role, content in messages)
|
||||||
|
|
||||||
|
|
||||||
|
def raw_prefix_added(
|
||||||
|
cached: tuple[tuple[str, str | None], ...],
|
||||||
|
current: tuple[tuple[str, str | None], ...],
|
||||||
|
cached_total: int,
|
||||||
|
current_total: int,
|
||||||
|
) -> int | None:
|
||||||
|
"""How many raws were only put on top of a cached newest-first listing.
|
||||||
|
|
||||||
|
`cached` and `current` are the (raw id, status) of the first page of each
|
||||||
|
listing. The answer is a count when the current page is the cached one
|
||||||
|
with new raws before it and nothing else changed; None when anything else
|
||||||
|
happened (a delete, a status change, a raw dated into the middle), which
|
||||||
|
needs a full re-listing.
|
||||||
|
"""
|
||||||
|
added = current_total - cached_total
|
||||||
|
if added <= 0 or added > len(current):
|
||||||
|
return None
|
||||||
|
kept = current[added:]
|
||||||
|
if not kept and cached_total:
|
||||||
|
return None
|
||||||
|
if kept != cached[: len(kept)]:
|
||||||
|
return None
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
_WORD = re.compile(r"\w+")
|
||||||
|
|
||||||
|
|
||||||
|
def _query_terms(query: str) -> frozenset[str]:
|
||||||
|
"""Words of the query, plus character bigrams of unspaced (CJK) words."""
|
||||||
|
terms: set[str] = set()
|
||||||
|
for word in _WORD.findall(query.casefold()):
|
||||||
|
terms.add(word)
|
||||||
|
if not word.isascii() and len(word) > 1:
|
||||||
|
terms.update(word[index : index + 2] for index in range(len(word) - 1))
|
||||||
|
return frozenset(terms)
|
||||||
|
|
||||||
|
|
||||||
|
def turns_for_hit(turns: tuple[RawDisplayTurn, ...], query: str) -> tuple[RawDisplayTurn, ...]:
|
||||||
|
"""The turns one semantic raw hit shows, never none while it has turns.
|
||||||
|
|
||||||
|
The SDK embeds a whole raw batch, so a vector hit cannot say which of its
|
||||||
|
turns matched. Every turn containing the query text is shown; when none
|
||||||
|
does, the turn sharing the most query terms (the earliest on a tie) stands
|
||||||
|
for the hit. A semantic hit is never dropped for lacking the literal query.
|
||||||
|
"""
|
||||||
|
if not turns:
|
||||||
|
return ()
|
||||||
|
needle = query.casefold().strip()
|
||||||
|
if needle:
|
||||||
|
lexical = tuple(turn for turn in turns if needle in turn.content.casefold())
|
||||||
|
if lexical:
|
||||||
|
return lexical
|
||||||
|
terms = _query_terms(query)
|
||||||
|
|
||||||
|
def overlap(turn: RawDisplayTurn) -> tuple[int, int]:
|
||||||
|
content = turn.content.casefold()
|
||||||
|
return (sum(1 for term in terms if term in content), -turn.turn_index)
|
||||||
|
|
||||||
|
return (max(turns, key=overlap),)
|
||||||
@@ -135,6 +135,8 @@ class RecallReceipt(BaseModel):
|
|||||||
#: exists yet (`fallback_reason`). Raw hits are excerpts, not memories.
|
#: exists yet (`fallback_reason`). Raw hits are excerpts, not memories.
|
||||||
mode: Literal["memory", "raw"] = "memory"
|
mode: Literal["memory", "raw"] = "memory"
|
||||||
fallback_reason: Literal["model_unavailable", "no_memories"] | None = None
|
fallback_reason: Literal["model_unavailable", "no_memories"] | None = None
|
||||||
|
#: Whose memories were searched: the request's `user_id`, else `local`.
|
||||||
|
user_id: str = DEFAULT_USER_ID
|
||||||
|
|
||||||
|
|
||||||
class RecallTarget(Protocol):
|
class RecallTarget(Protocol):
|
||||||
|
|||||||
@@ -32,6 +32,19 @@ FallbackReason = Literal["model_unavailable", "no_memories"]
|
|||||||
#: caller). An empty set means the caller may read no raw conversation.
|
#: caller). An empty set means the caller may read no raw conversation.
|
||||||
RawSources = frozenset[str] | None
|
RawSources = frozenset[str] | None
|
||||||
|
|
||||||
|
#: What the whole recall request may take beyond the caller's own
|
||||||
|
#: `timeout_ms`: the search keeps to that budget, but the checks around it
|
||||||
|
#: (is a model configured, is the library empty, which raw sources) and the
|
||||||
|
#: raw fallback need a little room of their own. Well under the broker's 15 s
|
||||||
|
#: wait on Core, so a slow Core answers 504 before the broker gives up.
|
||||||
|
RECALL_TOTAL_GRACE_SECONDS = 3.0
|
||||||
|
RECALL_TOTAL_CEILING_SECONDS = 12.0
|
||||||
|
|
||||||
|
|
||||||
|
def recall_total_timeout_seconds(timeout_ms: int) -> float:
|
||||||
|
"""The most one recall request may take from start to answer."""
|
||||||
|
return min(timeout_ms / 1_000 + RECALL_TOTAL_GRACE_SECONDS, RECALL_TOTAL_CEILING_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
async def recall_with_fallback(
|
async def recall_with_fallback(
|
||||||
request: RecallRequest,
|
request: RecallRequest,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from one_memory.model_profiles import (
|
|||||||
)
|
)
|
||||||
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
|
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
|
||||||
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
|
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
|
||||||
from one_memory.user_scope import owns
|
from one_memory.user_scope import DEFAULT_USER_ID, owns
|
||||||
|
|
||||||
_NO_GROUNDING = "现有记忆不足以回答这个问题。"
|
_NO_GROUNDING = "现有记忆不足以回答这个问题。"
|
||||||
_GROUNDING_LIMIT = 6
|
_GROUNDING_LIMIT = 6
|
||||||
@@ -62,6 +62,8 @@ class SdkMemoryAnswer(BaseModel):
|
|||||||
answer: str
|
answer: str
|
||||||
memories: tuple[SdkAskMemory, ...]
|
memories: tuple[SdkAskMemory, ...]
|
||||||
mode: Literal["generated", "extractive", "empty"]
|
mode: Literal["generated", "extractive", "empty"]
|
||||||
|
#: Whose memories answered: the request's `user_id`, else `local`.
|
||||||
|
user_id: str = DEFAULT_USER_ID
|
||||||
|
|
||||||
|
|
||||||
class _PromptMemory:
|
class _PromptMemory:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from one_memory.hy_runtime_config import HY_MEMORY_READER
|
|||||||
from one_memory.hy_sdk_runtime import SdkClientHandle, sdk_relevance_floor
|
from one_memory.hy_sdk_runtime import SdkClientHandle, sdk_relevance_floor
|
||||||
from one_memory.hy_sdk_types import HySdkError, parse_sdk_search
|
from one_memory.hy_sdk_types import HySdkError, parse_sdk_search
|
||||||
from one_memory.memory_queue import ExtractionWindowPreset, QueueDepths
|
from one_memory.memory_queue import ExtractionWindowPreset, QueueDepths
|
||||||
from one_memory.memory_server.durable_queue import COMPLETED, DurableWriteQueue
|
from one_memory.memory_server.durable_queue import WAITING_FOR_MODEL, DurableWriteQueue
|
||||||
from one_memory.sdk_write_processor import SdkWriteCoordinator
|
from one_memory.sdk_write_processor import SdkWriteCoordinator
|
||||||
from one_memory.semantic_deadline import SemanticMemoryHit
|
from one_memory.semantic_deadline import SemanticMemoryHit
|
||||||
|
|
||||||
@@ -34,6 +34,10 @@ _STABLE_CODE = re.compile(r"[a-z][a-z0-9_]{0,95}")
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Jobs not yet run that a suspended source holds back.
|
||||||
|
_WAITING_STATES = frozenset({"pending", "waiting_for_model", "waiting_for_permission"})
|
||||||
|
_WAITING_SQL = ",".join(f"'{state}'" for state in sorted(_WAITING_STATES))
|
||||||
|
|
||||||
|
|
||||||
class SdkMemoryRuntime:
|
class SdkMemoryRuntime:
|
||||||
"""Product services that remain after legacy task/governance writes retire."""
|
"""Product services that remain after legacy task/governance writes retire."""
|
||||||
@@ -48,12 +52,19 @@ class SdkMemoryRuntime:
|
|||||||
data_dir: Path,
|
data_dir: Path,
|
||||||
memory_mode: Callable[[], str] = lambda: "pro",
|
memory_mode: Callable[[], str] = lambda: "pro",
|
||||||
writes_open: Callable[[], bool] = lambda: True,
|
writes_open: Callable[[], bool] = lambda: True,
|
||||||
|
source_active: Callable[[str], bool] = lambda _source_key: True,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.coordinator = coordinator
|
self.coordinator = coordinator
|
||||||
#: The SDK mode, for the graph store's path (`hy_graph_admin`).
|
#: The SDK mode, for the graph store's path (`hy_graph_admin`).
|
||||||
self.memory_mode = memory_mode
|
self.memory_mode = memory_mode
|
||||||
#: False while an embedding switch pauses model writes.
|
#: False while an embedding switch pauses model writes.
|
||||||
self.writes_open = writes_open
|
self.writes_open = writes_open
|
||||||
|
#: Whether a queue lane's source may still become memories: the same
|
||||||
|
#: question the drain's claim asks of it. A lane that is off (the
|
||||||
|
#: user turned the source off, or its grant was revoked) has its
|
||||||
|
#: waiting jobs suspended: kept, never claimed, and not counted as
|
||||||
|
#: pending until the source is on again.
|
||||||
|
self.source_active = source_active
|
||||||
self.read_handle = read_handle
|
self.read_handle = read_handle
|
||||||
self.write_queue = write_queue
|
self.write_queue = write_queue
|
||||||
self.extraction_settings = extraction_settings
|
self.extraction_settings = extraction_settings
|
||||||
@@ -241,22 +252,11 @@ class SdkMemoryRuntime:
|
|||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
fresh_since = (now - timedelta(days=7)).isoformat()
|
fresh_since = (now - timedelta(days=7)).isoformat()
|
||||||
with main_database(self.write_queue.database_path).read() as database:
|
with main_database(self.write_queue.database_path).read() as database:
|
||||||
fresh = int(
|
rows = database.execute(
|
||||||
database.execute(
|
"SELECT source_key,event_at>=? AS fresh,COUNT(*) FROM memory_write_jobs "
|
||||||
"SELECT COUNT(*) FROM memory_write_jobs "
|
f"WHERE state IN ({_WAITING_SQL}) GROUP BY source_key,fresh",
|
||||||
"WHERE state IN ('pending','waiting_for_model','waiting_for_permission') "
|
(fresh_since,),
|
||||||
"AND event_at>=?",
|
).fetchall()
|
||||||
(fresh_since,),
|
|
||||||
).fetchone()[0]
|
|
||||||
)
|
|
||||||
backlog = int(
|
|
||||||
database.execute(
|
|
||||||
"SELECT COUNT(*) FROM memory_write_jobs "
|
|
||||||
"WHERE state IN ('pending','waiting_for_model','waiting_for_permission') "
|
|
||||||
"AND event_at<?",
|
|
||||||
(fresh_since,),
|
|
||||||
).fetchone()[0]
|
|
||||||
)
|
|
||||||
recent = tuple(
|
recent = tuple(
|
||||||
datetime.fromisoformat(str(row[0]))
|
datetime.fromisoformat(str(row[0]))
|
||||||
for row in database.execute(
|
for row in database.execute(
|
||||||
@@ -264,6 +264,9 @@ class SdkMemoryRuntime:
|
|||||||
"ORDER BY updated_at DESC LIMIT 8"
|
"ORDER BY updated_at DESC LIMIT 8"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
active = self._active_lanes({str(row[0]) for row in rows})
|
||||||
|
fresh = sum(int(row[2]) for row in rows if str(row[0]) in active and row[1])
|
||||||
|
backlog = sum(int(row[2]) for row in rows if str(row[0]) in active and not row[1])
|
||||||
return QueueDepths(fresh=fresh, backlog=backlog, recent_completions=recent)
|
return QueueDepths(fresh=fresh, backlog=backlog, recent_completions=recent)
|
||||||
|
|
||||||
def active_depths(self) -> tuple[int, int]:
|
def active_depths(self) -> tuple[int, int]:
|
||||||
@@ -276,13 +279,27 @@ class SdkMemoryRuntime:
|
|||||||
return running, running
|
return running, running
|
||||||
|
|
||||||
def status_counts(self) -> dict[str, int]:
|
def status_counts(self) -> dict[str, int]:
|
||||||
|
"""Jobs per state, with a suspended source's waiting jobs as `suspended`.
|
||||||
|
|
||||||
|
Those jobs keep their state in the table: suspension is the source's
|
||||||
|
switch, not the row's, so turning the source back on is all it takes
|
||||||
|
for the drain to claim them again, and nothing is moved or deleted.
|
||||||
|
"""
|
||||||
with main_database(self.write_queue.database_path).read() as database:
|
with main_database(self.write_queue.database_path).read() as database:
|
||||||
return {
|
rows = database.execute(
|
||||||
str(row[0]): int(row[1])
|
"SELECT source_key,state,COUNT(*) FROM memory_write_jobs GROUP BY source_key,state"
|
||||||
for row in database.execute(
|
).fetchall()
|
||||||
"SELECT state,COUNT(*) FROM memory_write_jobs GROUP BY state"
|
active = self._active_lanes({str(row[0]) for row in rows if str(row[1]) in _WAITING_STATES})
|
||||||
)
|
counts: dict[str, int] = {}
|
||||||
}
|
for source_key, state, count in rows:
|
||||||
|
name = str(state)
|
||||||
|
if name in _WAITING_STATES and str(source_key) not in active:
|
||||||
|
name = "suspended"
|
||||||
|
counts[name] = counts.get(name, 0) + int(count)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
def _active_lanes(self, lanes: set[str]) -> set[str]:
|
||||||
|
return {lane for lane in lanes if self.source_active(lane)}
|
||||||
|
|
||||||
def manual_batch_progress(self) -> tuple[int, int]:
|
def manual_batch_progress(self) -> tuple[int, int]:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
@@ -291,33 +308,11 @@ class SdkMemoryRuntime:
|
|||||||
del task_ids
|
del task_ids
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def reconcile_unknown_outputs(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
limit: int = 128,
|
|
||||||
exclude_error_codes: tuple[str, ...] = (),
|
|
||||||
) -> int:
|
|
||||||
reconciled = 0
|
|
||||||
for job in await asyncio.to_thread(
|
|
||||||
self.write_queue.unknown_jobs,
|
|
||||||
limit=limit,
|
|
||||||
exclude_error_codes=exclude_error_codes,
|
|
||||||
):
|
|
||||||
receipt = await self.coordinator.inquire_write_outcome(job.request_id)
|
|
||||||
if receipt is None:
|
|
||||||
continue
|
|
||||||
await asyncio.to_thread(
|
|
||||||
self.write_queue.settle,
|
|
||||||
job.job_id,
|
|
||||||
state=COMPLETED,
|
|
||||||
sdk_raw_ids=receipt.raw_ids,
|
|
||||||
sdk_memory_ids=receipt.memory_ids,
|
|
||||||
)
|
|
||||||
reconciled += 1
|
|
||||||
return reconciled
|
|
||||||
|
|
||||||
async def resume_allowed(self) -> None:
|
async def resume_allowed(self) -> None:
|
||||||
await asyncio.to_thread(self.write_queue.readmit_parked)
|
await asyncio.to_thread(
|
||||||
|
self.write_queue.readmit_parked,
|
||||||
|
states=(WAITING_FOR_MODEL,),
|
||||||
|
)
|
||||||
|
|
||||||
def accepted_backfill_status(self) -> None:
|
def accepted_backfill_status(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from collections.abc import Callable
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from one_memory.background_work import in_sdk_read
|
||||||
from one_memory.embedding_health import EmbeddingGuardedClient, EmbeddingHealth
|
from one_memory.embedding_health import EmbeddingGuardedClient, EmbeddingHealth
|
||||||
from one_memory.embedding_spaces import (
|
from one_memory.embedding_spaces import (
|
||||||
EmbeddingSpace,
|
EmbeddingSpace,
|
||||||
@@ -93,6 +94,11 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
|||||||
handle = await self.current_handle()
|
handle = await self.current_handle()
|
||||||
return self._guarded(handle, await handle.client())
|
return self._guarded(handle, await handle.client())
|
||||||
|
|
||||||
|
async def client_for_mode(self, mode: str) -> Any:
|
||||||
|
"""Build/read with the caller's one mode snapshot, without rereading it."""
|
||||||
|
handle = await self.current_handle(mode=mode)
|
||||||
|
return self._guarded(handle, await handle.client())
|
||||||
|
|
||||||
def _guarded(self, handle: SdkClientHandle, client: Any) -> Any:
|
def _guarded(self, handle: SdkClientHandle, client: Any) -> Any:
|
||||||
"""A remote embedding's searches report `embedding_unavailable` (no fallback)."""
|
"""A remote embedding's searches report `embedding_unavailable` (no fallback)."""
|
||||||
profile = getattr(handle, "profile", None)
|
profile = getattr(handle, "profile", None)
|
||||||
@@ -110,15 +116,15 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def current_handle(self) -> SdkClientHandle:
|
async def current_handle(self, *, mode: str | None = None) -> SdkClientHandle:
|
||||||
"""The handle of the live space, held for the asking task."""
|
"""The handle of the live space, held for the asking task."""
|
||||||
space, identity = await asyncio.to_thread(self._resolve)
|
space, identity = await in_sdk_read(self._resolve)
|
||||||
mode, features = self.memory_mode(), self.current_features()
|
selected_mode, features = mode or self.memory_mode(), self.current_features()
|
||||||
language = self.current_language()
|
language = self.current_language()
|
||||||
key = (
|
key = (
|
||||||
space.space_id if space is not None else "",
|
space.space_id if space is not None else "",
|
||||||
*identity,
|
*identity,
|
||||||
mode,
|
selected_mode,
|
||||||
features,
|
features,
|
||||||
language,
|
language,
|
||||||
)
|
)
|
||||||
@@ -127,8 +133,8 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
|||||||
if handle is None:
|
if handle is None:
|
||||||
# Only on a change of space or profile: resolving a key can
|
# Only on a change of space or profile: resolving a key can
|
||||||
# reach the system keychain, so it is not done on every read.
|
# reach the system keychain, so it is not done on every read.
|
||||||
handle = await asyncio.to_thread(
|
handle = await in_sdk_read(
|
||||||
self._build, space, identity, mode, features, language
|
self._build, space, identity, selected_mode, features, language
|
||||||
)
|
)
|
||||||
self._handles[key] = handle
|
self._handles[key] = handle
|
||||||
self._retired.update(value for name, value in self._handles.items() if name != key)
|
self._retired.update(value for name, value in self._handles.items() if name != key)
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from one_memory.background_work import in_sdk_write
|
||||||
from one_memory.embedding_spaces import (
|
from one_memory.embedding_spaces import (
|
||||||
EmbeddingSpace,
|
EmbeddingSpace,
|
||||||
EmbeddingSpaceStore,
|
EmbeddingSpaceStore,
|
||||||
@@ -20,14 +23,70 @@ from one_memory.hy_sdk_runtime import (
|
|||||||
SdkClientHandle,
|
SdkClientHandle,
|
||||||
builtin_retrieval_snapshot,
|
builtin_retrieval_snapshot,
|
||||||
language_kwargs,
|
language_kwargs,
|
||||||
|
retrieval_view,
|
||||||
)
|
)
|
||||||
from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord
|
from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord
|
||||||
from one_memory.input_sanitization import sanitize_model_input
|
from one_memory.input_sanitization import sanitize_model_input
|
||||||
from one_memory.memory_features import FeatureOverrides
|
from one_memory.memory_features import FeatureOverrides
|
||||||
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore
|
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore, ProviderKind
|
||||||
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
|
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
|
||||||
|
|
||||||
PRODUCT_AGENT_ID = "all-apps"
|
PRODUCT_AGENT_ID = "all-apps"
|
||||||
|
|
||||||
|
#: SDK request ids whose write call is running on a thread right now. A write
|
||||||
|
#: the drain gave up on (its work ceiling, a lost lease) keeps running on its
|
||||||
|
#: thread; it must not be re-run beside itself (`recover_unknown`).
|
||||||
|
_IN_FLIGHT: set[str] = set()
|
||||||
|
_IN_FLIGHT_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def sdk_write_in_flight(request_id: str) -> bool:
|
||||||
|
with _IN_FLIGHT_LOCK:
|
||||||
|
return request_id in _IN_FLIGHT
|
||||||
|
|
||||||
|
|
||||||
|
class _Tracked:
|
||||||
|
"""One SDK write call, held in `_IN_FLIGHT` from submission until it ends.
|
||||||
|
|
||||||
|
If the wait for it is cancelled before a thread picked it up, it is
|
||||||
|
abandoned: it will not start, and it leaves `_IN_FLIGHT` at once.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, request_id: str, work: Callable[..., Any]) -> None:
|
||||||
|
self.request_id = request_id
|
||||||
|
self.work = work
|
||||||
|
self.started = False
|
||||||
|
self.abandoned = False
|
||||||
|
with _IN_FLIGHT_LOCK:
|
||||||
|
_IN_FLIGHT.add(request_id)
|
||||||
|
|
||||||
|
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
with _IN_FLIGHT_LOCK:
|
||||||
|
if self.abandoned:
|
||||||
|
raise RuntimeError("hy_memory_write_abandoned")
|
||||||
|
self.started = True
|
||||||
|
try:
|
||||||
|
return self.work(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
with _IN_FLIGHT_LOCK:
|
||||||
|
_IN_FLIGHT.discard(self.request_id)
|
||||||
|
|
||||||
|
def abandon(self) -> None:
|
||||||
|
with _IN_FLIGHT_LOCK:
|
||||||
|
if not self.started:
|
||||||
|
self.abandoned = True
|
||||||
|
_IN_FLIGHT.discard(self.request_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write(tracked_id: str, work: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
call = _Tracked(tracked_id, work)
|
||||||
|
try:
|
||||||
|
return await in_sdk_write(call, *args, **kwargs)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
call.abandon()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
_MESSAGE_ROLES = frozenset({"user", "assistant", "tool"})
|
_MESSAGE_ROLES = frozenset({"user", "assistant", "tool"})
|
||||||
_CONVERSATION_ROLES = frozenset({"user", "assistant"})
|
_CONVERSATION_ROLES = frozenset({"user", "assistant"})
|
||||||
|
|
||||||
@@ -54,7 +113,7 @@ class SdkWriteProcessor:
|
|||||||
records: tuple[EvidenceRecord, ...],
|
records: tuple[EvidenceRecord, ...],
|
||||||
*,
|
*,
|
||||||
request_id: str,
|
request_id: str,
|
||||||
before_hy_invocation: Callable[[], Awaitable[None]],
|
before_hy_invocation: Callable[[bool], Awaitable[None]],
|
||||||
) -> SdkWriteReceipt:
|
) -> SdkWriteReceipt:
|
||||||
if not records:
|
if not records:
|
||||||
raise RuntimeError("hy_memory_task_without_inputs")
|
raise RuntimeError("hy_memory_task_without_inputs")
|
||||||
@@ -81,8 +140,9 @@ class SdkWriteProcessor:
|
|||||||
|
|
||||||
if extracted and not self._lite:
|
if extracted and not self._lite:
|
||||||
contents = [sanitize_model_input(record.verbatim) for record in records]
|
contents = [sanitize_model_input(record.verbatim) for record in records]
|
||||||
await before_hy_invocation()
|
await before_hy_invocation(True)
|
||||||
result = await asyncio.to_thread(
|
result = await _write(
|
||||||
|
request_id,
|
||||||
client.add_extracted,
|
client.add_extracted,
|
||||||
contents,
|
contents,
|
||||||
user_id=scope.user_id,
|
user_id=scope.user_id,
|
||||||
@@ -108,8 +168,9 @@ class SdkWriteProcessor:
|
|||||||
]
|
]
|
||||||
if not messages:
|
if not messages:
|
||||||
raise RuntimeError("hy_memory_task_without_inputs")
|
raise RuntimeError("hy_memory_task_without_inputs")
|
||||||
await before_hy_invocation()
|
await before_hy_invocation(not self._lite)
|
||||||
result = await asyncio.to_thread(
|
result = await _write(
|
||||||
|
request_id,
|
||||||
client.add,
|
client.add,
|
||||||
messages,
|
messages,
|
||||||
user_id=scope.user_id,
|
user_id=scope.user_id,
|
||||||
@@ -130,7 +191,7 @@ class SdkWriteProcessor:
|
|||||||
if not request_id:
|
if not request_id:
|
||||||
raise ValueError("request_id is required")
|
raise ValueError("request_id is required")
|
||||||
client = await self._client_handle.client()
|
client = await self._client_handle.client()
|
||||||
outcome = await asyncio.to_thread(client.get_write_outcome, request_id)
|
outcome = await in_sdk_write(client.get_write_outcome, request_id)
|
||||||
if outcome is None:
|
if outcome is None:
|
||||||
return None
|
return None
|
||||||
if not isinstance(outcome, Mapping):
|
if not isinstance(outcome, Mapping):
|
||||||
@@ -223,10 +284,8 @@ class SdkWriteCoordinator:
|
|||||||
records: tuple[EvidenceRecord, ...],
|
records: tuple[EvidenceRecord, ...],
|
||||||
*,
|
*,
|
||||||
request_id: str,
|
request_id: str,
|
||||||
before_hy_invocation: Callable[[], Awaitable[None]],
|
before_hy_invocation: Callable[[bool], Awaitable[None]],
|
||||||
) -> SdkWriteReceipt:
|
) -> SdkWriteReceipt:
|
||||||
if not self.sedimentation_enabled():
|
|
||||||
raise RuntimeError("model_sedimentation_disabled")
|
|
||||||
if not self.writes_open():
|
if not self.writes_open():
|
||||||
# An embedding switch paused writes: the drain puts the job back.
|
# An embedding switch paused writes: the drain puts the job back.
|
||||||
raise RuntimeError("embedding_switch_in_progress")
|
raise RuntimeError("embedding_switch_in_progress")
|
||||||
@@ -237,9 +296,8 @@ class SdkWriteCoordinator:
|
|||||||
self._writing[user_id] = self._writing.get(user_id, 0) + 1
|
self._writing[user_id] = self._writing.get(user_id, 0) + 1
|
||||||
try:
|
try:
|
||||||
async with self._active_handle() as handle:
|
async with self._active_handle() as handle:
|
||||||
return await SdkWriteProcessor(
|
lite = getattr(handle, "mode", None) == "lite"
|
||||||
handle, lite=getattr(handle, "mode", None) == "lite"
|
return await SdkWriteProcessor(handle, lite=lite).process_sdk_job(
|
||||||
).process_sdk_job(
|
|
||||||
records,
|
records,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
before_hy_invocation=before_hy_invocation,
|
before_hy_invocation=before_hy_invocation,
|
||||||
@@ -406,24 +464,27 @@ class SdkWriteCoordinator:
|
|||||||
return (builtin.profile_id, builtin.revision), builtin, None, space
|
return (builtin.profile_id, builtin.revision), builtin, None, space
|
||||||
identity = (space.profile_id, space.profile_revision)
|
identity = (space.profile_id, space.profile_revision)
|
||||||
profile = self.profiles.snapshot(*identity)
|
profile = self.profiles.snapshot(*identity)
|
||||||
try:
|
view = retrieval_view(profile)
|
||||||
secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets)
|
secret: str | None = None
|
||||||
except SecretStoreUnavailableError:
|
if view.embedding_provider_kind is ProviderKind.OPENAI_COMPATIBLE:
|
||||||
raise RuntimeError("secure_storage_unavailable") from None
|
try:
|
||||||
if profile.has_api_key and secret is None:
|
secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets)
|
||||||
raise RuntimeError("model_secret_unavailable")
|
except SecretStoreUnavailableError:
|
||||||
return identity, profile, secret, space
|
raise RuntimeError("secure_storage_unavailable") from None
|
||||||
|
if secret is None:
|
||||||
|
raise RuntimeError("model_secret_unavailable")
|
||||||
|
return identity, view, secret, space
|
||||||
|
|
||||||
def _read_active_credentials(
|
def _read_active_credentials(
|
||||||
self,
|
self,
|
||||||
mode: str = "pro",
|
mode: str = "pro",
|
||||||
) -> tuple[tuple[str, int], ModelProfileSnapshot, str | None, EmbeddingSpace | None]:
|
) -> tuple[tuple[str, int], ModelProfileSnapshot, str | None, EmbeddingSpace | None]:
|
||||||
|
if mode == "lite":
|
||||||
|
# A lite write is an embedding-only view of the live space. Never
|
||||||
|
# resolve or inject the generation model endpoint or its secret.
|
||||||
|
return self._lite_credentials()
|
||||||
identity = self.profiles.active_identity()
|
identity = self.profiles.active_identity()
|
||||||
if identity is None:
|
if identity is None:
|
||||||
if mode == "lite":
|
|
||||||
# lite stores raw turns with one embedding and calls no model:
|
|
||||||
# the live space's own, so they land where reads look.
|
|
||||||
return self._lite_credentials()
|
|
||||||
raise RuntimeError("model_profile_not_active")
|
raise RuntimeError("model_profile_not_active")
|
||||||
profile = self.profiles.snapshot(*identity)
|
profile = self.profiles.snapshot(*identity)
|
||||||
space = self._live_space() if self.spaces is not None else None
|
space = self._live_space() if self.spaces is not None else None
|
||||||
@@ -509,11 +570,36 @@ def _is_extracted_content(record: EvidenceRecord) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: The SDK's stable failure codes are upper snake case (`RECONCILE_FAILED`).
|
||||||
|
_SDK_FAILURE_CODE = re.compile(r"[A-Z][A-Z0-9_]{0,47}")
|
||||||
|
|
||||||
|
|
||||||
|
def sdk_write_failure_code(value: Mapping[object, object]) -> str:
|
||||||
|
"""The job's `error_code` for a failed SDK write: the SDK's own cause if it gave one.
|
||||||
|
|
||||||
|
hy-memory 2.0.13 names why a write failed: `agent_error_code`
|
||||||
|
(`LLM_ERROR`, `EMPTY_RESPONSE`, `JSON_PARSE_FAILED`, `EMBEDDING_FAILED`,
|
||||||
|
`RECONCILE_FAILED`) and, for a failed reconcile, `reconcile_error_code`
|
||||||
|
(`LLM_UNAVAILABLE`, `EMPTY_RESPONSE`, `JSON_PARSE_FAILED`,
|
||||||
|
`RECONCILE_EXCEPTION`). They become `hy_sdk_reconcile_empty_response`,
|
||||||
|
`hy_sdk_llm_error`, ... Without a recognisable code it stays
|
||||||
|
`hy_sdk_write_failed`. Only the code is kept, never the SDK's message.
|
||||||
|
"""
|
||||||
|
reconcile = value.get("reconcile_error_code")
|
||||||
|
if isinstance(reconcile, str) and _SDK_FAILURE_CODE.fullmatch(reconcile):
|
||||||
|
cause = reconcile.removeprefix("RECONCILE_") or "FAILED"
|
||||||
|
return f"hy_sdk_reconcile_{cause.lower()}"
|
||||||
|
agent = value.get("agent_error_code")
|
||||||
|
if isinstance(agent, str) and _SDK_FAILURE_CODE.fullmatch(agent):
|
||||||
|
return f"hy_sdk_{agent.lower()}"
|
||||||
|
return "hy_sdk_write_failed"
|
||||||
|
|
||||||
|
|
||||||
def _receipt_from_result(value: object) -> SdkWriteReceipt:
|
def _receipt_from_result(value: object) -> SdkWriteReceipt:
|
||||||
if not isinstance(value, Mapping):
|
if not isinstance(value, Mapping):
|
||||||
raise RuntimeError("hy_sdk_write_receipt_shape_invalid")
|
raise RuntimeError("hy_sdk_write_receipt_shape_invalid")
|
||||||
if value.get("success") is not True:
|
if value.get("success") is not True:
|
||||||
raise RuntimeError("hy_sdk_write_failed")
|
raise RuntimeError(sdk_write_failure_code(value))
|
||||||
|
|
||||||
# Read only documented public receipt fields. ``variant`` identifies the
|
# Read only documented public receipt fields. ``variant`` identifies the
|
||||||
# productivity namespace; IDs from both variants are canonical memory IDs.
|
# productivity namespace; IDs from both variants are canonical memory IDs.
|
||||||
|
|||||||
@@ -3,14 +3,19 @@
|
|||||||
The App owns the choice (its language selector) and tells Core whenever it
|
The App owns the choice (its language selector) and tells Core whenever it
|
||||||
changes, so Core can build every SDK client with it. It is one global value in
|
changes, so Core can build every SDK client with it. It is one global value in
|
||||||
`config.json` (`ui_language`), beside `memory_mode` and `memory_features`;
|
`config.json` (`ui_language`), beside `memory_mode` and `memory_features`;
|
||||||
absent (never chosen, or "follow the system") means no explicit choice.
|
absent means no language was set (a CLI/API-only install, or
|
||||||
|
`settings language system`). An App that follows the system sends the
|
||||||
|
language it resolved and shows, not null.
|
||||||
|
|
||||||
The owner's rule for the SDK:
|
The owner's rule for the SDK:
|
||||||
|
|
||||||
- zh-Hans, or no explicit choice: pass no language. The SDK keeps detecting
|
- Any supported code, zh-Hans and en included: pass it as
|
||||||
Chinese or English from the text, exactly as before this setting existed.
|
`MemoryConfig.language`, so extraction and the other prompts use that
|
||||||
- Any other code, including en: pass it as `MemoryConfig.language`, so
|
language. Every display language is passed explicitly (#243).
|
||||||
extraction and the other prompts use that language.
|
- No language at all (never set, as on a CLI/API-only install): pass no
|
||||||
|
language. The SDK keeps detecting Chinese or English from the text, exactly
|
||||||
|
as before this setting existed. The App does not leave it unset: it sends
|
||||||
|
the language it shows, including when it follows the system.
|
||||||
|
|
||||||
The language never names a collection; a change rebuilds the SDK handles the
|
The language never names a collection; a change rebuilds the SDK handles the
|
||||||
way a mode or feature change does (#218 use count).
|
way a mode or feature change does (#218 use count).
|
||||||
@@ -51,8 +56,6 @@ UI_LANGUAGES: tuple[UiLanguage, ...] = (
|
|||||||
)
|
)
|
||||||
#: Codes earlier App builds stored, and the code each one is now.
|
#: Codes earlier App builds stored, and the code each one is now.
|
||||||
LEGACY_UI_LANGUAGES: dict[str, UiLanguage] = {"zh-CN": "zh-Hans"}
|
LEGACY_UI_LANGUAGES: dict[str, UiLanguage] = {"zh-CN": "zh-Hans"}
|
||||||
#: Display languages that leave the SDK's own zh/en detection in charge.
|
|
||||||
AUTO_DETECTED: frozenset[str] = frozenset({"zh-Hans"})
|
|
||||||
#: What status shows when no language is passed to the SDK.
|
#: What status shows when no language is passed to the SDK.
|
||||||
SDK_LANGUAGE_AUTO = "auto"
|
SDK_LANGUAGE_AUTO = "auto"
|
||||||
|
|
||||||
@@ -66,8 +69,9 @@ def normalize_ui_language(value: object) -> UiLanguage | None:
|
|||||||
|
|
||||||
|
|
||||||
def sdk_language(ui_language: str | None) -> str | None:
|
def sdk_language(ui_language: str | None) -> str | None:
|
||||||
"""The `MemoryConfig.language` for a display language; None = SDK auto-detect."""
|
"""The `MemoryConfig.language` for a display language; None = SDK auto-detect.
|
||||||
language = normalize_ui_language(ui_language)
|
|
||||||
if language is None or language in AUTO_DETECTED:
|
Every supported code is passed as is; only an unset (or unknown) value
|
||||||
return None
|
leaves the SDK to detect zh/en.
|
||||||
return language
|
"""
|
||||||
|
return normalize_ui_language(ui_language)
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
"""Stable UI projections over the HY SDK's mode-specific storage shapes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any, Literal, cast
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from one_memory.hy_sdk_types import (
|
||||||
|
HySdkMemoryPage,
|
||||||
|
HySdkMemoryRecord,
|
||||||
|
HySdkRawPage,
|
||||||
|
HySdkRawRecord,
|
||||||
|
raw_turn_messages,
|
||||||
|
)
|
||||||
|
from one_memory.imports.sources.parsing import unwrap_host_envelope_with_positions
|
||||||
|
|
||||||
|
MemoryMode = Literal["lite", "pro", "ultra"]
|
||||||
|
TimeBasis = Literal["conversation", "import", "write", "update"]
|
||||||
|
ProjectionDisplayState = Literal["empty", "excerpt", "no_user_excerpt", "no_displayable_memory"]
|
||||||
|
RAW_STATUSES = ("active", "shadow")
|
||||||
|
HOME_PROJECTION_CANDIDATES = 10
|
||||||
|
HOME_EXCERPT_CODEPOINTS = 200
|
||||||
|
|
||||||
|
_SPACE = re.compile(r"\s+")
|
||||||
|
_CONTROL_ONLY = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"<<?\s*(?:image|attachment|system[_-]?reminder|no[_ -]?content)\s*>>?"
|
||||||
|
r"|\[\s*(?:image|attachment|system[_ -]?reminder|no[_ -]?content)\s*\]"
|
||||||
|
r")$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HomeProjectionCapabilities(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||||
|
|
||||||
|
content_plane: Literal["raw", "memory"]
|
||||||
|
generation_required: bool
|
||||||
|
graph_enabled: bool
|
||||||
|
evolve_available: bool
|
||||||
|
|
||||||
|
|
||||||
|
class _HomeLatest(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||||
|
|
||||||
|
time: datetime | None
|
||||||
|
time_basis: TimeBasis | None
|
||||||
|
excerpt: str
|
||||||
|
truncated: bool
|
||||||
|
source_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class HomeRawLatest(_HomeLatest):
|
||||||
|
type: Literal["raw"] = "raw"
|
||||||
|
raw_id: str
|
||||||
|
session_id: str
|
||||||
|
display_state: Literal["excerpt", "no_user_excerpt"]
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMemoryLatest(_HomeLatest):
|
||||||
|
type: Literal["memory"] = "memory"
|
||||||
|
memory_id: str
|
||||||
|
scene: Literal["chat", "working", "coding"]
|
||||||
|
# scene=all lists chat rows and productivity experiences; facts are not
|
||||||
|
# part of that page or its total.
|
||||||
|
kind: Literal["chat", "experience"]
|
||||||
|
variant: Literal["working", "coding"] | None
|
||||||
|
|
||||||
|
|
||||||
|
class HomeProjectionV1(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||||
|
|
||||||
|
projection_version: Literal[1] = 1
|
||||||
|
user_id: str
|
||||||
|
effective_mode: MemoryMode
|
||||||
|
capabilities: HomeProjectionCapabilities
|
||||||
|
visible_count: int = Field(ge=0)
|
||||||
|
display_state: ProjectionDisplayState
|
||||||
|
latest: HomeRawLatest | HomeMemoryLatest | None
|
||||||
|
observed_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def raw_list_filters(statuses: list[str] | tuple[str, ...] | None = None) -> dict[str, list[str]]:
|
||||||
|
"""The one kept-raw definition shared by `/raw` and the home projection."""
|
||||||
|
return {
|
||||||
|
"layers": ["l1_raw"],
|
||||||
|
"statuses": list(statuses or RAW_STATUSES),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def home_capabilities(mode: MemoryMode) -> HomeProjectionCapabilities:
|
||||||
|
return HomeProjectionCapabilities(
|
||||||
|
content_plane="raw" if mode == "lite" else "memory",
|
||||||
|
generation_required=mode != "lite",
|
||||||
|
graph_enabled=mode == "ultra",
|
||||||
|
evolve_available=mode == "ultra",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def raw_user_excerpt(content: str, *, limit: int = HOME_EXCERPT_CODEPOINTS) -> tuple[str, bool]:
|
||||||
|
"""Return only displayable user text from one SDK raw turn.
|
||||||
|
|
||||||
|
Role selection happens before wrapper removal, so assistant/system content
|
||||||
|
can never become a homepage excerpt after sanitization.
|
||||||
|
"""
|
||||||
|
candidates: list[str] = []
|
||||||
|
for role, text in raw_turn_messages(content):
|
||||||
|
if role != "user":
|
||||||
|
continue
|
||||||
|
envelope = unwrap_host_envelope_with_positions(text)
|
||||||
|
if envelope.status == "ambiguous":
|
||||||
|
# An uncertain host envelope may contain system context. Showing
|
||||||
|
# nothing is safer than guessing which bytes the person authored.
|
||||||
|
continue
|
||||||
|
cleaned = envelope.text or ""
|
||||||
|
cleaned = _SPACE.sub(" ", cleaned).strip()
|
||||||
|
if not cleaned or _CONTROL_ONLY.fullmatch(cleaned):
|
||||||
|
continue
|
||||||
|
candidates.append(cleaned)
|
||||||
|
joined = " ".join(candidates)
|
||||||
|
return joined[:limit], len(joined) > limit
|
||||||
|
|
||||||
|
|
||||||
|
def _epoch(value: int | None) -> datetime | None:
|
||||||
|
return datetime.fromtimestamp(value, tz=UTC) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_time(record: HySdkRawRecord) -> tuple[datetime | None, TimeBasis | None]:
|
||||||
|
if record.memory_at is not None:
|
||||||
|
return _epoch(record.memory_at), "conversation"
|
||||||
|
if record.gmt_created is not None:
|
||||||
|
return _epoch(record.gmt_created), "import"
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _memory_time(record: HySdkMemoryRecord) -> tuple[datetime | None, TimeBasis | None]:
|
||||||
|
# Exactly the scene=all merge key. `memory_at` is event time and does not
|
||||||
|
# decide which row list_memories returns first.
|
||||||
|
if record.sort_updated_at is not None:
|
||||||
|
return _epoch(record.sort_updated_at), "update"
|
||||||
|
written = record.gmt_created if record.scene == "chat" else record.created_at
|
||||||
|
if written is not None:
|
||||||
|
return _epoch(written), "write"
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded(text: str) -> tuple[str, bool]:
|
||||||
|
flattened = _SPACE.sub(" ", text).strip()
|
||||||
|
return flattened[:HOME_EXCERPT_CODEPOINTS], len(flattened) > HOME_EXCERPT_CODEPOINTS
|
||||||
|
|
||||||
|
|
||||||
|
def project_raw_latest(records: tuple[HySdkRawRecord, ...]) -> HomeRawLatest | None:
|
||||||
|
if not records:
|
||||||
|
return None
|
||||||
|
for record in records:
|
||||||
|
excerpt, truncated = raw_user_excerpt(record.content)
|
||||||
|
if excerpt:
|
||||||
|
at, basis = _raw_time(record)
|
||||||
|
return HomeRawLatest(
|
||||||
|
raw_id=record.raw_id,
|
||||||
|
session_id=record.session_id,
|
||||||
|
display_state="excerpt",
|
||||||
|
time=at,
|
||||||
|
time_basis=basis,
|
||||||
|
excerpt=excerpt,
|
||||||
|
truncated=truncated,
|
||||||
|
source_id=record.source,
|
||||||
|
)
|
||||||
|
newest = records[0]
|
||||||
|
at, basis = _raw_time(newest)
|
||||||
|
return HomeRawLatest(
|
||||||
|
raw_id=newest.raw_id,
|
||||||
|
session_id=newest.session_id,
|
||||||
|
display_state="no_user_excerpt",
|
||||||
|
time=at,
|
||||||
|
time_basis=basis,
|
||||||
|
excerpt="",
|
||||||
|
truncated=False,
|
||||||
|
source_id=newest.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def project_memory_latest(
|
||||||
|
records: tuple[HySdkMemoryRecord, ...],
|
||||||
|
) -> HomeMemoryLatest | None:
|
||||||
|
record = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in records
|
||||||
|
if item.kind in {"chat", "experience"}
|
||||||
|
and (
|
||||||
|
(item.kind == "chat" and item.scene == "chat" and item.variant is None)
|
||||||
|
or (
|
||||||
|
item.kind == "experience"
|
||||||
|
and item.scene in {"working", "coding"}
|
||||||
|
and item.variant == item.scene
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
excerpt, truncated = _bounded(record.content)
|
||||||
|
at, basis = _memory_time(record)
|
||||||
|
return HomeMemoryLatest(
|
||||||
|
memory_id=record.memory_id,
|
||||||
|
scene=record.scene,
|
||||||
|
kind=cast(Literal["chat", "experience"], record.kind),
|
||||||
|
variant=record.variant,
|
||||||
|
time=at,
|
||||||
|
time_basis=basis,
|
||||||
|
excerpt=excerpt,
|
||||||
|
truncated=truncated,
|
||||||
|
source_id=record.source or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def read_home_projection(
|
||||||
|
client: Any,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
mode: MemoryMode,
|
||||||
|
) -> HomeProjectionV1:
|
||||||
|
"""Read one authoritative projection; any SDK/shape failure escapes."""
|
||||||
|
client_mode = getattr(client, "mode", mode)
|
||||||
|
if client_mode != mode:
|
||||||
|
raise RuntimeError("home_projection_mode_mismatch")
|
||||||
|
if mode == "lite":
|
||||||
|
raw = await asyncio.to_thread(
|
||||||
|
client.list_memories,
|
||||||
|
user_id=user_id,
|
||||||
|
**raw_list_filters(),
|
||||||
|
limit=HOME_PROJECTION_CANDIDATES,
|
||||||
|
offset=0,
|
||||||
|
order="desc",
|
||||||
|
)
|
||||||
|
raw_page = HySdkRawPage.from_memory_list(raw, limit=HOME_PROJECTION_CANDIDATES, offset=0)
|
||||||
|
visible_count = raw_page.total
|
||||||
|
raw_latest = project_raw_latest(raw_page.raw)
|
||||||
|
latest: HomeRawLatest | HomeMemoryLatest | None = raw_latest
|
||||||
|
display_state: ProjectionDisplayState = (
|
||||||
|
"empty"
|
||||||
|
if visible_count == 0
|
||||||
|
else raw_latest.display_state
|
||||||
|
if raw_latest is not None
|
||||||
|
else "no_user_excerpt"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raw = await asyncio.to_thread(
|
||||||
|
client.list_memories,
|
||||||
|
user_id=user_id,
|
||||||
|
scene="all",
|
||||||
|
statuses=["active"],
|
||||||
|
limit=HOME_PROJECTION_CANDIDATES,
|
||||||
|
offset=0,
|
||||||
|
order="desc",
|
||||||
|
)
|
||||||
|
memory_page, _skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
|
||||||
|
raw, limit=HOME_PROJECTION_CANDIDATES, offset=0
|
||||||
|
)
|
||||||
|
visible_count = memory_page.total
|
||||||
|
latest = project_memory_latest(memory_page.memories)
|
||||||
|
display_state = (
|
||||||
|
"empty"
|
||||||
|
if visible_count == 0
|
||||||
|
else "excerpt"
|
||||||
|
if latest is not None
|
||||||
|
else "no_displayable_memory"
|
||||||
|
)
|
||||||
|
return HomeProjectionV1(
|
||||||
|
user_id=user_id,
|
||||||
|
effective_mode=mode,
|
||||||
|
capabilities=home_capabilities(mode),
|
||||||
|
visible_count=visible_count,
|
||||||
|
display_state=display_state,
|
||||||
|
latest=latest,
|
||||||
|
observed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
| `test_c1_cross_source_session_isolation.py` | `contract` | Same session name in two sources must stay two conversations. |
|
| `test_c1_cross_source_session_isolation.py` | `contract` | Same session name in two sources must stay two conversations. |
|
||||||
| `test_c1_damage_per_source.py` | `contract` | Damage and unknown schema, per source: a bad record must not sink the import. |
|
| `test_c1_damage_per_source.py` | `contract` | Damage and unknown schema, per source: a bad record must not sink the import. |
|
||||||
| `test_c1_deepseek_profiles.py` | `contract` | DeepSeek Harness has two profiles; only the headless session form is importable. |
|
| `test_c1_deepseek_profiles.py` | `contract` | DeepSeek Harness has two profiles; only the headless session form is importable. |
|
||||||
|
| `test_c1_deepseek_versioned_sessions.py` | `contract` | DeepSeek Harness versions its session file names; every generation must import. |
|
||||||
| `test_c1_import_event_matrix.py` | `contract` | C1 owned matrix: existing history, new file, append, truncate/replace, restart, revoke. |
|
| `test_c1_import_event_matrix.py` | `contract` | C1 owned matrix: existing history, new file, append, truncate/replace, restart, revoke. |
|
||||||
| `test_c1_import_failure_injection.py` | `contract` | C1 failure injection: atomic receipt, disk-full, read-only source, rotation. |
|
| `test_c1_import_failure_injection.py` | `contract` | C1 failure injection: atomic receipt, disk-full, read-only source, rotation. |
|
||||||
| `test_c1_import_instrumentation.py` | `contract` | Instrumented import accounting: bytes read, opens, hashes, jobs, duplicates. |
|
| `test_c1_import_instrumentation.py` | `contract` | Instrumented import accounting: bytes read, opens, hashes, jobs, duplicates. |
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
| `test_health.py` | `contract` | 覆盖 a backfill that fails says so rather than disappearing、a failed catch up leaves a traceback behind、configured model without content consent does not start backfill 等 15 个场景。 |
|
| `test_health.py` | `contract` | 覆盖 a backfill that fails says so rather than disappearing、a failed catch up leaves a traceback behind、configured model without content consent does not start backfill 等 15 个场景。 |
|
||||||
| `test_hermes_gateway_injection.py` | `contract` | Hermes gateway text in `user` rows: notices are skipped, relayed messages unwrapped. |
|
| `test_hermes_gateway_injection.py` | `contract` | Hermes gateway text in `user` rows: notices are skipped, relayed messages unwrapped. |
|
||||||
| `test_history_listing_rotation.py` | `contract` | 覆盖 history listing alternates started and untouched files、history listing drains whichever cohort is longer 场景。 |
|
| `test_history_listing_rotation.py` | `contract` | 覆盖 history listing alternates started and untouched files、history listing drains whichever cohort is longer 场景。 |
|
||||||
|
| `test_home_projection.py` | `contract` | 覆盖 memory projection covers pro and ultra、memory projection preserves productivity namespace、project raw latest uses import time without conversation time 等 7 个场景。 |
|
||||||
| `test_host_capabilities.py` | `unit` | A plugin does not become realtime on every host or every unknown version. |
|
| `test_host_capabilities.py` | `unit` | A plugin does not become realtime on every host or every unknown version. |
|
||||||
| `test_hourly_c1_peak_replay_contract.py` | `contract` | Guard the package peak-replay probe's error accounting. |
|
| `test_hourly_c1_peak_replay_contract.py` | `contract` | Guard the package peak-replay probe's error accounting. |
|
||||||
| `test_hourly_c1_recall_stage_probe_contract.py` | `contract` | Guard the packaged recall stage probe's command and error semantics. |
|
| `test_hourly_c1_recall_stage_probe_contract.py` | `contract` | Guard the packaged recall stage probe's command and error semantics. |
|
||||||
@@ -121,6 +123,7 @@
|
|||||||
| `test_import_api.py` | `integration` | 覆盖 resume during cleanup returns conflict without internal error、authorize detect import and revoke api flow、protected api rejects missing token and foreign origin 等 5 个场景。 |
|
| `test_import_api.py` | `integration` | 覆盖 resume during cleanup returns conflict without internal error、authorize detect import and revoke api flow、protected api rejects missing token and foreign origin 等 5 个场景。 |
|
||||||
| `test_import_control_races_review.py` | `contract` | Read-only review repro: real runner/store, synthetic delayed adapter cleanup. |
|
| `test_import_control_races_review.py` | `contract` | Read-only review repro: real runner/store, synthetic delayed adapter cleanup. |
|
||||||
| `test_import_evidence_atomicity.py` | `contract` | 覆盖 counter failure rolls back evidence versions and cursor、same identity cursor rewind is blocked and logged、replaced identity may reset checkpoint 等 20 个场景。 |
|
| `test_import_evidence_atomicity.py` | `contract` | 覆盖 counter failure rolls back evidence versions and cursor、same identity cursor rewind is blocked and logged、replaced identity may reset checkpoint 等 20 个场景。 |
|
||||||
|
| `test_import_job_retention.py` | `integration` | `import_jobs` stays bounded, and the questions asked of it are SQL, not scans. |
|
||||||
| `test_import_oversized_verbatim.py` | `contract` | 覆盖 normal verbatim is unchanged、oversized verbatim is truncated instead of rejected 场景。 |
|
| `test_import_oversized_verbatim.py` | `contract` | 覆盖 normal verbatim is unchanged、oversized verbatim is truncated instead of rejected 场景。 |
|
||||||
| `test_import_runner.py` | `contract` | 覆盖 job transitions are explicit、runner keeps per record errors and continues、sync slices reuse the same source listing 等 22 个场景。 |
|
| `test_import_runner.py` | `contract` | 覆盖 job transitions are explicit、runner keeps per record errors and continues、sync slices reuse the same source listing 等 22 个场景。 |
|
||||||
| `test_import_skip_batching.py` | `contract` | 覆盖 batch limit and tail use fewer commits、batched skips keep their order against the next write、flushes before every nonmatching event 等 14 个场景。 |
|
| `test_import_skip_batching.py` | `contract` | 覆盖 batch limit and tail use fewer commits、batched skips keep their order against the next write、flushes before every nonmatching event 等 14 个场景。 |
|
||||||
@@ -189,6 +192,7 @@
|
|||||||
| `test_memory_write_registry.py` | `contract` | A3-01: acceptance and outcome recording for the unified write path. |
|
| `test_memory_write_registry.py` | `contract` | A3-01: acceptance and outcome recording for the unified write path. |
|
||||||
| `test_memory_write_routes.py` | `integration` | A3-01: the HTTP write entry, tested against a real FastAPI app. |
|
| `test_memory_write_routes.py` | `integration` | A3-01: the HTTP write entry, tested against a real FastAPI app. |
|
||||||
| `test_migrate_off_sqlite.py` | `contract` | A1-06: the migration is only done when reconciliation agrees. |
|
| `test_migrate_off_sqlite.py` | `contract` | A1-06: the migration is only done when reconciliation agrees. |
|
||||||
|
| `test_model_backoff.py` | `contract` | A broken model configuration must not turn a backlog into a request storm (#245). |
|
||||||
| `test_model_creation_api.py` | `integration` | Normal protected model creation routes with temporary data and in-memory secrets. |
|
| `test_model_creation_api.py` | `integration` | Normal protected model creation routes with temporary data and in-memory secrets. |
|
||||||
| `test_model_creation_operations.py` | `contract` | 覆盖 lookup without table is read only and legacy create does not initialize、operation commits with profile and version without autoactivation、any replay conflicts before secret put 等 21 个场景。 |
|
| `test_model_creation_operations.py` | `contract` | 覆盖 lookup without table is read only and legacy create does not initialize、operation commits with profile and version without autoactivation、any replay conflicts before secret put 等 21 个场景。 |
|
||||||
| `test_model_creation_validation_boundary.py` | `integration` | Real ASGI validation must precede creation reservation and secret writes. |
|
| `test_model_creation_validation_boundary.py` | `integration` | Real ASGI validation must precede creation reservation and secret writes. |
|
||||||
@@ -210,6 +214,7 @@
|
|||||||
| `test_onboarding_profile.py` | `integration` | R-09:首次安装引导把画像录下来,不问用户一句话。 |
|
| `test_onboarding_profile.py` | `integration` | R-09:首次安装引导把画像录下来,不问用户一句话。 |
|
||||||
| `test_oversized_file_guard.py` | `contract` | One pass over a source file is bounded by a byte budget, not skipped. |
|
| `test_oversized_file_guard.py` | `contract` | One pass over a source file is bounded by a byte budget, not skipped. |
|
||||||
| `test_parent_watchdog.py` | `integration` | A Core that outlives its launcher makes the next launch impossible. |
|
| `test_parent_watchdog.py` | `integration` | A Core that outlives its launcher makes the next launch impossible. |
|
||||||
|
| `test_pinned_sdk_raw_search.py` | `integration` | MemHub's raw search against the hy-memory wheel it pins, not the repo source. |
|
||||||
| `test_policy_api_wiring.py` | `integration` | 覆盖 global model permission off does not backfill、backfill puts history on write jobs without scheduling memory tasks、source pause skips history until the source is reenabled 场景。 |
|
| `test_policy_api_wiring.py` | `integration` | 覆盖 global model permission off does not backfill、backfill puts history on write jobs without scheduling memory tasks、source pause skips history until the source is reenabled 场景。 |
|
||||||
| `test_policy_production_entry.py` | `contract` | 覆盖 production serve passes only supported app options、maintenance serve does not own the runtime 场景。 |
|
| `test_policy_production_entry.py` | `contract` | 覆盖 production serve passes only supported app options、maintenance serve does not own the runtime 场景。 |
|
||||||
| `test_precipitation_concurrency_setting.py` | `contract` | Rule 2/9: precipitation concurrency is configurable, not hardcoded to 1. |
|
| `test_precipitation_concurrency_setting.py` | `contract` | Rule 2/9: precipitation concurrency is configurable, not hardcoded to 1. |
|
||||||
@@ -318,4 +323,4 @@
|
|||||||
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
|
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
|
||||||
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
|
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
|
||||||
|
|
||||||
共 313 个测试文件。
|
共 318 个测试文件。
|
||||||
@@ -14,8 +14,8 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
||||||
EXPECTED_TEST_FILE_COUNT = 313
|
EXPECTED_TEST_FILE_COUNT = 318
|
||||||
EXPECTED_TEST_FILES_SHA256 = "dcada5d5421767196a79e415dd47c2b6c6d67681972dbe81481b31867d318928"
|
EXPECTED_TEST_FILES_SHA256 = "df8752566a0bf9e540f68b4948f3f5dbc409bd6836707b1330206e2349d70940"
|
||||||
|
|
||||||
# Pure transformations, validation models, parity checks and state machines.
|
# Pure transformations, validation models, parity checks and state machines.
|
||||||
# This list is intentionally small: uncertain files default to contract.
|
# This list is intentionally small: uncertain files default to contract.
|
||||||
@@ -70,6 +70,7 @@ INTEGRATION_FILES = frozenset(
|
|||||||
"test_file_import_operations.py",
|
"test_file_import_operations.py",
|
||||||
"test_graph_width_endpoint.py",
|
"test_graph_width_endpoint.py",
|
||||||
"test_import_api.py",
|
"test_import_api.py",
|
||||||
|
"test_import_job_retention.py",
|
||||||
"test_live_capture.py",
|
"test_live_capture.py",
|
||||||
"test_live_watch_grants.py",
|
"test_live_watch_grants.py",
|
||||||
"test_log_privacy.py",
|
"test_log_privacy.py",
|
||||||
@@ -93,6 +94,7 @@ INTEGRATION_FILES = frozenset(
|
|||||||
"test_model_profile_context_reset.py",
|
"test_model_profile_context_reset.py",
|
||||||
"test_onboarding_profile.py",
|
"test_onboarding_profile.py",
|
||||||
"test_parent_watchdog.py",
|
"test_parent_watchdog.py",
|
||||||
|
"test_pinned_sdk_raw_search.py",
|
||||||
"test_policy_api_wiring.py",
|
"test_policy_api_wiring.py",
|
||||||
"test_recall_scope_control.py",
|
"test_recall_scope_control.py",
|
||||||
"test_retrieval_lifespan.py",
|
"test_retrieval_lifespan.py",
|
||||||
|
|||||||
@@ -324,3 +324,48 @@ def test_saturated_raw_reads_do_not_delay_memory_reads() -> None:
|
|||||||
finally:
|
finally:
|
||||||
release_raw.set()
|
release_raw.set()
|
||||||
shutdown_bulk_pool()
|
shutdown_bulk_pool()
|
||||||
|
|
||||||
|
|
||||||
|
def test_recall_reads_answer_while_every_sdk_write_thread_is_stuck() -> None:
|
||||||
|
"""A write that hangs in the SDK keeps its thread, not recall's (#231)."""
|
||||||
|
from one_memory.background_work import SDK_WRITE_WORKERS, in_sdk_read, in_sdk_write
|
||||||
|
|
||||||
|
stuck = threading.Barrier(SDK_WRITE_WORKERS + 1)
|
||||||
|
release = threading.Event()
|
||||||
|
|
||||||
|
def hung_write() -> None:
|
||||||
|
stuck.wait(timeout=2)
|
||||||
|
assert release.wait(5)
|
||||||
|
|
||||||
|
async def scenario() -> tuple[float, int]:
|
||||||
|
writes = [asyncio.create_task(in_sdk_write(hung_write)) for _ in range(SDK_WRITE_WORKERS)]
|
||||||
|
# One more than the pool holds: it waits for a write thread, as the
|
||||||
|
# drain's next write would.
|
||||||
|
queued = asyncio.create_task(in_sdk_write(lambda: None))
|
||||||
|
await asyncio.to_thread(stuck.wait, 2)
|
||||||
|
started = time.monotonic()
|
||||||
|
answer = await asyncio.wait_for(in_sdk_read(lambda: 7), 1)
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
assert not queued.done()
|
||||||
|
release.set()
|
||||||
|
await asyncio.gather(*writes, queued)
|
||||||
|
return elapsed, answer
|
||||||
|
|
||||||
|
try:
|
||||||
|
elapsed, answer = asyncio.run(scenario())
|
||||||
|
assert answer == 7
|
||||||
|
assert elapsed < 0.2
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
shutdown_bulk_pool()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sdk_writes_run_on_their_own_bounded_threads() -> None:
|
||||||
|
from one_memory.background_work import SDK_WRITE_WORKERS, in_sdk_write, sdk_write_pool
|
||||||
|
|
||||||
|
try:
|
||||||
|
name = asyncio.run(in_sdk_write(lambda: threading.current_thread().name))
|
||||||
|
assert name.startswith("one-memory-sdk-write")
|
||||||
|
assert sdk_write_pool()._max_workers == SDK_WRITE_WORKERS
|
||||||
|
finally:
|
||||||
|
shutdown_bulk_pool()
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""DeepSeek Harness versions its session file names; every generation must import.
|
||||||
|
|
||||||
|
The first format writes `<session>/session.jsonl[.zstd]`; v3 and v4 write
|
||||||
|
`session.v3.jsonl.zstd` / `session.v4.jsonl.zstd`. Discovery used to match only
|
||||||
|
the two unversioned names, so every v3/v4 session was invisible to the import
|
||||||
|
job — `discovered 0` while the files sat on disk. These tests pin the file-name
|
||||||
|
contract, the header gate that keeps an unknown future version out of the
|
||||||
|
database, and the upgrade path where a library imported before the fix picks up
|
||||||
|
the versioned files on its next scan.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import zstandard
|
||||||
|
|
||||||
|
from one_memory.imports.adapters import ScanFailure, ScanIssueCode
|
||||||
|
from one_memory.imports.sources import deepseek_harness
|
||||||
|
from one_memory.imports.sources.deepseek_harness import (
|
||||||
|
DEEPSEEK_HARNESS_DESCRIPTOR,
|
||||||
|
create_deepseek_harness_adapter,
|
||||||
|
)
|
||||||
|
from tests.test_secondary_source_adapters import (
|
||||||
|
_cursors,
|
||||||
|
_evidence,
|
||||||
|
_evidence_text,
|
||||||
|
_grant,
|
||||||
|
_scan,
|
||||||
|
_skip_codes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_session(directory: Path, name: str, records: list[dict[str, object]]) -> None:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = "".join(f"{json.dumps(record)}\n" for record in records).encode()
|
||||||
|
(directory / name).write_bytes(zstandard.ZstdCompressor().compress(payload))
|
||||||
|
|
||||||
|
|
||||||
|
def _conversation(version: int | None, *, directory: str = "run-a") -> list[dict[str, object]]:
|
||||||
|
header: dict[str, object] = {
|
||||||
|
"type": "session",
|
||||||
|
"version": version,
|
||||||
|
"id": directory,
|
||||||
|
"cwd": "/private/versioned-project",
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
header,
|
||||||
|
{
|
||||||
|
"type": "user/message",
|
||||||
|
"seq": 1,
|
||||||
|
"data": {
|
||||||
|
"id": "human",
|
||||||
|
"role": "user",
|
||||||
|
"source": {"kind": "user"},
|
||||||
|
"content": [{"type": "text", "text": "visible human"}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "assistant/message",
|
||||||
|
"seq": 2,
|
||||||
|
"data": {
|
||||||
|
"message": {
|
||||||
|
"id": "assistant",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "text", "text": "visible assistant"}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _session_adapter(tmp_path: Path):
|
||||||
|
root = tmp_path / "dsh"
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
return root, create_deepseek_harness_adapter(
|
||||||
|
_grant(DEEPSEEK_HARNESS_DESCRIPTOR.source_id, root, tmp_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_versioned_v4_session_file_is_discovered_and_imported(tmp_path: Path) -> None:
|
||||||
|
root, adapter = _session_adapter(tmp_path)
|
||||||
|
session = root / "run-v4"
|
||||||
|
records = _conversation(4, directory="run-v4")
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"type": "tool/result",
|
||||||
|
"seq": 3,
|
||||||
|
"time": 1793341068763,
|
||||||
|
"data": {
|
||||||
|
"callId": "call-1",
|
||||||
|
"message": {"role": "tool", "content": [{"type": "text", "text": "tool output"}]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_write_session(session, "session.v4.jsonl.zstd", records)
|
||||||
|
# A rotated backup next to the live file must not be imported as a second copy.
|
||||||
|
(session / "session.v4.jsonl.zstd.orig-20261027").write_bytes(
|
||||||
|
zstandard.ZstdCompressor().compress(b'{"type":"session","version":4}\n')
|
||||||
|
)
|
||||||
|
|
||||||
|
events = _scan(adapter)
|
||||||
|
|
||||||
|
assert _evidence_text(events) == ["visible human", "visible assistant", "tool output"]
|
||||||
|
evidence = _evidence(events)
|
||||||
|
assert {event.evidence.project for event in evidence} == {"versioned-project"}
|
||||||
|
assert {event.evidence.conversation_id for event in evidence} == {"run-v4"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_versioned_v3_session_file_is_discovered_and_imported(tmp_path: Path) -> None:
|
||||||
|
root, adapter = _session_adapter(tmp_path)
|
||||||
|
_write_session(root / "run-v3", "session.v3.jsonl.zstd", _conversation(3))
|
||||||
|
|
||||||
|
events = _scan(adapter)
|
||||||
|
|
||||||
|
assert _evidence_text(events) == ["visible human", "visible assistant"]
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_result(content: list[dict[str, object]]) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"type": "tool/result",
|
||||||
|
"seq": 3,
|
||||||
|
"data": {"callId": "call-1", "message": {"role": "tool", "content": content}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_output_imports_in_either_block_shape(tmp_path: Path) -> None:
|
||||||
|
"""`tool/result` output written as `text` blocks used to be dropped.
|
||||||
|
|
||||||
|
Only `tool-result` blocks were read, so a session that writes plain `text`
|
||||||
|
blocks lost every tool output. Both shapes must arrive, in any generation.
|
||||||
|
"""
|
||||||
|
root, adapter = _session_adapter(tmp_path)
|
||||||
|
v3 = _conversation(3, directory="run-v3-text")
|
||||||
|
v3.append(_tool_result([{"type": "text", "text": "v3 text output"}]))
|
||||||
|
_write_session(root / "run-v3-text", "session.v3.jsonl.zstd", v3)
|
||||||
|
v0 = _conversation(0, directory="run-v0-block")
|
||||||
|
v0.append(_tool_result([{"type": "tool-result", "content": "v0 block output"}]))
|
||||||
|
_write_session(root / "run-v0-block", "session.jsonl.zstd", v0)
|
||||||
|
|
||||||
|
texts = _evidence_text(_scan(adapter))
|
||||||
|
|
||||||
|
assert "v3 text output" in texts
|
||||||
|
assert "v0 block output" in texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_tool_output_is_scrubbed_like_block_output(tmp_path: Path) -> None:
|
||||||
|
root, adapter = _session_adapter(tmp_path)
|
||||||
|
secret = "sk-" + "a" * 40
|
||||||
|
records = _conversation(4, directory="run-secret")
|
||||||
|
records.append(_tool_result([{"type": "text", "text": f"token {secret} " + "x" * 5000}]))
|
||||||
|
_write_session(root / "run-secret", "session.v4.jsonl.zstd", records)
|
||||||
|
|
||||||
|
tool = _evidence_text(_scan(adapter))[-1]
|
||||||
|
|
||||||
|
assert secret not in tool
|
||||||
|
assert len(tool) < 2000
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_file_names_outside_the_contract_are_not_imported(tmp_path: Path) -> None:
|
||||||
|
root, adapter = _session_adapter(tmp_path)
|
||||||
|
for name in (
|
||||||
|
"session.v4.jsonl.zstd.orig-20261027",
|
||||||
|
"session.vx.jsonl.zstd",
|
||||||
|
"other.v4.jsonl.zstd",
|
||||||
|
"session.v4.json",
|
||||||
|
):
|
||||||
|
_write_session(root / name.replace(".", "-"), name, _conversation(4))
|
||||||
|
|
||||||
|
assert _evidence(_scan(adapter)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_future_session_version_imports_nothing(tmp_path: Path) -> None:
|
||||||
|
root, adapter = _session_adapter(tmp_path)
|
||||||
|
records = _conversation(5)
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"type": "user/message",
|
||||||
|
"seq": 9,
|
||||||
|
"data": {
|
||||||
|
"id": "future",
|
||||||
|
"role": "user",
|
||||||
|
"source": {"kind": "user"},
|
||||||
|
"content": [{"type": "text", "text": "future generation payload"}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_write_session(root / "run-v5", "session.v5.jsonl.zstd", records)
|
||||||
|
|
||||||
|
events = _scan(adapter)
|
||||||
|
|
||||||
|
assert _evidence(events) == []
|
||||||
|
assert ScanIssueCode.UNSUPPORTED_SCHEMA in _skip_codes(events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_session_version_is_reported_and_read_once_supported(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Not importing a future format yet must stay "yet".
|
||||||
|
|
||||||
|
A cursor left behind by the refused scan would put the whole session behind
|
||||||
|
the checkpoint, so the release that learns the format would read nothing.
|
||||||
|
"""
|
||||||
|
root, adapter = _session_adapter(tmp_path)
|
||||||
|
_write_session(root / "run-v5", "session.v5.jsonl.zstd", _conversation(5))
|
||||||
|
|
||||||
|
refused = _scan(adapter)
|
||||||
|
|
||||||
|
assert _cursors(refused) == {}
|
||||||
|
assert [event.reason_code for event in refused if isinstance(event, ScanFailure)] == [
|
||||||
|
ScanIssueCode.UNSUPPORTED_SCHEMA
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(deepseek_harness, "SUPPORTED_SESSION_VERSIONS", frozenset({0, 3, 4, 5}))
|
||||||
|
_, upgraded = _session_adapter(tmp_path)
|
||||||
|
|
||||||
|
assert _evidence_text(_scan(upgraded, cursors=_cursors(refused))) == [
|
||||||
|
"visible human",
|
||||||
|
"visible assistant",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_scan_rediscovers_versioned_files_already_on_disk(tmp_path: Path) -> None:
|
||||||
|
"""A library imported before this fix must pick up the files on its next scan.
|
||||||
|
|
||||||
|
The first scan sees only the unversioned session that the old predicate
|
||||||
|
matched; after the upgrade, the versioned file next to it is discovered by
|
||||||
|
the next scan while the already imported session stays deduplicated by the
|
||||||
|
cursor it left behind.
|
||||||
|
"""
|
||||||
|
root, adapter = _session_adapter(tmp_path)
|
||||||
|
old = root / "run-old"
|
||||||
|
_write_session(old, "session.jsonl.zstd", _conversation(0, directory="run-old"))
|
||||||
|
first_scan = _scan(adapter)
|
||||||
|
assert _evidence_text(first_scan) == ["visible human", "visible assistant"]
|
||||||
|
assert _evidence(first_scan)[0].evidence.conversation_id == "run-old"
|
||||||
|
|
||||||
|
versioned = root / "run-v4"
|
||||||
|
_write_session(versioned, "session.v4.jsonl.zstd", _conversation(4, directory="run-v4"))
|
||||||
|
cursors = _cursors(first_scan)
|
||||||
|
assert cursors # the old session left a cursor behind
|
||||||
|
|
||||||
|
upgrade_scan = _scan(adapter, cursors=cursors)
|
||||||
|
|
||||||
|
assert _evidence_text(upgrade_scan) == ["visible human", "visible assistant"]
|
||||||
|
assert {event.evidence.conversation_id for event in _evidence(upgrade_scan)} == {"run-v4"}
|
||||||
@@ -263,6 +263,48 @@ def test_a_switch_over_the_api_pauses_writes_and_keeps_intake(tmp_path, backend)
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_backfill_blocks_starting_an_embedding_switch(tmp_path, backend):
|
||||||
|
backend.store.put(
|
||||||
|
builtin_base(),
|
||||||
|
512,
|
||||||
|
{"m1": ("memory", "local")},
|
||||||
|
model="bge-small-zh-v1.5",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
app = application(tmp_path)
|
||||||
|
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||||
|
await recovered(client)
|
||||||
|
profile = remote_profile(app)
|
||||||
|
plan = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/embedding-switches/plan",
|
||||||
|
json={"profile_id": profile.profile_id},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
hold = asyncio.Event()
|
||||||
|
task = asyncio.create_task(hold.wait())
|
||||||
|
app.state.memory_graph_backfill._task = task
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/embedding-switches",
|
||||||
|
json={
|
||||||
|
"profile_id": profile.profile_id,
|
||||||
|
"expected_revision": profile.revision,
|
||||||
|
"plan_token": plan["plan_token"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["detail"] == "memory_lifecycle_busy"
|
||||||
|
assert app.state.embedding_switch.writes_open() is True
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
app.state.memory_graph_backfill._task = None
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_over_the_api(tmp_path, backend):
|
def test_cancel_over_the_api(tmp_path, backend):
|
||||||
backend.store.put(builtin_base(), 512, {f"m{i}": (f"m {i}", "local") for i in range(4)})
|
backend.store.put(builtin_base(), 512, {f"m{i}": (f"m {i}", "local") for i in range(4)})
|
||||||
|
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ def _qwenwork() -> Harness:
|
|||||||
|
|
||||||
def _deepseek() -> Harness:
|
def _deepseek() -> Harness:
|
||||||
def write(path: Path, turns: list[Turn]) -> None:
|
def write(path: Path, turns: list[Turn]) -> None:
|
||||||
# deepseek only accepts a file literally named session.jsonl, and nests
|
# deepseek only accepts files named session[.vN].jsonl[.zstd], and nests
|
||||||
# the turn under "data".
|
# the turn under "data".
|
||||||
lines: list[dict] = [{"type": "session", "version": 0, "id": "s1", "cwd": "/w"}]
|
lines: list[dict] = [{"type": "session", "version": 0, "id": "s1", "cwd": "/w"}]
|
||||||
for index, (role, text, _session) in enumerate(turns):
|
for index, (role, text, _session) in enumerate(turns):
|
||||||
@@ -225,7 +225,7 @@ def _deepseek() -> Harness:
|
|||||||
|
|
||||||
from one_memory.imports.sources.deepseek_harness import create_deepseek_harness_adapter
|
from one_memory.imports.sources.deepseek_harness import create_deepseek_harness_adapter
|
||||||
|
|
||||||
# deepseek accepts only a file literally named session.jsonl.
|
# deepseek accepts only session[.vN].jsonl[.zstd]; the first format is used here.
|
||||||
return Harness(
|
return Harness(
|
||||||
"deepseek-harness",
|
"deepseek-harness",
|
||||||
write,
|
write,
|
||||||
|
|||||||
@@ -150,7 +150,18 @@ def test_lifespan_content_backfill_failure_is_visible(tmp_path, monkeypatch) ->
|
|||||||
|
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
async with app.router.lifespan_context(app):
|
async with app.router.lifespan_context(app):
|
||||||
health = await _get_json(app, "/api/v1/health")
|
# The rebuild is released when post-ready recovery finishes, and
|
||||||
|
# recovery now resumes imports off the loop, so it can still be
|
||||||
|
# running at the first request.
|
||||||
|
for _ in range(500):
|
||||||
|
if app.state.recovery in {"done", "failed"}:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
for _ in range(500):
|
||||||
|
health = await _get_json(app, "/api/v1/health")
|
||||||
|
if health["content_candidate_backfill"] != "running":
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
assert health["content_candidate_backfill"] == "failed"
|
assert health["content_candidate_backfill"] == "failed"
|
||||||
assert health["content_candidate_backfill_failure"] == "database_locked"
|
assert health["content_candidate_backfill_failure"] == "database_locked"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import SecretStr
|
||||||
|
|
||||||
|
from one_memory.api import create_app
|
||||||
|
from one_memory.config import Settings
|
||||||
|
from one_memory.hy_sdk_types import HySdkReadError
|
||||||
|
from one_memory.ui_projection import (
|
||||||
|
HOME_PROJECTION_CANDIDATES,
|
||||||
|
project_raw_latest,
|
||||||
|
raw_user_excerpt,
|
||||||
|
read_home_projection,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def raw_row(
|
||||||
|
raw_id: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
status: str = "active",
|
||||||
|
memory_at: int | None = 1_700_000_000,
|
||||||
|
created: int | None = 1_699_000_000,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"memory_id": raw_id,
|
||||||
|
"content": content,
|
||||||
|
"layer": "l1_raw",
|
||||||
|
"status": status,
|
||||||
|
"user_id": "local",
|
||||||
|
"agent_id": "all-apps",
|
||||||
|
"session_id": f"session-{raw_id}",
|
||||||
|
"source": "cursor",
|
||||||
|
"memory_at": memory_at,
|
||||||
|
"gmt_created": created,
|
||||||
|
"custom": {"private": "not projected"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def chat_row(memory_id: str = "m-1") -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"memory_id": memory_id,
|
||||||
|
"content": "The user ships on Friday",
|
||||||
|
"layer": "l2_fact",
|
||||||
|
"status": "active",
|
||||||
|
"is_latest": True,
|
||||||
|
"user_id": "local",
|
||||||
|
"agent_id": "all-apps",
|
||||||
|
"session_id": "s-1",
|
||||||
|
"source": "cursor",
|
||||||
|
"memory_at": 1_700_000_000,
|
||||||
|
"gmt_created": 1_699_000_000,
|
||||||
|
"updated_at": 1_800_000_000,
|
||||||
|
"scene": "chat",
|
||||||
|
"kind": "chat",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectionSdk:
|
||||||
|
def __init__(self, rows: list[dict[str, object]], *, total: int | None = None) -> None:
|
||||||
|
self.rows = rows
|
||||||
|
self.total = len(rows) if total is None else total
|
||||||
|
self.calls: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
def list_memories(self, **kwargs):
|
||||||
|
self.calls.append(kwargs)
|
||||||
|
if kwargs.get("layers") == ["l1_raw"]:
|
||||||
|
return {"vdb": {"memories": self.rows, "total": self.total}}
|
||||||
|
return {
|
||||||
|
"scene": "all",
|
||||||
|
"memories": self.rows,
|
||||||
|
"total": self.total,
|
||||||
|
"totals": {"chat": len(self.rows), "working": 0, "coding": 0},
|
||||||
|
"errors": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
def memory_stats(self, **kwargs):
|
||||||
|
raise AssertionError("home projection must use one scene=all snapshot")
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_excerpt_keeps_only_user_query_and_truncates_by_codepoint() -> None:
|
||||||
|
text = (
|
||||||
|
"[user]: <system_reminder>ignore this</system_reminder>\n"
|
||||||
|
"<user_info>private host data</user_info>\n"
|
||||||
|
"<user_query>短句 https://example.com/a /tmp/x " + "🙂" * 220 + "</user_query>\n"
|
||||||
|
"[assistant]: secret answer"
|
||||||
|
)
|
||||||
|
excerpt, truncated = raw_user_excerpt(text)
|
||||||
|
assert excerpt.startswith("短句 https://example.com/a /tmp/x ")
|
||||||
|
assert "ignore" not in excerpt and "private" not in excerpt and "secret" not in excerpt
|
||||||
|
assert len(excerpt) == 200
|
||||||
|
assert truncated is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_excerpt_fails_closed_on_nested_host_wrappers() -> None:
|
||||||
|
assert raw_user_excerpt(
|
||||||
|
"[user]: <system_reminder><user_query>keep me</user_query></system_reminder>"
|
||||||
|
) == ("", False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"wrapper",
|
||||||
|
[
|
||||||
|
"system_reminder",
|
||||||
|
"system-reminder",
|
||||||
|
"user_info",
|
||||||
|
"attached_files",
|
||||||
|
"image_local_path",
|
||||||
|
"agent_transcripts",
|
||||||
|
"rules",
|
||||||
|
"additional_data",
|
||||||
|
"working_memory_reminder",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_raw_excerpt_uses_shared_envelope_parser_for_host_context(wrapper) -> None:
|
||||||
|
excerpt, truncated = raw_user_excerpt(
|
||||||
|
f"[user]: <{wrapper}>private</{wrapper}><user_query>keep me</user_query>"
|
||||||
|
)
|
||||||
|
assert (excerpt, truncated) == ("keep me", False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text",
|
||||||
|
[
|
||||||
|
"[user]: <system_reminder>unclosed <user_query>do not guess</user_query>",
|
||||||
|
"[user]: <user_query>unclosed",
|
||||||
|
"[user]: <rules><rules>nested</rules><user_query>ambiguous</user_query></rules>",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_raw_excerpt_fails_closed_on_ambiguous_envelopes(text) -> None:
|
||||||
|
assert raw_user_excerpt(text) == ("", False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_projection_uses_shadow_rows_and_k10_without_leaking_payload() -> None:
|
||||||
|
sdk = ProjectionSdk(
|
||||||
|
[
|
||||||
|
raw_row("r-1", "[assistant]: no user here", status="shadow"),
|
||||||
|
raw_row("r-2", "[user]: deploy Friday", status="active"),
|
||||||
|
],
|
||||||
|
total=42,
|
||||||
|
)
|
||||||
|
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="lite"))
|
||||||
|
assert result.visible_count == 42
|
||||||
|
assert result.latest is not None and result.latest.type == "raw"
|
||||||
|
assert result.latest.raw_id == "r-2"
|
||||||
|
assert result.latest.excerpt == "deploy Friday"
|
||||||
|
assert result.latest.time_basis == "conversation"
|
||||||
|
assert result.display_state == "excerpt"
|
||||||
|
assert sdk.calls == [
|
||||||
|
{
|
||||||
|
"user_id": "local",
|
||||||
|
"layers": ["l1_raw"],
|
||||||
|
"statuses": ["active", "shadow"],
|
||||||
|
"limit": HOME_PROJECTION_CANDIDATES,
|
||||||
|
"offset": 0,
|
||||||
|
"order": "desc",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert set(result.latest.model_dump()) == {
|
||||||
|
"type",
|
||||||
|
"raw_id",
|
||||||
|
"session_id",
|
||||||
|
"display_state",
|
||||||
|
"time",
|
||||||
|
"time_basis",
|
||||||
|
"excerpt",
|
||||||
|
"truncated",
|
||||||
|
"source_id",
|
||||||
|
}
|
||||||
|
serialized = result.model_dump(mode="json")
|
||||||
|
assert serialized["projection_version"] == 1
|
||||||
|
assert serialized["latest"]["time"].endswith("Z")
|
||||||
|
assert "content" not in serialized["latest"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_projection_reports_no_user_excerpt_when_k_has_none() -> None:
|
||||||
|
sdk = ProjectionSdk([raw_row("r-1", "[assistant]: answer")])
|
||||||
|
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="lite"))
|
||||||
|
assert result.latest is not None and result.latest.type == "raw"
|
||||||
|
assert result.latest.raw_id == "r-1"
|
||||||
|
assert result.latest.display_state == "no_user_excerpt"
|
||||||
|
assert result.latest.excerpt == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mode", "graph", "evolve"),
|
||||||
|
[("pro", False, False), ("ultra", True, True)],
|
||||||
|
)
|
||||||
|
def test_memory_projection_covers_pro_and_ultra(mode, graph, evolve) -> None:
|
||||||
|
sdk = ProjectionSdk([chat_row()], total=9)
|
||||||
|
result = asyncio.run(read_home_projection(sdk, user_id="local", mode=mode))
|
||||||
|
assert result.visible_count == 9
|
||||||
|
assert result.display_state == "excerpt"
|
||||||
|
assert result.capabilities.content_plane == "memory"
|
||||||
|
assert result.capabilities.graph_enabled is graph
|
||||||
|
assert result.capabilities.evolve_available is evolve
|
||||||
|
assert result.latest is not None and result.latest.type == "memory"
|
||||||
|
assert result.latest.memory_id == "m-1"
|
||||||
|
assert result.latest.time_basis == "update"
|
||||||
|
assert result.latest.time.isoformat() == "2027-01-15T08:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_projection_prefers_gmt_modified_when_the_sdk_supplies_it() -> None:
|
||||||
|
row = {**chat_row(), "gmt_modified": 1_810_000_000}
|
||||||
|
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
|
||||||
|
assert result.latest is not None
|
||||||
|
assert result.latest.time_basis == "update"
|
||||||
|
assert result.latest.time.timestamp() == 1_810_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_projection_uses_productivity_updated_at() -> None:
|
||||||
|
row = {
|
||||||
|
"kind": "experience",
|
||||||
|
"experience_id": "e-1",
|
||||||
|
"task": "Release",
|
||||||
|
"solution": "Run tests",
|
||||||
|
"scene": "working",
|
||||||
|
"status": "active",
|
||||||
|
"user_id": "local",
|
||||||
|
"agent_id": "all-apps",
|
||||||
|
"source": "manual",
|
||||||
|
"memory_at": 1_600_000_000,
|
||||||
|
"created_at": 1_700_000_000,
|
||||||
|
"updated_at": 1_710_000_000,
|
||||||
|
}
|
||||||
|
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
|
||||||
|
assert result.latest is not None and result.latest.type == "memory"
|
||||||
|
assert (result.latest.scene, result.latest.kind, result.latest.variant) == (
|
||||||
|
"working",
|
||||||
|
"experience",
|
||||||
|
"working",
|
||||||
|
)
|
||||||
|
assert result.latest.time_basis == "update"
|
||||||
|
assert result.latest.time.isoformat() == "2024-03-09T16:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"row",
|
||||||
|
[
|
||||||
|
{**chat_row(), "updated_at": None},
|
||||||
|
{
|
||||||
|
"kind": "experience",
|
||||||
|
"experience_id": "e-1",
|
||||||
|
"task": "Release",
|
||||||
|
"solution": "Run tests",
|
||||||
|
"scene": "coding",
|
||||||
|
"status": "active",
|
||||||
|
"user_id": "local",
|
||||||
|
"agent_id": "all-apps",
|
||||||
|
"source": "manual",
|
||||||
|
"created_at": 1_700_000_000,
|
||||||
|
"updated_at": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_memory_projection_sort_time_falls_back_to_scene_creation(row) -> None:
|
||||||
|
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
|
||||||
|
assert result.latest is not None
|
||||||
|
assert result.latest.time_basis == "write"
|
||||||
|
expected = row.get("gmt_created") or row.get("created_at")
|
||||||
|
assert result.latest.time.timestamp() == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_fact_is_not_a_home_scene_all_item() -> None:
|
||||||
|
row = {
|
||||||
|
"kind": "fact",
|
||||||
|
"fact_id": "f-1",
|
||||||
|
"statement": "Uses pytest",
|
||||||
|
"scene": "coding",
|
||||||
|
"status": "active",
|
||||||
|
"user_id": "local",
|
||||||
|
"agent_id": "all-apps",
|
||||||
|
"source": "manual",
|
||||||
|
"created_at": 1_700_000_000,
|
||||||
|
}
|
||||||
|
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
|
||||||
|
assert result.visible_count == 1
|
||||||
|
assert result.latest is None
|
||||||
|
assert result.display_state == "no_displayable_memory"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_latest_uses_next_parseable_candidate_from_same_snapshot() -> None:
|
||||||
|
sdk = ProjectionSdk(
|
||||||
|
[
|
||||||
|
{"memory_id": "broken", "content": "", "scene": "chat", "kind": "chat"},
|
||||||
|
chat_row("m-2"),
|
||||||
|
],
|
||||||
|
total=2,
|
||||||
|
)
|
||||||
|
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
|
||||||
|
assert result.visible_count == 2
|
||||||
|
assert result.latest is not None and result.latest.memory_id == "m-2"
|
||||||
|
assert len(sdk.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_scene_all_rejects_boolean_total() -> None:
|
||||||
|
sdk = ProjectionSdk([chat_row()])
|
||||||
|
sdk.total = True # type: ignore[assignment]
|
||||||
|
with pytest.raises(HySdkReadError):
|
||||||
|
asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_projection_read_failure_is_not_converted_to_an_empty_projection() -> None:
|
||||||
|
class Broken:
|
||||||
|
def list_memories(self, **kwargs):
|
||||||
|
raise OSError("down")
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="down"):
|
||||||
|
asyncio.run(read_home_projection(Broken(), user_id="local", mode="lite"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_projection_refuses_a_client_built_for_another_mode() -> None:
|
||||||
|
sdk = ProjectionSdk([chat_row()])
|
||||||
|
sdk.mode = "lite"
|
||||||
|
with pytest.raises(RuntimeError, match="home_projection_mode_mismatch"):
|
||||||
|
asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_projection_route_maps_whole_read_failure_to_503(tmp_path, monkeypatch) -> None:
|
||||||
|
class Broken:
|
||||||
|
def list_memories(self, **kwargs):
|
||||||
|
raise OSError("down")
|
||||||
|
|
||||||
|
class Handle:
|
||||||
|
async def client(self):
|
||||||
|
return Broken()
|
||||||
|
|
||||||
|
app = create_app(
|
||||||
|
Settings(
|
||||||
|
_env_file=None,
|
||||||
|
data_dir=tmp_path / "data",
|
||||||
|
source_home=tmp_path,
|
||||||
|
api_token=SecretStr("test"),
|
||||||
|
imports_enabled=False,
|
||||||
|
automatic_memory_processing=False,
|
||||||
|
live_sync_enabled=False,
|
||||||
|
),
|
||||||
|
source_catalog={},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("one_memory.api._sdk_client_handle", lambda _app: Handle())
|
||||||
|
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
|
||||||
|
response = client.get("/api/v2/home-projection")
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.json() == {"detail": "home_projection_read_failed"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_projection_reads_mode_once_and_matches_the_client(tmp_path, monkeypatch) -> None:
|
||||||
|
sdk = ProjectionSdk([chat_row()])
|
||||||
|
sdk.mode = "pro"
|
||||||
|
|
||||||
|
class Handle:
|
||||||
|
async def client(self):
|
||||||
|
return sdk
|
||||||
|
|
||||||
|
app = create_app(
|
||||||
|
Settings(
|
||||||
|
_env_file=None,
|
||||||
|
data_dir=tmp_path / "data",
|
||||||
|
source_home=tmp_path,
|
||||||
|
api_token=SecretStr("test"),
|
||||||
|
imports_enabled=False,
|
||||||
|
automatic_memory_processing=False,
|
||||||
|
live_sync_enabled=False,
|
||||||
|
),
|
||||||
|
source_catalog={},
|
||||||
|
)
|
||||||
|
reads = 0
|
||||||
|
|
||||||
|
def mode(_app):
|
||||||
|
nonlocal reads
|
||||||
|
reads += 1
|
||||||
|
return "pro"
|
||||||
|
|
||||||
|
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
|
||||||
|
monkeypatch.setattr("one_memory.api._memory_mode", mode)
|
||||||
|
monkeypatch.setattr("one_memory.api._sdk_client_handle", lambda _app: Handle())
|
||||||
|
response = client.get("/api/v2/home-projection")
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert response.json()["effective_mode"] == "pro"
|
||||||
|
assert reads == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_raw_latest_uses_import_time_without_conversation_time() -> None:
|
||||||
|
from one_memory.hy_sdk_types import HySdkRawRecord
|
||||||
|
|
||||||
|
record = HySdkRawRecord.from_sdk(
|
||||||
|
{
|
||||||
|
**raw_row("r-1", "[user]: hello", memory_at=None),
|
||||||
|
"raw_id": "r-1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
latest = project_raw_latest((record,))
|
||||||
|
assert latest is not None
|
||||||
|
assert latest.time_basis == "import"
|
||||||
@@ -34,10 +34,10 @@ def test_resume_during_cleanup_returns_conflict_without_internal_error(tmp_path,
|
|||||||
job = supervisor.job_store.create(user_id="local", source_id="fixture")
|
job = supervisor.job_store.create(user_id="local", source_id="fixture")
|
||||||
supervisor.job_store.transition(job.job_id, ImportStatus.PAUSED)
|
supervisor.job_store.transition(job.job_id, ImportStatus.PAUSED)
|
||||||
|
|
||||||
def draining(_job_id):
|
async def draining(_job_id):
|
||||||
raise InvalidJobTransitionError("import_cleanup_in_progress")
|
raise InvalidJobTransitionError("import_cleanup_in_progress")
|
||||||
|
|
||||||
monkeypatch.setattr(supervisor, "resume", draining)
|
monkeypatch.setattr(supervisor, "resume_off_loop", draining)
|
||||||
response = await client.post(f"/api/v1/import-jobs/{job.job_id}/resume")
|
response = await client.post(f"/api/v1/import-jobs/{job.job_id}/resume")
|
||||||
assert response.status_code == 409
|
assert response.status_code == 409
|
||||||
assert response.json() == {"detail": "invalid_job_transition"}
|
assert response.json() == {"detail": "invalid_job_transition"}
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""`import_jobs` stays bounded, and the questions asked of it are SQL, not scans.
|
||||||
|
|
||||||
|
Live sync opened a job per change it imported and nothing ever removed one, so
|
||||||
|
the table only grew; every caller then read all of it and filtered in Python.
|
||||||
|
These tests hold the three halves of the fix: what pruning keeps, that the
|
||||||
|
filtered queries answer exactly what the old filters did, and that the indexes
|
||||||
|
behind them arrive by migration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from pydantic import SecretStr
|
||||||
|
|
||||||
|
from one_memory.api import _prune_finished_import_jobs, create_app
|
||||||
|
from one_memory.config import Settings
|
||||||
|
from one_memory.imports.jobs import (
|
||||||
|
FINISHED_STATUSES,
|
||||||
|
SYNC_JOBS_KEPT_PER_SOURCE,
|
||||||
|
ImportJob,
|
||||||
|
ImportJobStore,
|
||||||
|
ImportStatus,
|
||||||
|
)
|
||||||
|
from one_memory.memory_server.migration_runner import SqliteMigrationStore, migrate
|
||||||
|
from one_memory.memory_server.migrations import (
|
||||||
|
IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION,
|
||||||
|
MIGRATIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
_BASE = datetime(2026, 1, 1, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _store(tmp_path) -> ImportJobStore:
|
||||||
|
return ImportJobStore(tmp_path / "one-memory.sqlite3")
|
||||||
|
|
||||||
|
|
||||||
|
def _job(
|
||||||
|
store: ImportJobStore,
|
||||||
|
*,
|
||||||
|
user_id: str = "local",
|
||||||
|
source_id: str = "fixture",
|
||||||
|
operation: str = "sync",
|
||||||
|
status: ImportStatus = ImportStatus.COMPLETED,
|
||||||
|
minute: int,
|
||||||
|
issue: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Insert one row as its terminal shape, at a chosen creation time."""
|
||||||
|
created = (_BASE + timedelta(minutes=minute)).isoformat()
|
||||||
|
job_id = f"{source_id}-{user_id}-{operation}-{minute:05d}"
|
||||||
|
with store._connect(writing=True) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO import_jobs (job_id,user_id,source_id,status,created_at,updated_at,"
|
||||||
|
"operation) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(job_id, user_id, source_id, status, created, created, operation),
|
||||||
|
)
|
||||||
|
if issue:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO import_issues (job_id,kind,conversation_id,reason,created_at) "
|
||||||
|
"VALUES (?,'error',NULL,'synthetic_reason',?)",
|
||||||
|
(job_id, created),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO import_skip_reason_counts (job_id,reason,count) VALUES (?,'r',1)",
|
||||||
|
(job_id,),
|
||||||
|
)
|
||||||
|
return job_id
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(store: ImportJobStore, **filters) -> list[str]:
|
||||||
|
return [job.job_id for job in store.find_jobs(**filters)]
|
||||||
|
|
||||||
|
|
||||||
|
def _count(store: ImportJobStore, table: str, job_id: str) -> int:
|
||||||
|
with store._connect() as connection:
|
||||||
|
return int(
|
||||||
|
connection.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {table} WHERE job_id=?", (job_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pruning_keeps_the_newest_finished_syncs_of_every_source(tmp_path) -> None:
|
||||||
|
store = _store(tmp_path)
|
||||||
|
keep = 5
|
||||||
|
for source in ("alpha", "beta"):
|
||||||
|
for minute in range(12):
|
||||||
|
status = ImportStatus.CANCELLED if minute % 4 == 0 else ImportStatus.COMPLETED
|
||||||
|
_job(store, source_id=source, minute=minute, status=status, issue=True)
|
||||||
|
# A source with fewer than `keep` keeps all of them.
|
||||||
|
for minute in range(3):
|
||||||
|
_job(store, source_id="gamma", minute=minute)
|
||||||
|
# Never pruned: the active sync, history of any state, another user's lane.
|
||||||
|
running = _job(store, source_id="alpha", minute=1000, status=ImportStatus.RUNNING)
|
||||||
|
failed_history = _job(
|
||||||
|
store, source_id="alpha", operation="history", minute=2, status=ImportStatus.FAILED
|
||||||
|
)
|
||||||
|
histories = [
|
||||||
|
_job(store, source_id="alpha", operation="history", minute=100 + minute)
|
||||||
|
for minute in range(10)
|
||||||
|
]
|
||||||
|
other_user = [_job(store, user_id="other", source_id="alpha", minute=m) for m in range(8)]
|
||||||
|
|
||||||
|
removed = store.prune_finished_sync_jobs(keep=keep)
|
||||||
|
|
||||||
|
assert removed == (12 - keep) * 2 + (8 - keep)
|
||||||
|
for source in ("alpha", "beta"):
|
||||||
|
finished = _ids(
|
||||||
|
store, user_id="local", source_id=source, operation="sync", statuses=FINISHED_STATUSES
|
||||||
|
)
|
||||||
|
assert finished == [f"{source}-local-sync-{m:05d}" for m in range(12 - keep, 12)]
|
||||||
|
# Their issues and skip counts went with them; the kept ones still have theirs.
|
||||||
|
assert _count(store, "import_issues", f"{source}-local-sync-00000") == 0
|
||||||
|
assert _count(store, "import_skip_reason_counts", f"{source}-local-sync-00000") == 0
|
||||||
|
assert _count(store, "import_issues", f"{source}-local-sync-00011") == 1
|
||||||
|
assert len(_ids(store, source_id="gamma")) == 3
|
||||||
|
assert store.require(running).status is ImportStatus.RUNNING
|
||||||
|
assert store.require(failed_history).status is ImportStatus.FAILED
|
||||||
|
assert all(store.get(job_id) is not None for job_id in histories)
|
||||||
|
assert _ids(store, user_id="other") == other_user[-keep:]
|
||||||
|
# The newest job of every lane, which the App and live sync health show,
|
||||||
|
# is always kept.
|
||||||
|
assert store.latest_job(user_id="local", source_id="beta").job_id == "beta-local-sync-00011"
|
||||||
|
|
||||||
|
before = _ids(store)
|
||||||
|
assert store.prune_finished_sync_jobs(keep=keep) == 0
|
||||||
|
assert _ids(store) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_pruning_is_bounded_per_call_and_finishes_when_repeated(tmp_path) -> None:
|
||||||
|
store = _store(tmp_path)
|
||||||
|
for minute in range(60):
|
||||||
|
_job(store, minute=minute)
|
||||||
|
|
||||||
|
passes = []
|
||||||
|
while True:
|
||||||
|
removed = store.prune_finished_sync_jobs(keep=10, max_rows=15)
|
||||||
|
passes.append(removed)
|
||||||
|
if removed < 15:
|
||||||
|
break
|
||||||
|
|
||||||
|
assert passes == [15, 15, 15, 5]
|
||||||
|
assert len(_ids(store)) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_default_keep_count_is_twenty(tmp_path) -> None:
|
||||||
|
store = _store(tmp_path)
|
||||||
|
for minute in range(SYNC_JOBS_KEPT_PER_SOURCE + 7):
|
||||||
|
_job(store, minute=minute)
|
||||||
|
assert store.prune_finished_sync_jobs() == 7
|
||||||
|
assert len(_ids(store)) == SYNC_JOBS_KEPT_PER_SOURCE == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_pruning_runs_to_completion_off_the_loop(tmp_path) -> None:
|
||||||
|
store = _store(tmp_path)
|
||||||
|
for minute in range(500):
|
||||||
|
_job(store, minute=minute)
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
task = asyncio.create_task(_prune_finished_import_jobs(store, interval_seconds=3600))
|
||||||
|
for _ in range(500):
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
if len(await asyncio.to_thread(_ids, store)) == SYNC_JOBS_KEPT_PER_SOURCE:
|
||||||
|
break
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
assert len(_ids(store)) == SYNC_JOBS_KEPT_PER_SOURCE
|
||||||
|
|
||||||
|
|
||||||
|
def test_filtered_queries_answer_what_the_python_filters_did(tmp_path) -> None:
|
||||||
|
store = _store(tmp_path)
|
||||||
|
rng = random.Random(7)
|
||||||
|
lanes = [(user, source) for user in ("local", "other") for source in ("a", "b", "c")]
|
||||||
|
for minute in range(240):
|
||||||
|
user, source = rng.choice(lanes)
|
||||||
|
_job(
|
||||||
|
store,
|
||||||
|
user_id=user,
|
||||||
|
source_id=source,
|
||||||
|
operation=rng.choice(("sync", "history")),
|
||||||
|
# Only terminal shapes: the active-slot index allows one active job per lane.
|
||||||
|
status=rng.choice(FINISHED_STATUSES),
|
||||||
|
minute=minute,
|
||||||
|
)
|
||||||
|
everything: list[ImportJob] = store.list_jobs()
|
||||||
|
assert len(everything) == 240
|
||||||
|
|
||||||
|
status_sets = [
|
||||||
|
None,
|
||||||
|
(ImportStatus.COMPLETED,),
|
||||||
|
(ImportStatus.CANCELLED, ImportStatus.COMPLETED),
|
||||||
|
(ImportStatus.RUNNING,),
|
||||||
|
]
|
||||||
|
for user_id in (None, "local", "other"):
|
||||||
|
for source_id in (None, "a", "c"):
|
||||||
|
for operation in (None, "sync", "history"):
|
||||||
|
for statuses in status_sets:
|
||||||
|
expected = [
|
||||||
|
job
|
||||||
|
for job in everything
|
||||||
|
if (user_id is None or job.user_id == user_id)
|
||||||
|
and (source_id is None or job.source_id == source_id)
|
||||||
|
and (operation is None or job.operation == operation)
|
||||||
|
and (statuses is None or job.status in statuses)
|
||||||
|
]
|
||||||
|
found = store.find_jobs(
|
||||||
|
user_id=user_id,
|
||||||
|
source_id=source_id,
|
||||||
|
operation=operation,
|
||||||
|
statuses=statuses,
|
||||||
|
)
|
||||||
|
assert found == expected
|
||||||
|
newest = store.find_jobs(
|
||||||
|
user_id=user_id,
|
||||||
|
source_id=source_id,
|
||||||
|
operation=operation,
|
||||||
|
statuses=statuses,
|
||||||
|
newest_first=True,
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
assert newest == (
|
||||||
|
[max(expected, key=lambda job: (job.created_at, job.job_id))]
|
||||||
|
if expected
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
for status in ImportStatus:
|
||||||
|
assert store.list_jobs(status=status) == [job for job in everything if job.status is status]
|
||||||
|
assert store.find_jobs(statuses=()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_lookup_indexes_arrive_by_migration_and_serve_the_lane_query(tmp_path) -> None:
|
||||||
|
path = tmp_path / "one-memory.sqlite3"
|
||||||
|
older = tuple(
|
||||||
|
item for item in MIGRATIONS if item.version < IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION
|
||||||
|
)
|
||||||
|
asyncio.run(migrate(SqliteMigrationStore(path), older))
|
||||||
|
with sqlite3.connect(path) as database:
|
||||||
|
before = {
|
||||||
|
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='index'")
|
||||||
|
}
|
||||||
|
assert "import_jobs_lane" not in before
|
||||||
|
|
||||||
|
result = asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS))
|
||||||
|
|
||||||
|
assert result.applied == (IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION,)
|
||||||
|
with sqlite3.connect(path) as database:
|
||||||
|
indexes = {
|
||||||
|
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='index'")
|
||||||
|
}
|
||||||
|
plan = " ".join(
|
||||||
|
str(row[-1])
|
||||||
|
for row in database.execute(
|
||||||
|
"EXPLAIN QUERY PLAN SELECT * FROM import_jobs "
|
||||||
|
"WHERE user_id=? AND source_id=? AND operation=? AND status IN (?,?) "
|
||||||
|
"ORDER BY created_at DESC LIMIT 1",
|
||||||
|
("local", "fixture", "sync", "pending", "running"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert {"import_jobs_lane", "import_jobs_status"} <= indexes
|
||||||
|
assert "import_jobs_lane" in plan
|
||||||
|
# Re-running it is a no-op, and a store opened on the database agrees.
|
||||||
|
asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS))
|
||||||
|
ImportJobStore(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_running_home_gets_the_indexes_from_the_store(tmp_path) -> None:
|
||||||
|
path = tmp_path / "one-memory.sqlite3"
|
||||||
|
ImportJobStore(path)
|
||||||
|
with sqlite3.connect(path) as database:
|
||||||
|
database.execute("DROP INDEX import_jobs_lane")
|
||||||
|
database.execute("DROP INDEX import_jobs_status")
|
||||||
|
ImportJobStore(path)
|
||||||
|
with sqlite3.connect(path) as database:
|
||||||
|
indexes = {
|
||||||
|
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='index'")
|
||||||
|
}
|
||||||
|
assert {"import_jobs_lane", "import_jobs_status"} <= indexes
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_mysql_rendering_tolerates_an_index_already_there() -> None:
|
||||||
|
class Cursor:
|
||||||
|
dialect = "mysql"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.attempted: list[str] = []
|
||||||
|
|
||||||
|
async def execute(self, statement: str) -> None:
|
||||||
|
self.attempted.append(statement)
|
||||||
|
raise RuntimeError(1061, "duplicate key name")
|
||||||
|
|
||||||
|
cursor = Cursor()
|
||||||
|
migration = next(
|
||||||
|
item for item in MIGRATIONS if item.version == IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION
|
||||||
|
)
|
||||||
|
asyncio.run(migration.apply(cursor))
|
||||||
|
assert cursor.attempted and "import_jobs" in cursor.attempted[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_import_jobs_route_keeps_its_shape_and_order(tmp_path) -> None:
|
||||||
|
async def scenario() -> None:
|
||||||
|
app = create_app(
|
||||||
|
Settings(
|
||||||
|
data_dir=tmp_path / "data",
|
||||||
|
source_home=tmp_path / "home",
|
||||||
|
api_token=SecretStr("synthetic"),
|
||||||
|
automatic_memory_processing=False,
|
||||||
|
live_sync_enabled=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async with (
|
||||||
|
app.router.lifespan_context(app),
|
||||||
|
AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://test",
|
||||||
|
headers={"Authorization": "Bearer synthetic"},
|
||||||
|
) as client,
|
||||||
|
):
|
||||||
|
store: ImportJobStore = app.state.import_job_store
|
||||||
|
for minute in (3, 1, 2):
|
||||||
|
_job(store, source_id="fixture", minute=minute)
|
||||||
|
_job(store, source_id="fixture", operation="history", minute=0)
|
||||||
|
_job(store, user_id="other", source_id="hermes", minute=4)
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/import-jobs")
|
||||||
|
other = await client.get("/api/v1/import-jobs", params={"user_id": "other"})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
expected = [job for job in store.list_jobs() if job.user_id == "local"]
|
||||||
|
assert [item["job_id"] for item in body] == [job.job_id for job in expected]
|
||||||
|
assert [item["created_at"] for item in body] == sorted(
|
||||||
|
item["created_at"] for item in body
|
||||||
|
)
|
||||||
|
assert set(body[0]) == set(ImportJob.model_fields)
|
||||||
|
assert [item["user_id"] for item in other.json()] == ["other"]
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_processing_reports_the_write_drain(tmp_path) -> None:
|
||||||
|
async def scenario() -> None:
|
||||||
|
app = create_app(
|
||||||
|
Settings(
|
||||||
|
data_dir=tmp_path / "data",
|
||||||
|
source_home=tmp_path / "home",
|
||||||
|
api_token=SecretStr("synthetic"),
|
||||||
|
automatic_memory_processing=False,
|
||||||
|
live_sync_enabled=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async with (
|
||||||
|
app.router.lifespan_context(app),
|
||||||
|
AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://test",
|
||||||
|
headers={"Authorization": "Bearer synthetic"},
|
||||||
|
) as client,
|
||||||
|
):
|
||||||
|
for _ in range(500):
|
||||||
|
if app.state.recovery in {"done", "failed"}:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
body = (await client.get("/api/v1/memory-processing")).json()
|
||||||
|
assert body["write_drain"] == "running"
|
||||||
|
assert body["write_drain_error"] is None
|
||||||
|
|
||||||
|
drain = app.state.memory_write_drain
|
||||||
|
drain.liveness = "restarting"
|
||||||
|
drain.last_error_code = "operational_error"
|
||||||
|
body = (await client.get("/api/v1/memory-processing")).json()
|
||||||
|
assert body["write_drain"] == "restarting"
|
||||||
|
assert body["write_drain_error"] == "operational_error"
|
||||||
|
drain.liveness = "running"
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
@@ -415,15 +415,15 @@ def test_shutdown_preserves_a_job_that_finished_after_the_running_snapshot(
|
|||||||
task = asyncio.create_task(waiting())
|
task = asyncio.create_task(waiting())
|
||||||
supervisor._tasks[job.job_id] = task
|
supervisor._tasks[job.job_id] = task
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
original = jobs.list_jobs
|
original = jobs.find_jobs
|
||||||
|
|
||||||
def finish_after_snapshot(*args, **kwargs):
|
def finish_after_snapshot(*args, **kwargs):
|
||||||
found = original(*args, **kwargs)
|
found = original(*args, **kwargs)
|
||||||
if kwargs.get("status") is ImportStatus.RUNNING:
|
if tuple(kwargs.get("statuses") or ()) == (ImportStatus.RUNNING,):
|
||||||
jobs.finish_owned(job.job_id, runner_token=token, target=terminal)
|
jobs.finish_owned(job.job_id, runner_token=token, target=terminal)
|
||||||
return found
|
return found
|
||||||
|
|
||||||
monkeypatch.setattr(jobs, "list_jobs", finish_after_snapshot)
|
monkeypatch.setattr(jobs, "find_jobs", finish_after_snapshot)
|
||||||
try:
|
try:
|
||||||
await supervisor.shutdown()
|
await supervisor.shutdown()
|
||||||
assert jobs.require(job.job_id).status is terminal
|
assert jobs.require(job.job_id).status is terminal
|
||||||
@@ -433,3 +433,45 @@ def test_shutdown_preserves_a_job_that_finished_after_the_running_snapshot(
|
|||||||
await asyncio.gather(task, return_exceptions=True)
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_abandon_makes_its_database_calls_off_the_event_loop(tmp_path, monkeypatch) -> None:
|
||||||
|
"""Live sync abandons from its poll; a writer wait there froze the whole Core."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
evidence, jobs = stores(tmp_path)
|
||||||
|
supervisor = ImportSupervisor(adapters={}, evidence_store=evidence, job_store=jobs)
|
||||||
|
job = jobs.create(user_id="local", source_id="fixture", operation="sync")
|
||||||
|
job, _token = jobs.claim(job.job_id)
|
||||||
|
loop_thread = threading.current_thread()
|
||||||
|
calls: list[tuple[str, threading.Thread]] = []
|
||||||
|
for name in ("require", "interrupt_running"):
|
||||||
|
original = getattr(jobs, name)
|
||||||
|
|
||||||
|
def recorded(*args, _name=name, _original=original, **kwargs):
|
||||||
|
calls.append((_name, threading.current_thread()))
|
||||||
|
return _original(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(jobs, name, recorded)
|
||||||
|
|
||||||
|
async def wedged() -> None:
|
||||||
|
# Ignores the first cancel, like a runner stuck in a thread.
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
task = asyncio.create_task(wedged())
|
||||||
|
supervisor._tasks[job.job_id] = task
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
try:
|
||||||
|
assert await supervisor.abandon(job.job_id, timeout=0.01) is False
|
||||||
|
finally:
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
|
||||||
|
assert {name for name, _thread in calls} == {"require", "interrupt_running"}
|
||||||
|
assert all(thread is not loop_thread for _name, thread in calls)
|
||||||
|
assert jobs.require(job.job_id).status is ImportStatus.INTERRUPTED
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
@@ -298,7 +298,7 @@ def test_hook_job_completes_after_file_identity_adoption(tmp_path) -> None:
|
|||||||
assert current is not None
|
assert current is not None
|
||||||
assert ingest_journal.matches_current(connection, current)
|
assert ingest_journal.matches_current(connection, current)
|
||||||
connection.rollback()
|
connection.rollback()
|
||||||
await before_hy_invocation()
|
await before_hy_invocation(True)
|
||||||
return SdkWriteReceipt()
|
return SdkWriteReceipt()
|
||||||
|
|
||||||
drain = DurableWriteDrain(queue, execute=lambda _job: None)
|
drain = DurableWriteDrain(queue, execute=lambda _job: None)
|
||||||
|
|||||||
@@ -641,3 +641,32 @@ def _job(*, created_at: datetime) -> ImportJob:
|
|||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
updated_at=datetime.now(UTC),
|
updated_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attributing_changed_paths_happens_off_the_event_loop(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
"""Resolving a host's batch of changed paths blocked the loop for seconds."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
root, (_evidence, live, imports) = _source(tmp_path)
|
||||||
|
watch = FileWatchLiveSync(
|
||||||
|
roots={PI_DESCRIPTOR.source_id: root},
|
||||||
|
supervisor=live,
|
||||||
|
debounce_seconds=0.1,
|
||||||
|
min_interval_seconds=0.1,
|
||||||
|
)
|
||||||
|
loop_thread = threading.current_thread()
|
||||||
|
resolved_on: list[threading.Thread] = []
|
||||||
|
original = watch.source_for
|
||||||
|
|
||||||
|
def recorded(path: Path) -> str | None:
|
||||||
|
resolved_on.append(threading.current_thread())
|
||||||
|
return original(path)
|
||||||
|
|
||||||
|
monkeypatch.setattr(watch, "source_for", recorded)
|
||||||
|
|
||||||
|
assert await watch.handle_changes({tmp_path / "elsewhere/session.jsonl"}) == ()
|
||||||
|
assert resolved_on and loop_thread not in resolved_on
|
||||||
|
await imports.shutdown()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
@@ -318,9 +318,31 @@ def test_main_database_checkpoints_on_a_write_cadence(tmp_path, monkeypatch):
|
|||||||
with database.write() as db, db:
|
with database.write() as db, db:
|
||||||
db.execute("UPDATE counter SET value = 2 WHERE id = 1")
|
db.execute("UPDATE counter SET value = 2 WHERE id = 1")
|
||||||
|
|
||||||
|
assert _access.wait_for_cadence_checkpoints()
|
||||||
assert checkpoints and checkpoints[0][0] == "PASSIVE"
|
assert checkpoints and checkpoints[0][0] == "PASSIVE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cadence_checkpoint_does_not_run_on_the_writing_thread(tmp_path, monkeypatch):
|
||||||
|
"""The thread that wrote returns at once; the checkpointer takes the next turn."""
|
||||||
|
path = _database(tmp_path)
|
||||||
|
database = main_database(path)
|
||||||
|
ran_on = []
|
||||||
|
original = database._checkpoint_from_maintenance
|
||||||
|
|
||||||
|
def observed(mode):
|
||||||
|
ran_on.append(threading.current_thread().name)
|
||||||
|
return original(mode)
|
||||||
|
|
||||||
|
monkeypatch.setattr(database, "_checkpoint_from_maintenance", observed)
|
||||||
|
monkeypatch.setattr(_access, "MAIN_DATABASE_CHECKPOINT_WRITE_INTERVAL", 1)
|
||||||
|
|
||||||
|
with database.write() as db, db:
|
||||||
|
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
|
||||||
|
|
||||||
|
assert _access.wait_for_cadence_checkpoints()
|
||||||
|
assert ran_on == ["one-memory-checkpoint"]
|
||||||
|
|
||||||
|
|
||||||
def test_main_database_warns_when_wal_stays_above_the_high_water_mark(
|
def test_main_database_warns_when_wal_stays_above_the_high_water_mark(
|
||||||
tmp_path, monkeypatch, caplog
|
tmp_path, monkeypatch, caplog
|
||||||
):
|
):
|
||||||
@@ -332,10 +354,85 @@ def test_main_database_warns_when_wal_stays_above_the_high_water_mark(
|
|||||||
|
|
||||||
with database.write() as db, db:
|
with database.write() as db, db:
|
||||||
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
|
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
|
||||||
|
assert _access.wait_for_cadence_checkpoints()
|
||||||
|
|
||||||
assert any("main_database_wal_high" in record.message for record in caplog.records)
|
assert any("main_database_wal_high" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_wal_whose_frames_are_all_checkpointed_is_not_reported_high(
|
||||||
|
tmp_path, monkeypatch, caplog
|
||||||
|
):
|
||||||
|
"""#245: the alert judges frames still waiting, not the file's size."""
|
||||||
|
path = _database(tmp_path)
|
||||||
|
database = main_database(path)
|
||||||
|
monkeypatch.setattr(_access, "MAIN_DATABASE_CHECKPOINT_WRITE_INTERVAL", 1)
|
||||||
|
# One page: any frame left uncopied would cross it, a fully copied WAL not.
|
||||||
|
monkeypatch.setattr(_access, "MAIN_DATABASE_WAL_WARN_BYTES", 1)
|
||||||
|
caplog.set_level("WARNING", logger="one_memory.database_access")
|
||||||
|
|
||||||
|
with database.write() as db, db:
|
||||||
|
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
|
||||||
|
assert _access.wait_for_cadence_checkpoints()
|
||||||
|
|
||||||
|
assert database._wal_bytes() > 0
|
||||||
|
assert not any("main_database_wal_high" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_writer_caps_the_wal_it_leaves_behind(tmp_path):
|
||||||
|
path = _database(tmp_path)
|
||||||
|
with main_database(path).write() as db:
|
||||||
|
limit = db.execute("PRAGMA journal_size_limit").fetchone()[0]
|
||||||
|
assert limit == _access.MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_idle_checkpoint_shrinks_the_wal_after_a_large_burst(tmp_path, monkeypatch):
|
||||||
|
"""#245: a WAL that grew once used to stay that size for good."""
|
||||||
|
path = _database(tmp_path)
|
||||||
|
database = main_database(path)
|
||||||
|
# No cadence checkpoint during the burst: the WAL has to grow.
|
||||||
|
monkeypatch.setattr(_access, "MAIN_DATABASE_CHECKPOINT_WRITE_INTERVAL", 10_000_000)
|
||||||
|
monkeypatch.setattr(_access, "MAIN_DATABASE_CHECKPOINT_SECONDS", 10_000_000.0)
|
||||||
|
database._last_checkpoint_at = time.monotonic()
|
||||||
|
with database.write() as db, db:
|
||||||
|
db.execute("PRAGMA wal_autocheckpoint = 0")
|
||||||
|
db.execute("CREATE TABLE blobs (body BLOB)")
|
||||||
|
db.executemany(
|
||||||
|
"INSERT INTO blobs VALUES (randomblob(?))", [(64 * 1024,) for _ in range(1_500)]
|
||||||
|
)
|
||||||
|
with database.write() as db:
|
||||||
|
db.execute("PRAGMA wal_autocheckpoint = 1000")
|
||||||
|
grown = database._wal_bytes()
|
||||||
|
assert grown > _access.MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES
|
||||||
|
|
||||||
|
with database.read() as reader:
|
||||||
|
reader.execute("SELECT COUNT(*) FROM blobs").fetchone()
|
||||||
|
# Someone is reading: no truncate is even attempted.
|
||||||
|
assert database.checkpoint_if_idle() is None
|
||||||
|
assert _access.checkpoint_idle_main_databases() == 1
|
||||||
|
|
||||||
|
assert database._wal_bytes() <= _access.MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES
|
||||||
|
with database.read() as reader:
|
||||||
|
assert reader.execute("SELECT COUNT(*) FROM blobs").fetchone() == (1_500,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_busy_idle_checkpoint_is_skipped_not_raised(tmp_path):
|
||||||
|
path = _database(tmp_path)
|
||||||
|
database = main_database(path)
|
||||||
|
with database.write() as db, db:
|
||||||
|
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
|
||||||
|
# Another process's reader holds a snapshot: TRUNCATE cannot finish.
|
||||||
|
outsider = sqlite3.connect(path)
|
||||||
|
try:
|
||||||
|
outsider.execute("BEGIN")
|
||||||
|
outsider.execute("SELECT value FROM counter").fetchone()
|
||||||
|
result = database.checkpoint_if_idle()
|
||||||
|
assert result is None or result[0] == 1
|
||||||
|
finally:
|
||||||
|
outsider.close()
|
||||||
|
assert database.checkpoint_if_idle() == (0, 0, 0)
|
||||||
|
assert database._wal_bytes() == 0
|
||||||
|
|
||||||
|
|
||||||
def test_main_database_maintenance_checkpoint_does_not_break_an_active_reader(
|
def test_main_database_maintenance_checkpoint_does_not_break_an_active_reader(
|
||||||
tmp_path, monkeypatch
|
tmp_path, monkeypatch
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ def test_sdk_user_profile_http_returns_native_assembled_profile(tmp_path):
|
|||||||
response = await client.get("/api/v2/user-profile")
|
response = await client.get("/api/v2/user-profile")
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
|
"user_id": "local",
|
||||||
"request_id": "profile-request",
|
"request_id": "profile-request",
|
||||||
"profile": {
|
"profile": {
|
||||||
"memory_id": "l0-head",
|
"memory_id": "l0-head",
|
||||||
@@ -660,6 +661,8 @@ def test_memory_add_routes_do_not_collide_with_single_memory_routes(tmp_path):
|
|||||||
"task_id",
|
"task_id",
|
||||||
"memory_ids",
|
"memory_ids",
|
||||||
"error",
|
"error",
|
||||||
|
"error_code",
|
||||||
|
"next_retry_at",
|
||||||
}
|
}
|
||||||
status = await client.get(f"/api/v2/memories/additions/{request_id}")
|
status = await client.get(f"/api/v2/memories/additions/{request_id}")
|
||||||
assert status.json()["evidence_id"] == created.json()["evidence_id"]
|
assert status.json()["evidence_id"] == created.json()["evidence_id"]
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ from one_memory.memory_features import (
|
|||||||
FEATURE_NAMES,
|
FEATURE_NAMES,
|
||||||
FEATURES,
|
FEATURES,
|
||||||
FEATURES_BY_NAME,
|
FEATURES_BY_NAME,
|
||||||
|
PARAMETERS,
|
||||||
|
SETTING_NAMES,
|
||||||
MemoryFeatures,
|
MemoryFeatures,
|
||||||
effective_features,
|
effective_features,
|
||||||
feature_activity,
|
feature_activity,
|
||||||
@@ -46,15 +48,20 @@ TODAY = {
|
|||||||
"session_episodes": False,
|
"session_episodes": False,
|
||||||
"episode_summary": False,
|
"episode_summary": False,
|
||||||
"intention": True,
|
"intention": True,
|
||||||
|
"intention_dedup": True,
|
||||||
"few_shot": False,
|
"few_shot": False,
|
||||||
}
|
}
|
||||||
|
#: The same for the numeric settings.
|
||||||
|
TODAY_PARAMETERS = {"intention_dedup_threshold": 0.95}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _clean_sdk_env(monkeypatch):
|
def _clean_sdk_env(monkeypatch):
|
||||||
"""The SDK's own env vars feed its defaults; tests start without them."""
|
"""The SDK's own env vars feed its defaults; tests start without them."""
|
||||||
for spec in FEATURES:
|
for spec in (*FEATURES, *PARAMETERS):
|
||||||
monkeypatch.delenv(spec.env, raising=False)
|
monkeypatch.delenv(spec.env, raising=False)
|
||||||
|
# The threshold falls back to the SDK's shared dedup variable.
|
||||||
|
monkeypatch.delenv("MEMORY_DEDUP_THRESHOLD", raising=False)
|
||||||
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
|
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -95,17 +102,25 @@ def _field(config, name: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def test_the_document_names_exactly_the_catalog() -> None:
|
def test_the_document_names_exactly_the_catalog() -> None:
|
||||||
assert tuple(MemoryFeatures.model_fields) == FEATURE_NAMES
|
assert tuple(MemoryFeatures.model_fields) == SETTING_NAMES
|
||||||
assert set(TODAY) == set(FEATURE_NAMES)
|
assert set(TODAY) == set(FEATURE_NAMES)
|
||||||
# Each feature is a MemoryConfig field the SDK has; no env var is written.
|
# Each feature is a MemoryConfig field the SDK has; no env var is written.
|
||||||
defaults = hy_sdk_runtime.sdk_feature_defaults()
|
defaults = hy_sdk_runtime.sdk_feature_defaults()
|
||||||
assert set(defaults) == set(FEATURE_NAMES)
|
assert set(defaults) == set(SETTING_NAMES)
|
||||||
|
|
||||||
|
|
||||||
def test_the_defaults_are_todays_behaviour() -> None:
|
def test_the_defaults_are_todays_behaviour() -> None:
|
||||||
assert hy_sdk_runtime.sdk_feature_defaults() == TODAY
|
everything = {**TODAY, **TODAY_PARAMETERS}
|
||||||
|
assert hy_sdk_runtime.sdk_feature_defaults() == everything
|
||||||
assert MemoryFeatures().overrides() == ()
|
assert MemoryFeatures().overrides() == ()
|
||||||
assert effective_features((), TODAY) == TODAY
|
assert effective_features((), everything) == everything
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("profile", PROFILES, ids=("builtin", "remote"))
|
||||||
|
def test_intention_dedup_defaults_to_on_at_095_in_the_built_config(tmp_path, profile) -> None:
|
||||||
|
config = _config(tmp_path, profile)
|
||||||
|
assert config.recall.intention_dedup_enabled is True
|
||||||
|
assert config.recall.intention_dedup_threshold == 0.95
|
||||||
|
|
||||||
|
|
||||||
def test_the_sdk_env_var_still_moves_the_default_of_an_unset_feature(monkeypatch) -> None:
|
def test_the_sdk_env_var_still_moves_the_default_of_an_unset_feature(monkeypatch) -> None:
|
||||||
@@ -126,6 +141,32 @@ def test_unknown_or_non_boolean_values_are_refused() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", (0.79, 1.01, -1, 2, True, "0.9", float("nan"), float("inf")))
|
||||||
|
def test_an_intention_dedup_threshold_outside_its_range_is_refused(bad) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
MemoryFeatures.model_validate({"intention_dedup_threshold": bad})
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_intention_dedup_threshold_range_is_inclusive() -> None:
|
||||||
|
for value in (0.8, 0.9, 1, 1.0):
|
||||||
|
document = MemoryFeatures.model_validate({"intention_dedup_threshold": value})
|
||||||
|
assert document.overrides() == (("intention_dedup_threshold", float(value)),)
|
||||||
|
with pytest.raises(ValueError, match="invalid_memory_feature_value"):
|
||||||
|
hy_sdk_runtime.build_sdk_config(
|
||||||
|
PROFILES[0],
|
||||||
|
api_key=None,
|
||||||
|
data_dir=Path("/tmp/unused"),
|
||||||
|
features={"intention_dedup_threshold": 0.5},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sdk_env_vars_still_move_the_threshold_default(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.9")
|
||||||
|
assert hy_sdk_runtime.sdk_feature_defaults()["intention_dedup_threshold"] == 0.9
|
||||||
|
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_THRESHOLD", "0.97")
|
||||||
|
assert hy_sdk_runtime.sdk_feature_defaults()["intention_dedup_threshold"] == 0.97
|
||||||
|
|
||||||
|
|
||||||
def test_merge_sets_resets_and_leaves_absent_keys_alone() -> None:
|
def test_merge_sets_resets_and_leaves_absent_keys_alone() -> None:
|
||||||
saved = MemoryFeatures(graph_rerank=True, few_shot=True)
|
saved = MemoryFeatures(graph_rerank=True, few_shot=True)
|
||||||
update = MemoryFeatures.model_validate({"few_shot": None, "episode_recall": True})
|
update = MemoryFeatures.model_validate({"few_shot": None, "episode_recall": True})
|
||||||
@@ -165,6 +206,20 @@ def test_each_feature_reaches_the_built_memory_config(tmp_path, profile) -> None
|
|||||||
assert _field(config, other.name) is TODAY[other.name], (spec.name, other.name)
|
assert _field(config, other.name) is TODAY[other.name], (spec.name, other.name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("profile", PROFILES, ids=("builtin", "remote"))
|
||||||
|
def test_intention_dedup_on_off_and_threshold_reach_the_built_memory_config(
|
||||||
|
tmp_path, profile
|
||||||
|
) -> None:
|
||||||
|
off = _config(tmp_path, profile, {"intention_dedup": False})
|
||||||
|
assert off.recall.intention_dedup_enabled is False
|
||||||
|
assert off.recall.intention_dedup_threshold == 0.95
|
||||||
|
on = _config(tmp_path, profile, {"intention_dedup": True, "intention_dedup_threshold": 0.9})
|
||||||
|
assert on.recall.intention_dedup_enabled is True
|
||||||
|
assert on.recall.intention_dedup_threshold == 0.9
|
||||||
|
# Nothing else moves.
|
||||||
|
assert _field(on, "intention") is True and _field(on, "graph_rerank") is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("profile", PROFILES, ids=("builtin", "remote"))
|
@pytest.mark.parametrize("profile", PROFILES, ids=("builtin", "remote"))
|
||||||
def test_nothing_set_is_the_config_of_before_byte_for_byte(tmp_path, profile) -> None:
|
def test_nothing_set_is_the_config_of_before_byte_for_byte(tmp_path, profile) -> None:
|
||||||
before = repr(dataclasses.asdict(_config(tmp_path, profile)))
|
before = repr(dataclasses.asdict(_config(tmp_path, profile)))
|
||||||
@@ -172,6 +227,8 @@ def test_nothing_set_is_the_config_of_before_byte_for_byte(tmp_path, profile) ->
|
|||||||
assert repr(dataclasses.asdict(_config(tmp_path, profile, {}))) == before
|
assert repr(dataclasses.asdict(_config(tmp_path, profile, {}))) == before
|
||||||
# Setting every feature to its default value changes nothing either.
|
# Setting every feature to its default value changes nothing either.
|
||||||
assert repr(dataclasses.asdict(_config(tmp_path, profile, TODAY))) == before
|
assert repr(dataclasses.asdict(_config(tmp_path, profile, TODAY))) == before
|
||||||
|
everything = {**TODAY, **TODAY_PARAMETERS}
|
||||||
|
assert repr(dataclasses.asdict(_config(tmp_path, profile, everything))) == before
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("profile", PROFILES, ids=("builtin", "remote"))
|
@pytest.mark.parametrize("profile", PROFILES, ids=("builtin", "remote"))
|
||||||
@@ -190,6 +247,7 @@ def test_features_never_change_the_collection_identity(tmp_path, profile) -> Non
|
|||||||
{name: True for name in FEATURE_NAMES},
|
{name: True for name in FEATURE_NAMES},
|
||||||
{name: False for name in FEATURE_NAMES},
|
{name: False for name in FEATURE_NAMES},
|
||||||
{"graph_rerank": True, "episode_recall": True},
|
{"graph_rerank": True, "episode_recall": True},
|
||||||
|
{"intention_dedup": False, "intention_dedup_threshold": 0.8},
|
||||||
):
|
):
|
||||||
assert identity(_config(tmp_path, profile, values)) == baseline
|
assert identity(_config(tmp_path, profile, values)) == baseline
|
||||||
|
|
||||||
@@ -243,6 +301,13 @@ def test_a_feature_needing_a_higher_mode_is_reported_inactive() -> None:
|
|||||||
series = effective_features((("same_series_timeline", True), ("graph_rerank", True)), TODAY)
|
series = effective_features((("same_series_timeline", True), ("graph_rerank", True)), TODAY)
|
||||||
assert feature_activity("same_series_timeline", series, "pro") == "requires_ultra"
|
assert feature_activity("same_series_timeline", series, "pro") == "requires_ultra"
|
||||||
assert feature_activity("same_series_timeline", series, "ultra") is None
|
assert feature_activity("same_series_timeline", series, "ultra") is None
|
||||||
|
defaults = {**TODAY, **TODAY_PARAMETERS}
|
||||||
|
assert feature_activity("intention_dedup", defaults, "pro") is None
|
||||||
|
assert feature_activity("intention_dedup_threshold", defaults, "pro") is None
|
||||||
|
assert feature_activity("intention_dedup_threshold", defaults, "lite") == "requires_pro"
|
||||||
|
off = effective_features((("intention_dedup", False),), defaults)
|
||||||
|
assert feature_activity("intention_dedup", off, "pro") == "disabled"
|
||||||
|
assert feature_activity("intention_dedup_threshold", off, "pro") == "requires_intention_dedup"
|
||||||
|
|
||||||
|
|
||||||
# --- rebuilding handles -------------------------------------------------------
|
# --- rebuilding handles -------------------------------------------------------
|
||||||
@@ -459,6 +524,11 @@ def test_features_api_get_put_reset_and_status(tmp_path, monkeypatch) -> None:
|
|||||||
"episode_recall": True,
|
"episode_recall": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
app.state.model_profile_store,
|
||||||
|
"active_identity",
|
||||||
|
lambda: ("active-profile", 1),
|
||||||
|
)
|
||||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
|
await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
|
||||||
await app.state.memory_graph_backfill.wait()
|
await app.state.memory_graph_backfill.wait()
|
||||||
ultra = _by_name((await client.get("/api/v1/settings/memory-features")).json())
|
ultra = _by_name((await client.get("/api/v1/settings/memory-features")).json())
|
||||||
@@ -497,3 +567,99 @@ def test_features_api_get_put_reset_and_status(tmp_path, monkeypatch) -> None:
|
|||||||
assert (await client.get("/api/v1/settings/memory-features")).json()["non_default"] == {}
|
assert (await client.get("/api/v1/settings/memory-features")).json()["non_default"] == {}
|
||||||
|
|
||||||
run_app(tmp_path, monkeypatch, scenario)
|
run_app(tmp_path, monkeypatch, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fresh_sdk
|
||||||
|
def test_intention_dedup_api_on_off_threshold_and_422(tmp_path, monkeypatch) -> None:
|
||||||
|
built: list[tuple[bool, float]] = []
|
||||||
|
original = hy_sdk_runtime.build_sdk_config
|
||||||
|
|
||||||
|
def recording(*args, **kwargs):
|
||||||
|
config = original(*args, **kwargs)
|
||||||
|
built.append(
|
||||||
|
(config.recall.intention_dedup_enabled, config.recall.intention_dedup_threshold)
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
monkeypatch.setattr(hy_sdk_runtime, "build_sdk_config", recording)
|
||||||
|
|
||||||
|
def built_now(app) -> tuple[bool, float]:
|
||||||
|
"""What a client built with the read handle's current key carries."""
|
||||||
|
recording(
|
||||||
|
hy_sdk_runtime.builtin_retrieval_snapshot(),
|
||||||
|
api_key=None,
|
||||||
|
data_dir=tmp_path,
|
||||||
|
features=app.state.sdk_client_handle.current_features(),
|
||||||
|
)
|
||||||
|
return built[-1]
|
||||||
|
|
||||||
|
async def scenario(app, client):
|
||||||
|
path = "/api/v1/settings/memory-features"
|
||||||
|
current = (await client.get(path)).json()
|
||||||
|
dedup = _by_name(current)["intention_dedup"]
|
||||||
|
assert dedup["enabled"] is True and dedup["default"] is True
|
||||||
|
assert dedup["configured"] is None and dedup["active"] is True
|
||||||
|
assert dedup["sdk_field"] == "recall.intention_dedup_enabled"
|
||||||
|
assert dedup["env"] == "MEMORY_INTENTION_DEDUP_ENABLED"
|
||||||
|
(threshold,) = current["parameters"]
|
||||||
|
assert threshold == {
|
||||||
|
"name": "intention_dedup_threshold",
|
||||||
|
"value": 0.95,
|
||||||
|
"configured": None,
|
||||||
|
"default": 0.95,
|
||||||
|
"minimum": 0.8,
|
||||||
|
"maximum": 1.0,
|
||||||
|
"active": True,
|
||||||
|
"reason": None,
|
||||||
|
"requires_feature": "intention_dedup",
|
||||||
|
"sdk_field": "recall.intention_dedup_threshold",
|
||||||
|
"env": "MEMORY_INTENTION_DEDUP_THRESHOLD",
|
||||||
|
"description": threshold["description"],
|
||||||
|
}
|
||||||
|
assert built_now(app) == (True, 0.95)
|
||||||
|
|
||||||
|
off = await client.put(path, json={"intention_dedup": False})
|
||||||
|
assert off.status_code == 200, off.text
|
||||||
|
body = off.json()
|
||||||
|
assert body["changed"] == ["intention_dedup"]
|
||||||
|
assert body["non_default"] == {"intention_dedup": False}
|
||||||
|
assert body["parameters"][0]["active"] is False
|
||||||
|
assert body["parameters"][0]["reason"] == "requires_intention_dedup"
|
||||||
|
assert app.state.sdk_write_coordinator.memory_features() == (("intention_dedup", False),)
|
||||||
|
assert built_now(app) == (False, 0.95)
|
||||||
|
|
||||||
|
on = (
|
||||||
|
await client.put(path, json={"intention_dedup": True, "intention_dedup_threshold": 0.9})
|
||||||
|
).json()
|
||||||
|
assert on["changed"] == ["intention_dedup", "intention_dedup_threshold"]
|
||||||
|
assert on["non_default"] == {"intention_dedup_threshold": 0.9}
|
||||||
|
assert on["parameters"][0]["value"] == 0.9 and on["parameters"][0]["active"] is True
|
||||||
|
expected = (("intention_dedup", True), ("intention_dedup_threshold", 0.9))
|
||||||
|
assert app.state.sdk_client_handle.current_features() == expected
|
||||||
|
assert built_now(app) == (True, 0.9)
|
||||||
|
processing = (await client.get("/api/v1/memory-processing")).json()
|
||||||
|
assert processing["pipeline"]["features"] == {"intention_dedup_threshold": 0.9}
|
||||||
|
saved = json.loads((tmp_path / "data" / "config.json").read_text())
|
||||||
|
assert saved["memory_features"] == {
|
||||||
|
"intention_dedup": True,
|
||||||
|
"intention_dedup_threshold": 0.9,
|
||||||
|
}
|
||||||
|
|
||||||
|
for bad in (0.5, 0.79, 1.5, -0.1, "0.9", True):
|
||||||
|
response = await client.put(path, json={"intention_dedup_threshold": bad})
|
||||||
|
assert response.status_code == 422, bad
|
||||||
|
assert (await client.put(path, json={"intention_dedup": "false"})).status_code == 422
|
||||||
|
# A refused body changes nothing.
|
||||||
|
assert app.state.sdk_client_handle.current_features() == expected
|
||||||
|
|
||||||
|
reset = (
|
||||||
|
await client.put(
|
||||||
|
path, json={"intention_dedup": None, "intention_dedup_threshold": None}
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
assert reset["changed"] == ["intention_dedup_threshold"]
|
||||||
|
assert reset["non_default"] == {}
|
||||||
|
assert app.state.sdk_client_handle.current_features() == ()
|
||||||
|
assert built_now(app) == (True, 0.95)
|
||||||
|
|
||||||
|
run_app(tmp_path, monkeypatch, scenario)
|
||||||
@@ -41,6 +41,8 @@ from one_memory.memory_evolution import (
|
|||||||
EvolveScheduler,
|
EvolveScheduler,
|
||||||
EvolveService,
|
EvolveService,
|
||||||
EvolveStateStore,
|
EvolveStateStore,
|
||||||
|
GraphBuildState,
|
||||||
|
GraphBuildUser,
|
||||||
MemoryGraphBackfill,
|
MemoryGraphBackfill,
|
||||||
evolve_due,
|
evolve_due,
|
||||||
)
|
)
|
||||||
@@ -58,8 +60,10 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
|
|||||||
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
|
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
|
||||||
store = ConfigStore(tmp_path)
|
store = ConfigStore(tmp_path)
|
||||||
assert store.memory_mode() == "pro"
|
assert store.memory_mode() == "pro"
|
||||||
|
assert store.memory_mode_explicitly_configured() is False
|
||||||
assert effective_memory_mode(store.memory_mode()) == "pro"
|
assert effective_memory_mode(store.memory_mode()) == "pro"
|
||||||
store.set_memory_mode("lite")
|
store.set_memory_mode("lite")
|
||||||
|
assert store.memory_mode_explicitly_configured() is True
|
||||||
assert ConfigStore(tmp_path).memory_mode() == "lite"
|
assert ConfigStore(tmp_path).memory_mode() == "lite"
|
||||||
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "lite"
|
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "lite"
|
||||||
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
|
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
|
||||||
@@ -70,6 +74,21 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
|
|||||||
store.set_memory_mode("max") # type: ignore[arg-type]
|
store.set_memory_mode("max") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_config_defaults_to_implicit_pro_until_pro_is_explicitly_set(tmp_path) -> None:
|
||||||
|
(tmp_path / "config.json").write_text(
|
||||||
|
json.dumps({"schema_version": 1, "sources": {}}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
store = ConfigStore(tmp_path)
|
||||||
|
assert store.memory_mode() == "pro"
|
||||||
|
assert store.memory_mode_explicitly_configured() is False
|
||||||
|
|
||||||
|
store.set_memory_mode("pro")
|
||||||
|
|
||||||
|
assert store.memory_mode_explicitly_configured() is True
|
||||||
|
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "pro"
|
||||||
|
assert ConfigStore(tmp_path).memory_mode_explicitly_configured() is True
|
||||||
|
|
||||||
|
|
||||||
def test_an_untouched_install_writes_no_new_settings(tmp_path) -> None:
|
def test_an_untouched_install_writes_no_new_settings(tmp_path) -> None:
|
||||||
store = ConfigStore(tmp_path)
|
store = ConfigStore(tmp_path)
|
||||||
store.set_sync("cursor", True)
|
store.set_sync("cursor", True)
|
||||||
@@ -158,6 +177,37 @@ def test_the_read_handle_rebuilds_on_a_mode_or_profile_change(monkeypatch, tmp_p
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_handle_can_bind_one_caller_mode_snapshot(monkeypatch, tmp_path) -> None:
|
||||||
|
from one_memory import sdk_read_handle
|
||||||
|
|
||||||
|
callback_reads = 0
|
||||||
|
|
||||||
|
def current_mode() -> str:
|
||||||
|
nonlocal callback_reads
|
||||||
|
callback_reads += 1
|
||||||
|
return "pro"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sdk_read_handle,
|
||||||
|
"SdkClientHandle",
|
||||||
|
lambda profile, **kwargs: _FakeHandle(kwargs.get("mode") or "pro", profile.profile_id),
|
||||||
|
)
|
||||||
|
reads = sdk_read_handle.ActiveProfileReadHandle(
|
||||||
|
profiles=_Profiles(identity=None), # type: ignore[arg-type]
|
||||||
|
secrets=None, # type: ignore[arg-type]
|
||||||
|
data_dir=tmp_path,
|
||||||
|
memory_mode=current_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
client = await reads.client_for_mode("lite")
|
||||||
|
assert client.mode == "lite"
|
||||||
|
await reads.close()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
assert callback_reads == 0
|
||||||
|
|
||||||
|
|
||||||
class _FakeHandle:
|
class _FakeHandle:
|
||||||
def __init__(self, mode: str, profile_id: str = "p") -> None:
|
def __init__(self, mode: str, profile_id: str = "p") -> None:
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
@@ -288,7 +338,7 @@ class _SdkHandle:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _noop() -> None:
|
async def _noop(_requires_generation: bool) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -311,14 +361,14 @@ def test_a_note_in_lite_is_stored_as_raw_and_never_calls_add_extracted() -> None
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_a_lite_write_runs_through_the_coordinator_without_a_model(monkeypatch) -> None:
|
def test_a_lite_write_ignores_the_model_sedimentation_pause(monkeypatch) -> None:
|
||||||
sdk = _WritingSdk()
|
sdk = _WritingSdk()
|
||||||
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
||||||
profiles=_Profiles(identity=None), # type: ignore[arg-type]
|
profiles=_Profiles(identity=None), # type: ignore[arg-type]
|
||||||
secrets=None, # type: ignore[arg-type]
|
secrets=None, # type: ignore[arg-type]
|
||||||
data_dir=None,
|
data_dir=None,
|
||||||
transport_factory=lambda _profile: None,
|
transport_factory=lambda _profile: None,
|
||||||
sedimentation_enabled=lambda: True,
|
sedimentation_enabled=lambda: False,
|
||||||
memory_mode=lambda: "lite",
|
memory_mode=lambda: "lite",
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -335,6 +385,37 @@ def test_a_lite_write_runs_through_the_coordinator_without_a_model(monkeypatch)
|
|||||||
assert [name for name, _ in sdk.calls] == ["add"]
|
assert [name for name, _ in sdk.calls] == ["add"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lite_write_ignores_the_model_sedimentation_switch(monkeypatch) -> None:
|
||||||
|
sdk = _WritingSdk()
|
||||||
|
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
||||||
|
profiles=_Profiles(identity=None), # type: ignore[arg-type]
|
||||||
|
secrets=None, # type: ignore[arg-type]
|
||||||
|
data_dir=None,
|
||||||
|
transport_factory=lambda _profile: None,
|
||||||
|
sedimentation_enabled=lambda: False,
|
||||||
|
memory_mode=lambda: "lite",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sdk_write_processor, "SdkClientHandle", lambda profile, **kwargs: _SdkHandle(sdk, "lite")
|
||||||
|
)
|
||||||
|
admitted = 0
|
||||||
|
|
||||||
|
async def admit(_requires_generation: bool) -> None:
|
||||||
|
nonlocal admitted
|
||||||
|
admitted += 1
|
||||||
|
|
||||||
|
receipt = asyncio.run(
|
||||||
|
coordinator.process_sdk_job(
|
||||||
|
(_record("still stored as raw", extracted=False),),
|
||||||
|
request_id="r-off-lite",
|
||||||
|
before_hy_invocation=admit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert receipt.raw_ids == ("raw-1",)
|
||||||
|
assert admitted == 1
|
||||||
|
assert [name for name, _ in sdk.calls] == ["add"]
|
||||||
|
|
||||||
|
|
||||||
def test_no_raw_is_ever_deleted_in_any_mode() -> None:
|
def test_no_raw_is_ever_deleted_in_any_mode() -> None:
|
||||||
"""Owner decision: MemHub keeps every raw; there is no retention window."""
|
"""Owner decision: MemHub keeps every raw; there is no retention window."""
|
||||||
from one_memory import sdk_memory_runtime
|
from one_memory import sdk_memory_runtime
|
||||||
@@ -624,6 +705,38 @@ def test_the_scheduler_runs_each_due_user_once_in_ultra_only(tmp_path, monkeypat
|
|||||||
assert state.user("wife").last_result["trigger"] == "scheduled"
|
assert state.user("wife").last_result["trigger"] == "scheduled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduled_evolve_obeys_the_shared_lifecycle_gate(tmp_path, monkeypatch) -> None:
|
||||||
|
sdk = _EvolveSdk(continuing=0)
|
||||||
|
state = EvolveStateStore(tmp_path)
|
||||||
|
state.anchor(_at(1, 12))
|
||||||
|
open_gate = [False]
|
||||||
|
service = EvolveService(
|
||||||
|
coordinator=_Coordinator(sdk, monkeypatch),
|
||||||
|
state=state,
|
||||||
|
memory_mode=lambda: "ultra",
|
||||||
|
now=lambda: _at(2, 3, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def users():
|
||||||
|
return ("local",)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
scheduler = EvolveScheduler(
|
||||||
|
service=service,
|
||||||
|
schedule=EvolveSchedule,
|
||||||
|
users_with_memories=users,
|
||||||
|
lifecycle_lock=asyncio.Lock(),
|
||||||
|
lifecycle_open=lambda: open_gate[0],
|
||||||
|
now=lambda: _at(2, 3, 2),
|
||||||
|
)
|
||||||
|
assert await scheduler.tick() == ()
|
||||||
|
assert sdk.calls == []
|
||||||
|
open_gate[0] = True
|
||||||
|
assert await scheduler.tick() == ("local",)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
|
def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
|
||||||
sdk = _EvolveSdk()
|
sdk = _EvolveSdk()
|
||||||
state = EvolveStateStore(tmp_path)
|
state = EvolveStateStore(tmp_path)
|
||||||
@@ -643,6 +756,63 @@ def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_interrupted_graph_backfill_resumes_only_unfinished_users(tmp_path, monkeypatch) -> None:
|
||||||
|
sdk = _EvolveSdk()
|
||||||
|
state = EvolveStateStore(tmp_path)
|
||||||
|
state.set_graph_build(
|
||||||
|
GraphBuildState(
|
||||||
|
state="running",
|
||||||
|
started_at=_at(1, 3),
|
||||||
|
users={
|
||||||
|
"local": GraphBuildUser(state="succeeded", memories_scanned=4, nodes=4),
|
||||||
|
"wife": GraphBuildUser(state="running"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
backfill = MemoryGraphBackfill(coordinator=_Coordinator(sdk, monkeypatch), state=state)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
resumed = backfill.recover(["local", "wife"])
|
||||||
|
assert resumed.state == "running"
|
||||||
|
assert resumed.users["local"].state == "succeeded"
|
||||||
|
assert resumed.users["wife"].state == "pending"
|
||||||
|
await backfill.wait()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
assert sdk.graph_builds == ["wife"]
|
||||||
|
assert state.graph_build().state == "succeeded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_backfill_waits_for_embedding_maintenance_to_finish(tmp_path, monkeypatch) -> None:
|
||||||
|
sdk = _EvolveSdk()
|
||||||
|
state = EvolveStateStore(tmp_path)
|
||||||
|
gate = [False]
|
||||||
|
checked = asyncio.Event()
|
||||||
|
|
||||||
|
def lifecycle_open() -> bool:
|
||||||
|
checked.set()
|
||||||
|
return gate[0]
|
||||||
|
|
||||||
|
backfill = MemoryGraphBackfill(
|
||||||
|
coordinator=_Coordinator(sdk, monkeypatch),
|
||||||
|
state=state,
|
||||||
|
lifecycle_open=lifecycle_open,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
backfill.start(["local"])
|
||||||
|
await checked.wait()
|
||||||
|
assert sdk.graph_builds == []
|
||||||
|
assert state.graph_build().state == "running"
|
||||||
|
assert state.graph_build().users["local"].state == "pending"
|
||||||
|
gate[0] = True
|
||||||
|
await backfill.wait()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
assert sdk.graph_builds == ["local"]
|
||||||
|
assert state.graph_build().state == "succeeded"
|
||||||
|
|
||||||
|
|
||||||
# --- the HTTP surface -----------------------------------------------------------
|
# --- the HTTP surface -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -701,23 +871,38 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
|
|||||||
async def scenario(app, sdk, client):
|
async def scenario(app, sdk, client):
|
||||||
current = (await client.get("/api/v1/settings/memory-mode")).json()
|
current = (await client.get("/api/v1/settings/memory-mode")).json()
|
||||||
assert current["mode"] == current["configured_mode"] == "pro"
|
assert current["mode"] == current["configured_mode"] == "pro"
|
||||||
|
assert current["explicitly_configured"] is False
|
||||||
assert current["forced_by_env"] is False
|
assert current["forced_by_env"] is False
|
||||||
assert app.state.sdk_write_coordinator.memory_mode() == "pro"
|
assert app.state.sdk_write_coordinator.memory_mode() == "pro"
|
||||||
|
assert (
|
||||||
|
await client.put("/api/v1/sedimentation", json={"enabled": False})
|
||||||
|
).status_code == 200
|
||||||
|
from one_memory.api import _sedimentation_claim_enabled
|
||||||
|
|
||||||
|
app.state.history_import_gate_released = True
|
||||||
|
assert not _sedimentation_claim_enabled(app, "one-memory-manual")
|
||||||
|
|
||||||
|
app.state.config_store.set_sedimentation_enabled(False)
|
||||||
lite = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
|
lite = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
|
||||||
assert lite.status_code == 200, lite.text
|
assert lite.status_code == 200, lite.text
|
||||||
body = lite.json()
|
body = lite.json()
|
||||||
assert body["mode"] == "lite" and body["previous_mode"] == "pro"
|
assert body["mode"] == "lite" and body["previous_mode"] == "pro"
|
||||||
|
assert body["explicitly_configured"] is True
|
||||||
assert body["effects"] == ["higher_mode_memories_hidden"]
|
assert body["effects"] == ["higher_mode_memories_hidden"]
|
||||||
|
resume = getattr(app.state, "sedimentation_resume", None)
|
||||||
|
assert resume is not None
|
||||||
|
await asyncio.wait_for(asyncio.shield(resume), 5)
|
||||||
assert app.state.sdk_write_coordinator.memory_mode() == "lite"
|
assert app.state.sdk_write_coordinator.memory_mode() == "lite"
|
||||||
assert _sdk_client_handle(app).memory_mode() == "lite"
|
assert _sdk_client_handle(app).memory_mode() == "lite"
|
||||||
# lite claims queued work without a generation model.
|
# lite claims queued work without a generation model.
|
||||||
from one_memory.api import _sedimentation_claim_enabled
|
|
||||||
|
|
||||||
assert app.state.model_profile_store.active_identity() is None
|
assert app.state.model_profile_store.active_identity() is None
|
||||||
app.state.history_import_gate_released = True
|
|
||||||
assert _sedimentation_claim_enabled(app, "one-memory-manual")
|
assert _sedimentation_claim_enabled(app, "one-memory-manual")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
app.state.model_profile_store,
|
||||||
|
"active_identity",
|
||||||
|
lambda: ("active-profile", 1),
|
||||||
|
)
|
||||||
ultra = await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
|
ultra = await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
|
||||||
assert ultra.status_code == 200, ultra.text
|
assert ultra.status_code == 200, ultra.text
|
||||||
assert "existing_memories_added_to_graph" in ultra.json()["effects"]
|
assert "existing_memories_added_to_graph" in ultra.json()["effects"]
|
||||||
@@ -741,6 +926,173 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
|
|||||||
run_app(tmp_path, monkeypatch, scenario)
|
run_app(tmp_path, monkeypatch, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fresh_sdk
|
||||||
|
def test_mode_plan_and_put_share_validation_and_effects(tmp_path, monkeypatch) -> None:
|
||||||
|
async def scenario(app, _sdk, client):
|
||||||
|
plan = await client.post(
|
||||||
|
"/api/v1/settings/memory-mode/plan",
|
||||||
|
json={"mode": "lite"},
|
||||||
|
)
|
||||||
|
assert plan.status_code == 200
|
||||||
|
body = plan.json()
|
||||||
|
assert body == {
|
||||||
|
"current": "pro",
|
||||||
|
"target": "lite",
|
||||||
|
"effective_target": "lite",
|
||||||
|
"capabilities": {
|
||||||
|
"content_plane": "raw",
|
||||||
|
"generation_required": False,
|
||||||
|
"graph_enabled": False,
|
||||||
|
"evolve_available": False,
|
||||||
|
},
|
||||||
|
"effects": ["higher_mode_memories_hidden"],
|
||||||
|
"warnings": [],
|
||||||
|
"blockers": [],
|
||||||
|
}
|
||||||
|
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
|
||||||
|
assert changed.status_code == 200
|
||||||
|
assert changed.json()["effects"] == body["effects"]
|
||||||
|
|
||||||
|
missing_model = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/settings/memory-mode/plan",
|
||||||
|
json={"mode": "pro"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
assert missing_model["blockers"] == ["active_model_required"]
|
||||||
|
assert missing_model["effects"] == []
|
||||||
|
refused = await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"})
|
||||||
|
assert refused.status_code == 409
|
||||||
|
assert refused.json()["detail"] == missing_model["blockers"][0]
|
||||||
|
|
||||||
|
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
|
||||||
|
forced = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/settings/memory-mode/plan",
|
||||||
|
json={"mode": "lite"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
assert forced["effective_target"] == "ultra"
|
||||||
|
assert forced["warnings"] == ["forced_by_env", "effective_no_change"]
|
||||||
|
|
||||||
|
run_app(tmp_path, monkeypatch, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fresh_sdk
|
||||||
|
def test_mode_plan_reports_all_blockers_without_planned_effects(tmp_path, monkeypatch) -> None:
|
||||||
|
async def scenario(app, _sdk, client):
|
||||||
|
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
|
||||||
|
assert changed.status_code == 200
|
||||||
|
switch = app.state.embedding_switch
|
||||||
|
switch._scan_hold = True
|
||||||
|
hold = asyncio.Event()
|
||||||
|
task = asyncio.create_task(hold.wait())
|
||||||
|
app.state.memory_graph_backfill._task = task
|
||||||
|
monkeypatch.setattr("one_memory.api.importlib.util.find_spec", lambda _name: None)
|
||||||
|
try:
|
||||||
|
plan = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/settings/memory-mode/plan",
|
||||||
|
json={"mode": "ultra"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
assert plan["blockers"] == [
|
||||||
|
"embedding_maintenance_in_progress",
|
||||||
|
"active_model_required",
|
||||||
|
"memory_lifecycle_busy",
|
||||||
|
"graph_store_unavailable",
|
||||||
|
]
|
||||||
|
assert plan["effects"] == []
|
||||||
|
finally:
|
||||||
|
switch._scan_hold = False
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
app.state.memory_graph_backfill._task = None
|
||||||
|
|
||||||
|
run_app(tmp_path, monkeypatch, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fresh_sdk
|
||||||
|
def test_lite_startup_readmits_jobs_while_model_sedimentation_is_off(tmp_path, monkeypatch) -> None:
|
||||||
|
store = ConfigStore(tmp_path / "data")
|
||||||
|
store.set_memory_mode("lite")
|
||||||
|
store.set_sedimentation_enabled(False)
|
||||||
|
resumed = asyncio.Event()
|
||||||
|
original = SdkMemoryRuntime.resume_allowed
|
||||||
|
|
||||||
|
async def resume_allowed(runtime):
|
||||||
|
await original(runtime)
|
||||||
|
resumed.set()
|
||||||
|
|
||||||
|
monkeypatch.setattr(SdkMemoryRuntime, "resume_allowed", resume_allowed)
|
||||||
|
|
||||||
|
async def scenario(app, _sdk, _client):
|
||||||
|
await asyncio.wait_for(resumed.wait(), 5)
|
||||||
|
assert app.state.config_store.sedimentation_enabled() is False
|
||||||
|
assert app.state.config_store.memory_mode() == "lite"
|
||||||
|
|
||||||
|
run_app(tmp_path, monkeypatch, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fresh_sdk
|
||||||
|
def test_embedding_maintenance_blocks_mode_put_and_evolve_but_not_mode_get(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
async def scenario(app, _sdk, client):
|
||||||
|
switch = app.state.embedding_switch
|
||||||
|
|
||||||
|
switch._scan_hold = True
|
||||||
|
current = await client.get("/api/v1/settings/memory-mode")
|
||||||
|
assert current.status_code == 200
|
||||||
|
assert current.json()["explicitly_configured"] is False
|
||||||
|
for response in (
|
||||||
|
await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"}),
|
||||||
|
await client.post("/api/v2/evolve", json={}),
|
||||||
|
):
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["detail"] == "embedding_maintenance_in_progress"
|
||||||
|
assert app.state.config_store.memory_mode_explicitly_configured() is False
|
||||||
|
|
||||||
|
switch._scan_hold = False
|
||||||
|
switch._open_cache = True
|
||||||
|
assert (await client.get("/api/v1/settings/memory-mode")).status_code == 200
|
||||||
|
for response in (
|
||||||
|
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
|
||||||
|
await client.post("/api/v2/evolve", json={}),
|
||||||
|
):
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["detail"] == "embedding_maintenance_in_progress"
|
||||||
|
|
||||||
|
switch._open_cache = False
|
||||||
|
explicitly_pro = await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"})
|
||||||
|
assert explicitly_pro.status_code == 200
|
||||||
|
assert explicitly_pro.json()["explicitly_configured"] is True
|
||||||
|
assert explicitly_pro.json()["effects"] == []
|
||||||
|
|
||||||
|
run_app(tmp_path, monkeypatch, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fresh_sdk
|
||||||
|
def test_graph_backfill_blocks_mode_change_and_manual_evolve(tmp_path, monkeypatch) -> None:
|
||||||
|
async def scenario(app, _sdk, client):
|
||||||
|
hold = asyncio.Event()
|
||||||
|
task = asyncio.create_task(hold.wait())
|
||||||
|
app.state.memory_graph_backfill._task = task
|
||||||
|
try:
|
||||||
|
for response in (
|
||||||
|
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
|
||||||
|
await client.post("/api/v2/evolve", json={}),
|
||||||
|
):
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["detail"] == "memory_lifecycle_busy"
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
app.state.memory_graph_backfill._task = None
|
||||||
|
|
||||||
|
run_app(tmp_path, monkeypatch, scenario)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.fresh_sdk
|
@pytest.mark.fresh_sdk
|
||||||
def test_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatch) -> None:
|
def test_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatch) -> None:
|
||||||
async def scenario(app, sdk, client):
|
async def scenario(app, sdk, client):
|
||||||
@@ -748,7 +1100,13 @@ def test_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatc
|
|||||||
assert refused.status_code == 409
|
assert refused.status_code == 409
|
||||||
assert refused.json()["detail"] == "memory_mode_not_ultra"
|
assert refused.json()["detail"] == "memory_mode_not_ultra"
|
||||||
|
|
||||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
|
monkeypatch.setattr(
|
||||||
|
app.state.model_profile_store,
|
||||||
|
"active_identity",
|
||||||
|
lambda: ("active-profile", 1),
|
||||||
|
)
|
||||||
|
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
|
||||||
|
assert changed.status_code == 200, changed.text
|
||||||
await app.state.memory_graph_backfill.wait()
|
await app.state.memory_graph_backfill.wait()
|
||||||
started = await client.post(
|
started = await client.post(
|
||||||
"/api/v2/evolve", json={"user_id": "wife", "agent_id": "all-apps", "max_batches": 2}
|
"/api/v2/evolve", json={"user_id": "wife", "agent_id": "all-apps", "max_batches": 2}
|
||||||
@@ -865,3 +1223,87 @@ def test_a_job_written_in_lite_stays_done_and_a_pending_job_takes_the_new_mode(
|
|||||||
await coordinator.close()
|
await coordinator.close()
|
||||||
|
|
||||||
run_app(tmp_path, monkeypatch, scenario)
|
run_app(tmp_path, monkeypatch, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fresh_sdk
|
||||||
|
def test_paused_model_still_writes_a_lite_job_through_the_durable_boundary(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
from one_memory.api import _admit_pipeline_invocation
|
||||||
|
from one_memory.memory_server.durable_queue import DurableWriteDrain, execute_job
|
||||||
|
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
class Sdk:
|
||||||
|
mode = "lite"
|
||||||
|
|
||||||
|
def add(self, messages, **kwargs):
|
||||||
|
seen.append("add")
|
||||||
|
return {"success": True, "raw_ids": ["raw-lite"]}
|
||||||
|
|
||||||
|
def add_extracted(self, contents, **kwargs):
|
||||||
|
seen.append("add_extracted")
|
||||||
|
raise AssertionError("lite must never call add_extracted")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sdk_write_processor,
|
||||||
|
"SdkClientHandle",
|
||||||
|
lambda profile, **kwargs: _SdkHandle(Sdk(), "lite"),
|
||||||
|
)
|
||||||
|
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
||||||
|
profiles=_Profiles(identity=None), # type: ignore[arg-type]
|
||||||
|
secrets=None, # type: ignore[arg-type]
|
||||||
|
data_dir=None,
|
||||||
|
transport_factory=lambda _profile: None,
|
||||||
|
sedimentation_enabled=lambda: False,
|
||||||
|
memory_mode=lambda: "lite",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def never(_job_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def scenario(app, _sdk, client):
|
||||||
|
app.state.memory_write_drain.stop()
|
||||||
|
if app.state.memory_write_drain_task is not None:
|
||||||
|
await app.state.memory_write_drain_task
|
||||||
|
app.state.memory_write_drain_task = None
|
||||||
|
await client.put("/api/v1/sedimentation", json={"enabled": False})
|
||||||
|
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
|
||||||
|
app.state.history_import_gate_released = True
|
||||||
|
written = await client.post(
|
||||||
|
"/api/v2/memory-writes",
|
||||||
|
json={
|
||||||
|
"request_id": "paused-lite-turn",
|
||||||
|
"session_id": "paused-lite-session",
|
||||||
|
"source_id": "one-memory-manual",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "保留原始会话"},
|
||||||
|
{"role": "assistant", "content": "已保留"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert written.status_code == 202
|
||||||
|
queue = app.state.memory_write_queue
|
||||||
|
drain = DurableWriteDrain(queue, execute=never, lease=timedelta(seconds=30))
|
||||||
|
[job_id] = drain.claim(limit=1)
|
||||||
|
await execute_job(
|
||||||
|
job_id,
|
||||||
|
queue=queue,
|
||||||
|
supervisor=coordinator,
|
||||||
|
evidence=None, # type: ignore[arg-type]
|
||||||
|
admit_hy_invocation=lambda source_id, requires_generation, mark: (
|
||||||
|
_admit_pipeline_invocation(
|
||||||
|
app,
|
||||||
|
source_id,
|
||||||
|
requires_generation,
|
||||||
|
mark,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
current = queue.get("paused-lite-turn")
|
||||||
|
assert current is not None and current.state == "completed"
|
||||||
|
assert current.sdk_raw_ids == ("raw-lite",)
|
||||||
|
assert seen == ["add"]
|
||||||
|
await coordinator.close()
|
||||||
|
|
||||||
|
run_app(tmp_path, monkeypatch, scenario)
|
||||||
@@ -832,7 +832,7 @@ class _Recorder:
|
|||||||
async def process_sdk_job(self, records, **_kwargs):
|
async def process_sdk_job(self, records, **_kwargs):
|
||||||
self.seen.append(tuple(record.evidence_id for record in records))
|
self.seen.append(tuple(record.evidence_id for record in records))
|
||||||
if self.invoke_hy:
|
if self.invoke_hy:
|
||||||
await _kwargs["before_hy_invocation"]()
|
await _kwargs["before_hy_invocation"](True)
|
||||||
if self.error is not None:
|
if self.error is not None:
|
||||||
raise self.error
|
raise self.error
|
||||||
return type("Receipt", (), {"raw_ids": (), "memory_ids": ()})()
|
return type("Receipt", (), {"raw_ids": (), "memory_ids": ()})()
|
||||||
@@ -844,10 +844,10 @@ class _TwoInvocationRecorder:
|
|||||||
self.invocations = 0
|
self.invocations = 0
|
||||||
|
|
||||||
async def process_sdk_job(self, _records, **kwargs):
|
async def process_sdk_job(self, _records, **kwargs):
|
||||||
await kwargs["before_hy_invocation"]()
|
await kwargs["before_hy_invocation"](True)
|
||||||
self.invocations += 1
|
self.invocations += 1
|
||||||
self.pause_after_first()
|
self.pause_after_first()
|
||||||
await kwargs["before_hy_invocation"]()
|
await kwargs["before_hy_invocation"](True)
|
||||||
self.invocations += 1
|
self.invocations += 1
|
||||||
return type("Receipt", (), {"raw_ids": (), "memory_ids": ()})()
|
return type("Receipt", (), {"raw_ids": (), "memory_ids": ()})()
|
||||||
|
|
||||||
@@ -916,7 +916,7 @@ def test_execute_job_persists_sdk_ids_returned_after_the_single_add(tmp_path) ->
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def process_sdk_job(cls, _records, **kwargs):
|
async def process_sdk_job(cls, _records, **kwargs):
|
||||||
cls.calls += 1
|
cls.calls += 1
|
||||||
await kwargs["before_hy_invocation"]()
|
await kwargs["before_hy_invocation"](True)
|
||||||
return type(
|
return type(
|
||||||
"Result",
|
"Result",
|
||||||
(),
|
(),
|
||||||
@@ -1045,7 +1045,7 @@ def test_sdk_write_processor_calls_add_once_with_public_scope_and_receipt() -> N
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def run() -> SdkWriteReceipt:
|
async def run() -> SdkWriteReceipt:
|
||||||
async def before() -> None:
|
async def before(_requires_generation: bool) -> None:
|
||||||
client.events.append("before")
|
client.events.append("before")
|
||||||
|
|
||||||
return await processor.process_sdk_job(
|
return await processor.process_sdk_job(
|
||||||
@@ -1105,7 +1105,7 @@ def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_additions
|
|||||||
processor = SdkWriteProcessor(_SdkPublicHandle(client)) # type: ignore[arg-type]
|
processor = SdkWriteProcessor(_SdkPublicHandle(client)) # type: ignore[arg-type]
|
||||||
|
|
||||||
async def run() -> SdkWriteReceipt:
|
async def run() -> SdkWriteReceipt:
|
||||||
async def before() -> None:
|
async def before(_requires_generation: bool) -> None:
|
||||||
client.events.append("before")
|
client.events.append("before")
|
||||||
|
|
||||||
return await processor.process_sdk_job(
|
return await processor.process_sdk_job(
|
||||||
@@ -1260,7 +1260,7 @@ def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeyp
|
|||||||
|
|
||||||
async def process(self, records, *, request_id, before_hy_invocation):
|
async def process(self, records, *, request_id, before_hy_invocation):
|
||||||
client = await self._client_handle.client()
|
client = await self._client_handle.client()
|
||||||
await before_hy_invocation()
|
await before_hy_invocation(getattr(client, "mode", None) != "lite")
|
||||||
seen.append((client.profile_id, client.closed))
|
seen.append((client.profile_id, client.closed))
|
||||||
if client.profile_id == "profile-a":
|
if client.profile_id == "profile-a":
|
||||||
await writing.wait()
|
await writing.wait()
|
||||||
@@ -1270,7 +1270,7 @@ def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeyp
|
|||||||
|
|
||||||
monkeypatch.setattr(sdk_write_processor.SdkWriteProcessor, "process_sdk_job", process)
|
monkeypatch.setattr(sdk_write_processor.SdkWriteProcessor, "process_sdk_job", process)
|
||||||
|
|
||||||
async def noop():
|
async def noop(_requires_generation: bool):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def scenario():
|
async def scenario():
|
||||||
@@ -1433,25 +1433,6 @@ def test_a_claimed_row_with_no_readable_payload_is_settled_not_left_running(tmp_
|
|||||||
assert _rows(path)[0]["lease_owner"] is None
|
assert _rows(path)[0]["lease_owner"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_an_unknown_outcome_is_recorded_as_unknown_not_retried(tmp_path) -> None:
|
|
||||||
path = tmp_path / "one-memory.sqlite3"
|
|
||||||
queue = DurableWriteQueue(path)
|
|
||||||
record = queue.submit(_request())
|
|
||||||
supervisor = _Recorder(error=RuntimeError("model_content_outcome_unknown"))
|
|
||||||
_claim_through_drain(queue, record.job_id)
|
|
||||||
|
|
||||||
asyncio.run(
|
|
||||||
execute_job(
|
|
||||||
record.job_id,
|
|
||||||
queue=queue,
|
|
||||||
supervisor=supervisor,
|
|
||||||
evidence=_EvidenceStore({"e1"}),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert _state(path, record.job_id) == "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unpermitted_evidence_parks_and_is_readmitted_after_confirmation(tmp_path) -> None:
|
def test_unpermitted_evidence_parks_and_is_readmitted_after_confirmation(tmp_path) -> None:
|
||||||
"""A note claimed before its confirmation must not become terminal failed."""
|
"""A note claimed before its confirmation must not become terminal failed."""
|
||||||
path = tmp_path / "one-memory.sqlite3"
|
path = tmp_path / "one-memory.sqlite3"
|
||||||
@@ -1478,34 +1459,6 @@ def test_unpermitted_evidence_parks_and_is_readmitted_after_confirmation(tmp_pat
|
|||||||
assert replay.state == PENDING
|
assert replay.state == PENDING
|
||||||
|
|
||||||
|
|
||||||
def test_a_permission_error_after_hy_invocation_stays_unknown(tmp_path) -> None:
|
|
||||||
"""An error code cannot erase the durable possibly-sent boundary."""
|
|
||||||
path = tmp_path / "one-memory.sqlite3"
|
|
||||||
queue = DurableWriteQueue(path)
|
|
||||||
record = queue.submit(_request())
|
|
||||||
_claim_through_drain(queue, record.job_id)
|
|
||||||
|
|
||||||
asyncio.run(
|
|
||||||
execute_job(
|
|
||||||
record.job_id,
|
|
||||||
queue=queue,
|
|
||||||
supervisor=_Recorder(
|
|
||||||
error=RuntimeError("model_content_evidence_changed"),
|
|
||||||
invoke_hy=True,
|
|
||||||
),
|
|
||||||
evidence=_EvidenceStore({"e1"}),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert queue.get(record.request_id).state == UNKNOWN
|
|
||||||
with sqlite3.connect(path) as connection:
|
|
||||||
assert connection.execute(
|
|
||||||
"SELECT hy_invoked,last_error FROM memory_write_jobs WHERE job_id=?",
|
|
||||||
(record.job_id,),
|
|
||||||
).fetchone() == (1, "model_content_evidence_changed")
|
|
||||||
assert queue.reclaim_expired() == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_replayed_live_evidence_readmits_a_pre_send_identity_failure(tmp_path) -> None:
|
def test_replayed_live_evidence_readmits_a_pre_send_identity_failure(tmp_path) -> None:
|
||||||
path = tmp_path / "one-memory.sqlite3"
|
path = tmp_path / "one-memory.sqlite3"
|
||||||
queue = DurableWriteQueue(path)
|
queue = DurableWriteQueue(path)
|
||||||
@@ -1623,90 +1576,6 @@ def test_startup_never_repairs_a_possibly_sent_identity_failure(tmp_path) -> Non
|
|||||||
assert queue.get(job.request_id).state == UNKNOWN
|
assert queue.get(job.request_id).state == UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
def test_reconciled_absent_timeout_is_requeued_once_without_resetting_attempts(
|
|
||||||
tmp_path,
|
|
||||||
) -> None:
|
|
||||||
path = tmp_path / "one-memory.sqlite3"
|
|
||||||
queue = DurableWriteQueue(path)
|
|
||||||
record = queue.submit(_request())
|
|
||||||
with sqlite3.connect(path) as connection, connection:
|
|
||||||
connection.execute(
|
|
||||||
"UPDATE memory_write_jobs SET state='unknown', attempts=1, "
|
|
||||||
"hy_invoked=1, last_error='hy_memory_timeout', updated_at=? WHERE job_id=?",
|
|
||||||
("2026-01-01T00:00:00+00:00", record.job_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
queue.retry_timed_out_unknown_after_reconcile(
|
|
||||||
older_than=datetime(2026, 1, 1, 0, 5, tzinfo=UTC)
|
|
||||||
)
|
|
||||||
== 1
|
|
||||||
)
|
|
||||||
with sqlite3.connect(path) as connection:
|
|
||||||
state, attempts, invoked, error = connection.execute(
|
|
||||||
"SELECT state, attempts, hy_invoked, last_error FROM memory_write_jobs WHERE job_id=?",
|
|
||||||
(record.job_id,),
|
|
||||||
).fetchone()
|
|
||||||
assert (state, attempts, invoked, error) == (
|
|
||||||
"pending",
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
"model_content_reconciled_absent_retry",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_unresolved_unknown_is_automatically_retried_only_once(tmp_path) -> None:
|
|
||||||
path = tmp_path / "one-memory.sqlite3"
|
|
||||||
queue = DurableWriteQueue(path)
|
|
||||||
record = queue.submit(_request())
|
|
||||||
with sqlite3.connect(path) as connection, connection:
|
|
||||||
connection.execute(
|
|
||||||
"UPDATE memory_write_jobs SET state='unknown',attempts=1,hy_invoked=1,"
|
|
||||||
"last_error='model_content_outcome_unknown',updated_at=? WHERE job_id=?",
|
|
||||||
("2026-01-01T00:00:00+00:00", record.job_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
cutoff = datetime(2026, 1, 1, 0, 5, tzinfo=UTC)
|
|
||||||
assert queue.retry_first_unknown_after_reconcile(older_than=cutoff) == 1
|
|
||||||
first_retry = queue.get(record.request_id)
|
|
||||||
assert first_retry.state == PENDING
|
|
||||||
with sqlite3.connect(path) as connection:
|
|
||||||
assert (
|
|
||||||
connection.execute(
|
|
||||||
"SELECT attempts FROM memory_write_jobs WHERE job_id=?",
|
|
||||||
(record.job_id,),
|
|
||||||
).fetchone()[0]
|
|
||||||
== 1
|
|
||||||
)
|
|
||||||
|
|
||||||
drain = DurableWriteDrain(queue, execute=_never_execute, owner="retry-owner")
|
|
||||||
assert drain.claim(limit=1) == (record.job_id,)
|
|
||||||
assert queue.mark_invoked(record.job_id, owner="retry-owner")
|
|
||||||
queue.settle(
|
|
||||||
record.job_id,
|
|
||||||
state=UNKNOWN,
|
|
||||||
owner="retry-owner",
|
|
||||||
error_code="model_content_outcome_unknown",
|
|
||||||
hy_invoked=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
with sqlite3.connect(path) as connection:
|
|
||||||
assert (
|
|
||||||
connection.execute(
|
|
||||||
"SELECT attempts FROM memory_write_jobs WHERE job_id=?",
|
|
||||||
(record.job_id,),
|
|
||||||
).fetchone()[0]
|
|
||||||
== 2
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
queue.retry_first_unknown_after_reconcile(
|
|
||||||
older_than=datetime.now(UTC) + timedelta(minutes=1)
|
|
||||||
)
|
|
||||||
== 0
|
|
||||||
)
|
|
||||||
assert queue.get(record.request_id).state == UNKNOWN
|
|
||||||
|
|
||||||
|
|
||||||
def test_drain_runs_unknown_reconciliation_during_busy_safe_maintenance(tmp_path) -> None:
|
def test_drain_runs_unknown_reconciliation_during_busy_safe_maintenance(tmp_path) -> None:
|
||||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||||
calls = 0
|
calls = 0
|
||||||
@@ -1759,26 +1628,6 @@ def test_a_reclaimed_uninvoked_job_with_attempts_left_is_retryable(tmp_path) ->
|
|||||||
assert _rows(path)[0]["attempts"] == 0
|
assert _rows(path)[0]["attempts"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_post_invocation_transport_failure_stays_unknown_not_pending(tmp_path) -> None:
|
|
||||||
"""Once the bridge may have been opened, no error is safe to replay."""
|
|
||||||
path = tmp_path / "one-memory.sqlite3"
|
|
||||||
queue = DurableWriteQueue(path)
|
|
||||||
record = queue.submit(_request())
|
|
||||||
_claim_through_drain(queue, record.job_id)
|
|
||||||
|
|
||||||
asyncio.run(
|
|
||||||
execute_job(
|
|
||||||
record.job_id,
|
|
||||||
queue=queue,
|
|
||||||
supervisor=_Recorder(error=RuntimeError("hy_memory_timeout")),
|
|
||||||
evidence=_EvidenceStore({"e1"}),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert _state(path, record.job_id) == UNKNOWN
|
|
||||||
assert _rows(path)[0]["hy_invoked"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"code,expected",
|
"code,expected",
|
||||||
[
|
[
|
||||||
@@ -1879,7 +1728,7 @@ def test_a_failed_job_logs_where_it_failed_but_never_the_message(tmp_path, caplo
|
|||||||
|
|
||||||
class _Failing:
|
class _Failing:
|
||||||
async def process_sdk_job(self, _records, **kwargs):
|
async def process_sdk_job(self, _records, **kwargs):
|
||||||
await kwargs["before_hy_invocation"]()
|
await kwargs["before_hy_invocation"](True)
|
||||||
try:
|
try:
|
||||||
closed_store(type("Client", (), {"private_store_name": None})())
|
closed_store(type("Client", (), {"private_store_name": None})())
|
||||||
except AttributeError as error:
|
except AttributeError as error:
|
||||||
@@ -1900,7 +1749,9 @@ def test_a_failed_job_logs_where_it_failed_but_never_the_message(tmp_path, caplo
|
|||||||
for message in caplog.records
|
for message in caplog.records
|
||||||
if "durable_write_job_failed" in message.getMessage()
|
if "durable_write_job_failed" in message.getMessage()
|
||||||
]
|
]
|
||||||
assert f"job_id={record.job_id} error_code=RuntimeError chain=RuntimeError@" in line
|
assert (
|
||||||
|
f"job_id={record.job_id} error_code=RuntimeError state=pending chain=RuntimeError@" in line
|
||||||
|
)
|
||||||
assert ";AttributeError@" in line
|
assert ";AttributeError@" in line
|
||||||
assert "test_memory_server_durable_queue.py:" in line and ":closed_store" in line
|
assert "test_memory_server_durable_queue.py:" in line and ":closed_store" in line
|
||||||
assert "secret diary" not in line
|
assert "secret diary" not in line
|
||||||
@@ -2620,57 +2471,6 @@ def test_a_wedged_job_hits_the_work_ceiling_and_settles_unknown(tmp_path) -> Non
|
|||||||
assert (state, owner, expiry) == ("unknown", None, None)
|
assert (state, owner, expiry) == ("unknown", None, None)
|
||||||
|
|
||||||
|
|
||||||
def test_user_confirmed_unknown_retry_requeues_job_and_linked_tasks(tmp_path) -> None:
|
|
||||||
path = tmp_path / "one-memory.sqlite3"
|
|
||||||
queue = DurableWriteQueue(path)
|
|
||||||
record = queue.submit(_request(evidence=("e1",)))
|
|
||||||
with sqlite3.connect(path) as connection, connection:
|
|
||||||
connection.execute(
|
|
||||||
"UPDATE memory_write_jobs SET state='unknown', attempts=2, hy_invoked=1, "
|
|
||||||
"last_error='hy_memory_timeout' WHERE job_id=?",
|
|
||||||
(record.job_id,),
|
|
||||||
)
|
|
||||||
connection.executescript(
|
|
||||||
"""
|
|
||||||
CREATE TABLE memory_tasks (
|
|
||||||
task_id TEXT PRIMARY KEY,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
runner_token TEXT,
|
|
||||||
attempts INTEGER NOT NULL,
|
|
||||||
last_error_code TEXT,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE memory_task_inputs (
|
|
||||||
task_id TEXT PRIMARY KEY,
|
|
||||||
refs_json TEXT NOT NULL
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO memory_tasks VALUES (?,?,?,?,?,?)",
|
|
||||||
("task-1", "unknown", None, 2, "model_content_outcome_unknown", "old"),
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO memory_task_inputs VALUES (?,?)",
|
|
||||||
("task-1", '[{"evidence_id":"e1"}]'),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert queue.retry_unknown_confirmed(record.job_id) == 1
|
|
||||||
retried = queue.get(record.request_id)
|
|
||||||
assert retried is not None
|
|
||||||
assert retried.state == PENDING
|
|
||||||
with sqlite3.connect(path) as connection:
|
|
||||||
assert connection.execute(
|
|
||||||
"SELECT attempts, hy_invoked FROM memory_write_jobs WHERE job_id=?",
|
|
||||||
(record.job_id,),
|
|
||||||
).fetchone() == (0, 0)
|
|
||||||
assert connection.execute(
|
|
||||||
"SELECT status, attempts, last_error_code FROM memory_tasks WHERE task_id='task-1'"
|
|
||||||
).fetchone() == ("pending", 0, "model_content_manual_retry_requested")
|
|
||||||
|
|
||||||
assert queue.retry_unknown_confirmed(record.job_id) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_restart_redrains_a_job_claimed_by_a_worker_that_died(tmp_path) -> None:
|
def test_restart_redrains_a_job_claimed_by_a_worker_that_died(tmp_path) -> None:
|
||||||
"""End-to-end restart recovery: a claimed-but-not-run job runs once.
|
"""End-to-end restart recovery: a claimed-but-not-run job runs once.
|
||||||
|
|
||||||
@@ -2922,30 +2722,6 @@ def test_forever_drain_admits_a_new_source_while_an_earlier_source_is_running(tm
|
|||||||
assert queue.get("workbuddy-1").state == COMPLETED
|
assert queue.get("workbuddy-1").state == COMPLETED
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_drain_preserves_unknown_after_execute_job(tmp_path) -> None:
|
|
||||||
"""A drain failure after invocation must remain unknown, not look retryable."""
|
|
||||||
path = tmp_path / "one-memory.sqlite3"
|
|
||||||
queue = DurableWriteQueue(path)
|
|
||||||
queue.submit(_request(evidence=("e1",)))
|
|
||||||
|
|
||||||
async def execute(job_id: str) -> None:
|
|
||||||
await execute_job(
|
|
||||||
job_id,
|
|
||||||
queue=queue,
|
|
||||||
supervisor=_Recorder(error=RuntimeError("model_content_outcome_unknown")),
|
|
||||||
evidence=_EvidenceStore({"e1"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def scenario() -> None:
|
|
||||||
drain = DurableWriteDrain(queue, execute=execute)
|
|
||||||
assert await drain.run_one() == 1
|
|
||||||
|
|
||||||
asyncio.run(scenario())
|
|
||||||
assert queue.get("idem-1").state == "unknown"
|
|
||||||
assert queue.reclaim_expired() == 0
|
|
||||||
assert queue.get("idem-1").state == "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_drain_keeps_running_after_an_executor_raises(tmp_path) -> None:
|
def test_sqlite_drain_keeps_running_after_an_executor_raises(tmp_path) -> None:
|
||||||
"""One executor exception must not kill the drain or strand other rows.
|
"""One executor exception must not kill the drain or strand other rows.
|
||||||
|
|
||||||
@@ -4222,27 +3998,6 @@ def test_set_state_rejects_an_unvalidated_state(tmp_path) -> None:
|
|||||||
assert registry.get(record.request_id).state == "accepted"
|
assert registry.get(record.request_id).state == "accepted"
|
||||||
|
|
||||||
|
|
||||||
def test_set_state_cannot_resolve_unknown_without_an_inquiry(tmp_path) -> None:
|
|
||||||
"""The HTTP-facing setter must not bypass the inquiry-only exit.
|
|
||||||
|
|
||||||
`DurableWriteQueue.settle` deliberately treats `unknown` as sticky; a
|
|
||||||
direct `set_state("completed"/"failed")` would otherwise turn a
|
|
||||||
possibly-delivered write into a terminal claim without asking HY. The
|
|
||||||
durable registry is the shared caller surface, so it must preserve that
|
|
||||||
boundary even though today only tests call it.
|
|
||||||
"""
|
|
||||||
from one_memory.memory_server.durable_queue import DurableWriteRegistry
|
|
||||||
|
|
||||||
path = tmp_path / "one-memory.sqlite3"
|
|
||||||
registry = DurableWriteRegistry(path)
|
|
||||||
durable = registry.queue.submit(_request())
|
|
||||||
registry.queue.settle(durable.job_id, state=UNKNOWN, hy_invoked=True)
|
|
||||||
|
|
||||||
assert registry.set_state(durable.request_id, "completed") is None
|
|
||||||
assert registry.set_state(durable.request_id, "failed") is None
|
|
||||||
assert registry.get(durable.request_id).state == UNKNOWN
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("state", (WAITING_FOR_PERMISSION, WAITING_FOR_MODEL))
|
@pytest.mark.parametrize("state", (WAITING_FOR_PERMISSION, WAITING_FOR_MODEL))
|
||||||
def test_set_state_accepts_the_route_parked_states(tmp_path, state: str) -> None:
|
def test_set_state_accepts_the_route_parked_states(tmp_path, state: str) -> None:
|
||||||
"""The durable setter must accept every non-terminal route state.
|
"""The durable setter must accept every non-terminal route state.
|
||||||
@@ -4383,6 +4138,71 @@ def test_a_failing_renewal_does_not_kill_the_drain(tmp_path) -> None:
|
|||||||
assert queue.get("idem-1").state == COMPLETED
|
assert queue.get("idem-1").state == COMPLETED
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_locked_fallback_settlement_does_not_kill_the_drain(tmp_path, monkeypatch) -> None:
|
||||||
|
"""The incident shape: executor and its fallback both lose the writer.
|
||||||
|
|
||||||
|
The first row keeps its lease for normal reclaim; an unrelated row must
|
||||||
|
still run instead of being stranded behind a dead drain task.
|
||||||
|
"""
|
||||||
|
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||||
|
first = queue.submit(_request(key="first", source="cursor"))
|
||||||
|
second = queue.submit(_request(key="second", source="codex"))
|
||||||
|
executed: list[str] = []
|
||||||
|
|
||||||
|
async def execute(job_id: str) -> None:
|
||||||
|
executed.append(job_id)
|
||||||
|
if job_id == first.job_id:
|
||||||
|
raise RuntimeError("executor failed")
|
||||||
|
queue.settle(job_id, state=COMPLETED)
|
||||||
|
drain.stop()
|
||||||
|
|
||||||
|
drain = DurableWriteDrain(queue, execute=execute, max_concurrent_sources=1)
|
||||||
|
|
||||||
|
def locked_settlement(*_args, **_kwargs):
|
||||||
|
raise sqlite3.OperationalError("database is locked")
|
||||||
|
|
||||||
|
monkeypatch.setattr(queue, "settle_worker_error", locked_settlement)
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
|
||||||
|
|
||||||
|
assert executed == [first.job_id, second.job_id]
|
||||||
|
assert queue.get("first").state == RUNNING
|
||||||
|
assert queue.get("second").state == COMPLETED
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unexpected_job_task_failure_is_contained_by_the_drain(
|
||||||
|
tmp_path, monkeypatch, caplog
|
||||||
|
) -> None:
|
||||||
|
"""A programming error stays visible without taking down the consumer."""
|
||||||
|
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||||
|
first = queue.submit(_request(key="first", source="cursor"))
|
||||||
|
second = queue.submit(_request(key="second", source="codex"))
|
||||||
|
executed: list[str] = []
|
||||||
|
|
||||||
|
async def run_job(job_id: str) -> None:
|
||||||
|
executed.append(job_id)
|
||||||
|
if job_id == first.job_id:
|
||||||
|
raise RuntimeError("unexpected job task failure")
|
||||||
|
drain.stop()
|
||||||
|
|
||||||
|
drain = DurableWriteDrain(
|
||||||
|
queue,
|
||||||
|
execute=_never_execute,
|
||||||
|
max_concurrent_sources=1,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(drain, "_run_claimed_job", run_job)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR, logger="one_memory.memory_server.durable_queue"):
|
||||||
|
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
|
||||||
|
|
||||||
|
assert executed == [first.job_id, second.job_id]
|
||||||
|
assert any(
|
||||||
|
"durable_write_drain_job_task_failed" in message.getMessage()
|
||||||
|
and "error_type=RuntimeError" in message.getMessage()
|
||||||
|
for message in caplog.records
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("terminal", [COMPLETED, FAILED, DEAD])
|
@pytest.mark.parametrize("terminal", [COMPLETED, FAILED, DEAD])
|
||||||
def test_a_terminal_row_is_sticky_against_a_late_settle(tmp_path, terminal: str) -> None:
|
def test_a_terminal_row_is_sticky_against_a_late_settle(tmp_path, terminal: str) -> None:
|
||||||
"""A late settle must not overwrite an outcome that already happened.
|
"""A late settle must not overwrite an outcome that already happened.
|
||||||
@@ -5003,7 +4823,7 @@ def test_sedimentation_pause_releases_claim_without_spending_an_attempt(tmp_path
|
|||||||
drain = DurableWriteDrain(queue, execute=_never_execute, owner="pause-owner")
|
drain = DurableWriteDrain(queue, execute=_never_execute, owner="pause-owner")
|
||||||
assert drain.claim(limit=1) == (record.job_id,)
|
assert drain.claim(limit=1) == (record.job_id,)
|
||||||
|
|
||||||
async def refuse_before_hy(_source_id, _mark_invoked):
|
async def refuse_before_hy(_source_id, _requires_generation, _mark_invoked):
|
||||||
raise RuntimeError("model_sedimentation_disabled")
|
raise RuntimeError("model_sedimentation_disabled")
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
@@ -5225,6 +5045,27 @@ def test_permanent_renewal_error_stops_the_worker(tmp_path, monkeypatch) -> None
|
|||||||
asyncio.run(drain.run_one())
|
asyncio.run(drain.run_one())
|
||||||
|
|
||||||
|
|
||||||
|
def test_permanent_renewal_error_stops_the_long_lived_drain(tmp_path, monkeypatch) -> None:
|
||||||
|
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||||
|
queue.submit(_request())
|
||||||
|
drain = DurableWriteDrain(
|
||||||
|
queue,
|
||||||
|
execute=lambda _job_id: asyncio.sleep(0.03),
|
||||||
|
lease=timedelta(seconds=1),
|
||||||
|
renew_every=timedelta(milliseconds=10),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
drain,
|
||||||
|
"renew",
|
||||||
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||||
|
sqlite3.OperationalError("database schema is corrupt")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
|
||||||
|
|
||||||
|
|
||||||
def test_lost_renewal_waits_for_execution_before_releasing_source(tmp_path, monkeypatch) -> None:
|
def test_lost_renewal_waits_for_execution_before_releasing_source(tmp_path, monkeypatch) -> None:
|
||||||
path = tmp_path / "one-memory.sqlite3"
|
path = tmp_path / "one-memory.sqlite3"
|
||||||
queue = DurableWriteQueue(path)
|
queue = DurableWriteQueue(path)
|
||||||
@@ -5435,83 +5276,6 @@ def test_readmit_paths_never_requeue_a_possibly_sent_row(tmp_path) -> None:
|
|||||||
assert DurableWriteDrain(queue, execute=_never_execute).claim(limit=5) == ()
|
assert DurableWriteDrain(queue, execute=_never_execute).claim(limit=5) == ()
|
||||||
|
|
||||||
|
|
||||||
def test_a_refused_source_context_is_nonblocking_unknown(
|
|
||||||
tmp_path,
|
|
||||||
) -> None:
|
|
||||||
"""An ambiguous call-boundary failure is retained but never blocks FIFO."""
|
|
||||||
path = tmp_path / "one-memory.sqlite3"
|
|
||||||
queue = DurableWriteQueue(path)
|
|
||||||
record = queue.submit(_request(key="refused-scope", source="opencode"))
|
|
||||||
claimer = DurableWriteDrain(queue, execute=_never_execute, owner="refused-owner")
|
|
||||||
assert claimer.claim(limit=1) == (record.job_id,)
|
|
||||||
|
|
||||||
asyncio.run(
|
|
||||||
execute_job(
|
|
||||||
record.job_id,
|
|
||||||
queue=queue,
|
|
||||||
supervisor=_Recorder(
|
|
||||||
error=RuntimeError("hy_memory_write_verification_context_scope"),
|
|
||||||
invoke_hy=True,
|
|
||||||
),
|
|
||||||
evidence=_EvidenceStore({"e1"}),
|
|
||||||
owner="refused-owner",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with sqlite3.connect(path.as_uri() + "?mode=ro", uri=True) as db:
|
|
||||||
state, invoked, last_error = db.execute(
|
|
||||||
"SELECT state, hy_invoked, last_error FROM memory_write_jobs WHERE job_id=?",
|
|
||||||
(record.job_id,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
assert state == UNKNOWN
|
|
||||||
assert invoked == 1
|
|
||||||
assert last_error == "hy_memory_write_verification_context_scope"
|
|
||||||
assert DurableWriteDrain(queue, execute=_never_execute).claim(limit=1) == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_failure_after_the_call_still_parks_as_unknown(tmp_path) -> None:
|
|
||||||
"""The fix must not widen: a post-call refusal is still possibly-sent."""
|
|
||||||
path = tmp_path / "one-memory.sqlite3"
|
|
||||||
queue = DurableWriteQueue(path)
|
|
||||||
record = queue.submit(_request(key="after-call", source="opencode"))
|
|
||||||
claimer = DurableWriteDrain(queue, execute=_never_execute, owner="after-call-owner")
|
|
||||||
assert claimer.claim(limit=1) == (record.job_id,)
|
|
||||||
recorder = _Recorder(
|
|
||||||
error=RuntimeError("hy_memory_write_verification_authority_invalid"),
|
|
||||||
invoke_hy=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.run(
|
|
||||||
execute_job(
|
|
||||||
record.job_id,
|
|
||||||
queue=queue,
|
|
||||||
supervisor=recorder,
|
|
||||||
evidence=_EvidenceStore({"e1"}),
|
|
||||||
owner="after-call-owner",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with sqlite3.connect(path.as_uri() + "?mode=ro", uri=True) as db:
|
|
||||||
state, invoked = db.execute(
|
|
||||||
"SELECT state, hy_invoked FROM memory_write_jobs WHERE job_id=?",
|
|
||||||
(record.job_id,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
assert state == UNKNOWN
|
|
||||||
assert invoked == 1
|
|
||||||
assert len(recorder.seen) == 1
|
|
||||||
assert (
|
|
||||||
queue.retry_first_unknown_after_reconcile(
|
|
||||||
older_than=datetime.now(UTC) + timedelta(minutes=1)
|
|
||||||
)
|
|
||||||
== 0
|
|
||||||
)
|
|
||||||
assert queue.retry_unknown_confirmed(record.job_id) == 0
|
|
||||||
assert DurableWriteDrain(queue, execute=_never_execute).claim(limit=1) == ()
|
|
||||||
assert len(recorder.seen) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_event_time_not_enqueue_order_drives_the_window(
|
def test_event_time_not_enqueue_order_drives_the_window(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -5674,3 +5438,280 @@ def test_hand_added_memories_still_run_one_at_a_time_in_order(tmp_path) -> None:
|
|||||||
assert drain.claim(limit=5) == ()
|
assert drain.claim(limit=5) == ()
|
||||||
assert queue.get("note-2").state == PENDING
|
assert queue.get("note-2").state == PENDING
|
||||||
assert second.job_id
|
assert second.job_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_job_the_execute_gate_releases_is_not_reclaimed_while_it_still_refuses(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""The claim gate and the execute gate can disagree for good (#245).
|
||||||
|
|
||||||
|
The claim asks about the lane's user, the execute gate about the user of
|
||||||
|
the job's Evidence. An id-only pre-upgrade payload sits in the `local`
|
||||||
|
lane even when its Evidence belongs to someone else: while that user
|
||||||
|
evolves, the claim admits the job and the execute gate hands it back.
|
||||||
|
Released with no hold, the very next tick claimed it again, in a loop.
|
||||||
|
"""
|
||||||
|
path = tmp_path / "one-memory.sqlite3"
|
||||||
|
queue = DurableWriteQueue(path)
|
||||||
|
record = queue.submit(_request(key="evolving-user", source="cursor"))
|
||||||
|
drain = DurableWriteDrain(queue, execute=_never_execute, owner="gate-owner")
|
||||||
|
assert drain.claim(limit=1) == (record.job_id,)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
execute_job(
|
||||||
|
record.job_id,
|
||||||
|
queue=queue,
|
||||||
|
supervisor=_Recorder(error=RuntimeError("memory_evolve_in_progress")),
|
||||||
|
evidence=_EvidenceStore({"e1"}),
|
||||||
|
owner="gate-owner",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert queue.get(record.request_id).state == PENDING
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
assert drain.claim(limit=1, now=now) == ()
|
||||||
|
assert drain.claim(limit=1, now=now + timedelta(seconds=4)) == ()
|
||||||
|
assert drain.claim(limit=1, now=now + timedelta(seconds=6)) == (record.job_id,)
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
assert connection.execute(
|
||||||
|
"SELECT attempts,hy_invoked FROM memory_write_jobs WHERE job_id=?",
|
||||||
|
(record.job_id,),
|
||||||
|
).fetchone() == (1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _suspension_runtime(queue: DurableWriteQueue, enabled: set[str]):
|
||||||
|
from one_memory.sdk_memory_runtime import SdkMemoryRuntime
|
||||||
|
|
||||||
|
return SdkMemoryRuntime(
|
||||||
|
coordinator=SimpleNamespace(),
|
||||||
|
read_handle=SimpleNamespace(), # type: ignore[arg-type]
|
||||||
|
write_queue=queue,
|
||||||
|
extraction_settings=SimpleNamespace(), # type: ignore[arg-type]
|
||||||
|
data_dir=queue.database_path.parent,
|
||||||
|
source_active=lambda source_key: source_key in enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_disabled_source_suspends_its_jobs_and_enabling_it_resumes_them(tmp_path) -> None:
|
||||||
|
"""A source the user turned off keeps its jobs, out of the pending count (#245)."""
|
||||||
|
path = tmp_path / "one-memory.sqlite3"
|
||||||
|
queue = DurableWriteQueue(path)
|
||||||
|
recent = datetime.now(UTC)
|
||||||
|
for index in range(3):
|
||||||
|
queue.submit(_request(key=f"cursor-{index}", source="cursor", event_at=recent))
|
||||||
|
codex = queue.submit(_request(key="codex-0", source="codex", event_at=recent))
|
||||||
|
enabled = {"cursor", "codex"}
|
||||||
|
runtime = _suspension_runtime(queue, enabled)
|
||||||
|
drain = DurableWriteDrain(
|
||||||
|
queue,
|
||||||
|
execute=_never_execute,
|
||||||
|
owner="suspend-owner",
|
||||||
|
source_writes_permitted=lambda source_key: source_key in enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert runtime.status_counts() == {PENDING: 4}
|
||||||
|
assert runtime.queue_depths().fresh == 4
|
||||||
|
|
||||||
|
enabled.discard("cursor")
|
||||||
|
|
||||||
|
assert runtime.status_counts() == {PENDING: 1, "suspended": 3}
|
||||||
|
assert runtime.queue_depths().fresh == 1
|
||||||
|
assert drain.claim(limit=10) == (codex.job_id,)
|
||||||
|
queue.settle(codex.job_id, state=COMPLETED, hy_invoked=True, owner="suspend-owner")
|
||||||
|
assert drain.claim(limit=10) == ()
|
||||||
|
assert runtime.status_counts() == {COMPLETED: 1, "suspended": 3}
|
||||||
|
# Nothing was cancelled, moved or deleted: the rows are as they were.
|
||||||
|
assert [row["state"] for row in _rows(path) if row["source_key"] == "cursor"] == [PENDING] * 3
|
||||||
|
|
||||||
|
enabled.add("cursor")
|
||||||
|
|
||||||
|
assert runtime.status_counts() == {PENDING: 3, COMPLETED: 1}
|
||||||
|
assert runtime.queue_depths().fresh == 3
|
||||||
|
assert len(drain.claim(limit=10)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_claim_path_error_restarts_the_drain_and_later_jobs_still_run(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""An unexpected SQLite error on the claim path used to end the drain for good."""
|
||||||
|
from one_memory.memory_server import durable_queue as _queue_module
|
||||||
|
|
||||||
|
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||||
|
queue.submit(_request(key="later", source="cursor"))
|
||||||
|
reported: list[tuple[str, BaseException]] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_queue_module,
|
||||||
|
"report_exception",
|
||||||
|
lambda component, event, error, **_kwargs: reported.append((event, error)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(job_id: str) -> None:
|
||||||
|
queue.settle(job_id, state=COMPLETED)
|
||||||
|
drain.stop()
|
||||||
|
|
||||||
|
drain = DurableWriteDrain(queue, execute=execute, max_concurrent_sources=1)
|
||||||
|
real_claim = drain.claim
|
||||||
|
calls = {"claim": 0}
|
||||||
|
|
||||||
|
def failing_once(**kwargs):
|
||||||
|
calls["claim"] += 1
|
||||||
|
if calls["claim"] == 1:
|
||||||
|
raise sqlite3.OperationalError("disk I/O error")
|
||||||
|
return real_claim(**kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(drain, "claim", failing_once)
|
||||||
|
first_owner = drain.owner
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
asyncio.wait_for(
|
||||||
|
drain.run_supervised(idle_pause=0.005, restart_backoff=0.01, max_restart_backoff=0.05),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert queue.get("later").state == COMPLETED
|
||||||
|
assert drain.restarts == 1
|
||||||
|
assert drain.last_error_code == "operational_error"
|
||||||
|
assert drain.liveness == "stopped"
|
||||||
|
# A restarted drain leases under a new name: nothing the old one left in a
|
||||||
|
# pool thread can renew or settle as it.
|
||||||
|
assert drain.owner != first_owner
|
||||||
|
assert [event for event, _error in reported] == ["memory.write_queue.drain_exited"]
|
||||||
|
assert reported[0][1].__traceback__ is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_claim_gate_exception_restarts_the_drain(tmp_path, monkeypatch) -> None:
|
||||||
|
from one_memory.memory_server import durable_queue as _queue_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(_queue_module, "report_exception", lambda *_args, **_kwargs: None)
|
||||||
|
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||||
|
queue.submit(_request(key="gated", source="cursor"))
|
||||||
|
asked = {"count": 0}
|
||||||
|
|
||||||
|
def gate(_source_key: str) -> bool:
|
||||||
|
asked["count"] += 1
|
||||||
|
if asked["count"] == 1:
|
||||||
|
raise KeyError("gate bug")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def execute(job_id: str) -> None:
|
||||||
|
queue.settle(job_id, state=COMPLETED)
|
||||||
|
drain.stop()
|
||||||
|
|
||||||
|
drain = DurableWriteDrain(queue, execute=execute, source_claim_enabled=gate)
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(drain.run_supervised(idle_pause=0.005, restart_backoff=0.01), 2))
|
||||||
|
|
||||||
|
assert queue.get("gated").state == COMPLETED
|
||||||
|
assert drain.last_error_code == "key_error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_ends_a_restart_backoff_at_once(tmp_path, monkeypatch) -> None:
|
||||||
|
from one_memory.memory_server import durable_queue as _queue_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(_queue_module, "report_exception", lambda *_args, **_kwargs: None)
|
||||||
|
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||||
|
drain = DurableWriteDrain(queue, execute=_never_execute)
|
||||||
|
|
||||||
|
def broken(**_kwargs):
|
||||||
|
raise sqlite3.DatabaseError("database disk image is malformed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(drain, "claim", broken)
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
task = asyncio.create_task(drain.run_supervised(restart_backoff=30))
|
||||||
|
for _ in range(200):
|
||||||
|
if drain.liveness == "restarting":
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.005)
|
||||||
|
assert drain.liveness == "restarting"
|
||||||
|
drain.stop()
|
||||||
|
await asyncio.wait_for(task, 1)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
assert drain.liveness == "stopped"
|
||||||
|
assert drain.last_error_code == "database_error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_cancelled_supervised_drain_stays_cancelled(tmp_path) -> None:
|
||||||
|
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||||
|
drain = DurableWriteDrain(queue, execute=_never_execute)
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
task = asyncio.create_task(drain.run_supervised(idle_pause=0.005))
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
assert drain.liveness == "stopped"
|
||||||
|
assert drain.restarts == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_contained_job_task_failure_is_reported_with_its_stack(tmp_path, monkeypatch) -> None:
|
||||||
|
from one_memory.memory_server import durable_queue as _queue_module
|
||||||
|
|
||||||
|
reported: list[tuple[str, BaseException]] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_queue_module,
|
||||||
|
"report_exception",
|
||||||
|
lambda component, event, error, **_kwargs: reported.append((event, error)),
|
||||||
|
)
|
||||||
|
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||||
|
first = queue.submit(_request(key="first", source="cursor"))
|
||||||
|
queue.submit(_request(key="second", source="codex"))
|
||||||
|
|
||||||
|
async def run_job(job_id: str) -> None:
|
||||||
|
if job_id == first.job_id:
|
||||||
|
raise RuntimeError("systematic job task bug")
|
||||||
|
drain.stop()
|
||||||
|
|
||||||
|
drain = DurableWriteDrain(queue, execute=_never_execute, max_concurrent_sources=1)
|
||||||
|
monkeypatch.setattr(drain, "_run_claimed_job", run_job)
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
|
||||||
|
|
||||||
|
assert [event for event, _error in reported] == ["memory.write_queue.job_task_failed"]
|
||||||
|
assert reported[0][1].__traceback__ is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lease_is_renewed_while_every_bulk_thread_is_blocked(tmp_path) -> None:
|
||||||
|
"""The incident: bulk threads stuck in job listings starved lease renewal."""
|
||||||
|
from one_memory.background_work import BULK_WORKERS, bulk_pool, shutdown_bulk_pool
|
||||||
|
|
||||||
|
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||||
|
queue.submit(_request(key="slow", source="cursor"))
|
||||||
|
release = threading.Event()
|
||||||
|
blocked = [bulk_pool().submit(release.wait, 10) for _ in range(BULK_WORKERS * 2)]
|
||||||
|
renewed: list[bool] = []
|
||||||
|
|
||||||
|
async def execute(job_id: str) -> None:
|
||||||
|
# Longer than the lease: only renewals keep it.
|
||||||
|
await asyncio.sleep(0.8)
|
||||||
|
queue.settle(job_id, state=COMPLETED, owner=drain.owner)
|
||||||
|
|
||||||
|
drain = DurableWriteDrain(
|
||||||
|
queue,
|
||||||
|
execute=execute,
|
||||||
|
lease=timedelta(milliseconds=400),
|
||||||
|
renew_every=timedelta(milliseconds=50),
|
||||||
|
)
|
||||||
|
real_renew = drain.renew
|
||||||
|
|
||||||
|
def counted(job_id: str, **kwargs) -> bool:
|
||||||
|
result = real_renew(job_id, **kwargs)
|
||||||
|
renewed.append(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
drain.renew = counted # type: ignore[method-assign]
|
||||||
|
try:
|
||||||
|
assert asyncio.run(asyncio.wait_for(drain.run_one(), 3)) == 1
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
for future in blocked:
|
||||||
|
future.result(timeout=5)
|
||||||
|
shutdown_bulk_pool()
|
||||||
|
|
||||||
|
assert renewed and all(renewed)
|
||||||
|
assert queue.get("slow").state == COMPLETED
|
||||||
@@ -17,6 +17,7 @@ from one_memory.memory_server.migration_runner import (
|
|||||||
InMemoryMigrationStore,
|
InMemoryMigrationStore,
|
||||||
MigrationResult,
|
MigrationResult,
|
||||||
MigrationSchemaMismatchError,
|
MigrationSchemaMismatchError,
|
||||||
|
SqliteMigrationStore,
|
||||||
ledger_sql,
|
ledger_sql,
|
||||||
migrate,
|
migrate,
|
||||||
)
|
)
|
||||||
@@ -138,6 +139,76 @@ def test_sdk_write_receipts_are_owned_by_migration_56() -> None:
|
|||||||
assert "CREATE TABLE IF NOT EXISTS memory_task_sdk_receipts" in ddl
|
assert "CREATE TABLE IF NOT EXISTS memory_task_sdk_receipts" in ddl
|
||||||
|
|
||||||
|
|
||||||
|
def test_onboarding_progress_migrates_old_sqlite_columns_in_append_order(tmp_path) -> None:
|
||||||
|
path = tmp_path / "old-onboarding.sqlite3"
|
||||||
|
with sqlite3.connect(path) as database:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE onboarding_state (
|
||||||
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
selected_source_ids_json TEXT NOT NULL,
|
||||||
|
started_at TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"INSERT INTO onboarding_state VALUES "
|
||||||
|
"(1,'importing','[\"cursor\"]',NULL,NULL,'2026-11-04T00:00:00+00:00')"
|
||||||
|
)
|
||||||
|
|
||||||
|
migration = next(item for item in MIGRATIONS if item.version == 62)
|
||||||
|
result = asyncio.run(migrate(SqliteMigrationStore(path), (migration,)))
|
||||||
|
|
||||||
|
assert result.applied == (62,)
|
||||||
|
with sqlite3.connect(path) as database:
|
||||||
|
columns = [str(row[1]) for row in database.execute("PRAGMA table_info(onboarding_state)")]
|
||||||
|
row = database.execute(
|
||||||
|
"SELECT mode_intent, setup_step, model_profile_id FROM onboarding_state"
|
||||||
|
).fetchone()
|
||||||
|
assert columns == [
|
||||||
|
"singleton",
|
||||||
|
"status",
|
||||||
|
"selected_source_ids_json",
|
||||||
|
"started_at",
|
||||||
|
"completed_at",
|
||||||
|
"updated_at",
|
||||||
|
"mode_intent",
|
||||||
|
"setup_step",
|
||||||
|
"model_profile_id",
|
||||||
|
]
|
||||||
|
assert row == (None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_onboarding_progress_mysql_retries_each_duplicate_column() -> None:
|
||||||
|
class DuplicateColumnError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Cursor:
|
||||||
|
dialect = "mysql"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.attempted: list[str] = []
|
||||||
|
self.applied: list[str] = []
|
||||||
|
|
||||||
|
async def execute(self, statement: str) -> None:
|
||||||
|
self.attempted.append(statement)
|
||||||
|
if "ADD COLUMN mode_intent" in statement or "ADD COLUMN setup_step" in statement:
|
||||||
|
raise DuplicateColumnError(1060, "duplicate column")
|
||||||
|
self.applied.append(statement)
|
||||||
|
|
||||||
|
cursor = Cursor()
|
||||||
|
migration = next(item for item in MIGRATIONS if item.version == 62)
|
||||||
|
|
||||||
|
asyncio.run(migration.apply(cursor))
|
||||||
|
|
||||||
|
assert len(cursor.attempted) == 3
|
||||||
|
assert len(cursor.applied) == 1
|
||||||
|
assert "model_profile_id" in cursor.applied[0]
|
||||||
|
|
||||||
|
|
||||||
def test_sdk_matter_references_are_owned_by_migration_57() -> None:
|
def test_sdk_matter_references_are_owned_by_migration_57() -> None:
|
||||||
from one_memory.memory_server.migrations import MIGRATIONS, sdk_matter_references_ddl
|
from one_memory.memory_server.migrations import MIGRATIONS, sdk_matter_references_ddl
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 293 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user