Skip to content

staging <- dev#216

Merged
ducnmm merged 25 commits into
stagingfrom
dev
Jun 2, 2026
Merged

staging <- dev#216
ducnmm merged 25 commits into
stagingfrom
dev

Conversation

@ducnmm

@ducnmm ducnmm commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

hungtranphamminh and others added 25 commits May 28, 2026 13:09
Adds an optional `occurred_at` field to `AnalyzeRequest` and threads
it through to the extractor as a `<context occurred_at="..."/>` tag
in the LLM prompt. extract.v6 resolves in-turn relative-time references
("last Friday", "yesterday") to absolute dates *inside the extracted
fact text* — so the resolved date ends up in (a) the SEAL-encrypted
blob on Walrus and (b) the embedding vector. Targets the dominant
LOCOMO/LME temporal failure mode (78% of failing temporal answers
hinge on a date; 96.6% of those dates were absent from retrieved
memories on the v5 baseline).

Architecture A: the resolved date lives ONLY inside the encrypted fact
text + embedding. There is NO new metadata column on `vector_entries`.
The server can never filter or rank by event time — that's the
privacy-floor-preserving trade. Full design context on WALM-55.

Bench result (baseline preset, vs 2026-05-21 extract.v5 archive):
- LOCOMO temporal: 45.2 → 62.3 (+17.1) — past noise floor, mechanism confirmed
- LME temporal:    60.1 → 65.3  (+5.2) — past noise floor
- LOCOMO overall:  67.4 → 69.95 (+2.55)
- LME overall:     77.9 → 78.42 (+0.5)
Other categories within ±3 J judge-noise floor.

This commit also contains three transient-failure fixes diagnosed during
the bench investigation. Previously these dropped turns silently because
they returned HTTP 500 (not in the SDK retry set):

1. New `AppError::UpstreamUnavailable` variant → HTTP 503. Maps three
   upstream failure shapes from `AppError::Internal`:

   (a) Transport-level: `resp.text().await` fails — connection drop or
       HTTP/2 stream reset mid-body.
   (b) OpenRouter error envelope wrapped in HTTP 200 OK
       (`{"error":{"message":"...","code":NNN}}` with no `choices`
       field). Detected by new `parse_openrouter_error_envelope` helper
       with a `choices.is_none()` guard to avoid false-positives on
       bodies containing both fields.
   (c) `gpt-4o-mini` HTTP 200 with `content: null` and
       `completion_tokens: 0`. Fixed at the type level
       (`ChatMessageResp.content: Option<String>`) — degrades to empty
       string, which `parse_extracted_facts` correctly returns as zero
       facts (same as the prompt's explicit NONE response).

   Net effect: LME bench completion went from 91.5% (pre-fix) to 99.99%
   (with fix), with the harness's existing retry policy now recovering
   transient upstream failures.

2. Body-read switched from `resp.json()` to `resp.text() →
   from_str(&body)` at both call sites (extractor + embedder) so the
   envelope detection can short-circuit before deserialise.

3. The `Option<String>` content ripples to two other consumers of
   `ChatMessageResp` outside the extractor: the `ask` endpoint
   (`routes/admin.rs`) preserves its 'No response from LLM' fallback;
   the long-text summariser (`routes/remember.rs`) keeps its empty
   guard.

Trait change: `Extractor::extract_with_context` signature extended
with `occurred_at: Option<chrono::DateTime<chrono::Utc>>`. Default
impl ignores it (backwards-compatible for any future Extractor impls).
`LlmExtractor` builds 1–3 user messages depending on which contexts
are present, short-circuits to plain `extract` when both are absent.

10 new Rust tests pinning: envelope detection across 5 body shapes
(including the choices+error edge case), occurred_at block rendering
in two timezones, default-impl pass-through for occurred_at, the v6
prompt anti-leak rules, the 503 mapping, and the upstream_unavailable
observability label.

Files:
- src/types.rs                     — AnalyzeRequest.occurred_at, AppError::UpstreamUnavailable
- src/services/llm_chat.rs         — ChatMessageResp.content: Option<String>
- src/services/extractor.rs        — trait sig + impl + helpers + tests
- src/services/embedder.rs         — parity envelope detection + test
- src/services/prompts/extract.txt — extract.v6 prompt
- src/routes/analyze.rs            — pass occurred_at through
- src/routes/admin.rs              — Option<String> ripple
- src/routes/remember.rs           — Option<String> ripple
Wires the LOCOMO and LongMemEval harnesses to send each turn's session
timestamp as the new `occurred_at` field on `/api/analyze`. Without
this, the previous commit's server-side capability would be dormant on
bench runs (the source datasets carry per-session dates that were being
silently dropped before reaching the extractor).

Both adapters normalise their source-specific date formats to RFC 3339
UTC at parse time so `Turn.timestamp` is a uniform shape by the time
`build_ingest_text_per_turn` packages turns for ingestion:

- LOCOMO: `"1:56 pm on 8 May, 2023"` → `"2023-05-08T13:56:00+00:00"`
  via new `_normalize_locomo_timestamp` helper. Unparseable strings
  return None with a warning log — that turn just goes through without
  a temporal anchor (graceful degradation, never fabricate now()).
- LongMemEval: `"2023/04/10 (Mon) 17:50"` → `"2023-04-10T17:50:00+00:00"`
  inline at the `Turn` construction site (LME's existing
  `_parse_haystack_date` already produced a datetime; the change is
  just to .replace(tzinfo=UTC).isoformat() at assignment).

Naive-UTC convention: source datasets don't carry timezone info. The
harness treats naive timestamps as UTC for benchmark-mode consistency
across runs. Documented in the adapter comments. Not acceptable for
production callers — but the production SDKs don't expose
`occurred_at` yet (deferred follow-up).

API ripples through the harness:

- `MemWalClient.analyze(text, namespace, occurred_at=None)` — optional
  param, sent on the JSON body only when not None.
- `build_ingest_text_per_turn` return type widened from
  `list[tuple[str, str]]` to `list[tuple[str, str, str | None]]` —
  the new third element is the per-turn RFC 3339 timestamp.
- `build_ingest_text_naive_concat` returns `(label, text, None)` —
  the concat helper collapses many turns into one blob, so picking any
  one turn's timestamp would be arbitrary. Documented in the docstring.
- `run.py::stage_ingest` unpacks the new 3-tuple and threads
  `occurred_at` into `client.analyze()`. Type annotations updated.

4 new tests pin the normalisation behaviour:
- LOCOMO canonical-format → exact RFC 3339 UTC output
- LOCOMO unparseable input → None + warning
- LOCOMO None input → None
- LME fixture session dates → RFC 3339 UTC on the Turn

The two existing per-turn-chunking tests in both adapters are updated
to unpack the new 3-tuple and additionally assert the occurred_at
shape (RFC 3339 with T separator + +00:00 or Z suffix).

Files:
- benchmarks/core/client.py            — analyze() accepts occurred_at
- benchmarks/benchmarks/base.py        — build_ingest_text_* return triples
- benchmarks/benchmarks/locomo.py      — _normalize_locomo_timestamp
- benchmarks/benchmarks/longmemeval.py — inline RFC 3339 normalisation
- benchmarks/run.py                    — unpack 3-tuple, pass occurred_at
- benchmarks/tests/test_adapters.py    — coverage for normalisation
Removes the WALM-55: / MEM-57 prefixes from doc comments across
the WALM-55 implementation. Explanatory text is preserved; only
the ticket-prefix noise is stripped. Per code-review feedback —
ticket IDs belong in commit messages / PR descriptions, not in
source comments.

No behaviour change. Symmetric diff (33 deletions / 33 insertions).
Rust 267/267 + Python 42/42 tests pass.
Adds AnalyzeOptions object as an alternative to the (text, namespace)
overload on analyze() and analyzeAndWait(). The new `occurredAt` field
carries a Date or RFC-3339 string from the SDK caller to the server's
AnalyzeRequest.occurred_at, where the extractor uses it as a temporal
anchor (extract.v6 prompt resolves "last Friday" / "yesterday" to
absolute dates inside the fact text before embedding/encryption).

- New AnalyzeOptions type in types.ts; re-exported from index.ts.
- normalizeAnalyzeOptions helper preserves backwards compatibility:
  passing a string still works as namespace.
- occurredAtToWire normalises Date → RFC-3339 UTC with trailing 'Z'
  (matches server-side wire format).
- analyzeAndWait threads the options object through analyze() so a
  single call captures both timestamp + wait semantics.

No server change; field is already accepted on AnalyzeRequest.

Backbone for the WALM-55 effect to reach JS/TS callers — the auto-
capture hook and openclaw tools follow in subsequent commits.
Adds an optional `occurred_at` kwarg to MemWal.analyze(),
MemWal.analyze_and_wait(), and their sync wrappers on MemWalSync. The
field carries a datetime or RFC-3339 string to the server's
AnalyzeRequest.occurred_at, where the extractor uses it as a temporal
anchor (extract.v6 prompt resolves 'last Friday' / 'yesterday' to
absolute dates inside the fact text before embedding/encryption).

- _occurred_at_to_wire helper normalises datetime → RFC-3339 UTC with
  trailing 'Z'. Naïve datetimes are assumed UTC; aware datetimes are
  converted to UTC. String inputs pass through verbatim.
- Field is omitted from the request body when None, so existing callers
  see no wire-format change.
- 3 new tests pin the wire format across datetime-aware, datetime-
  naïve, and string inputs. Full suite still passes (34/34).
The agent_end auto-capture hook now passes `occurredAt: new Date()`
to analyze(), so every agent-captured conversation gets the WALM-55
temporal-anchor treatment: the server extractor resolves in-turn
relative references ('yesterday', 'last Friday') into absolute dates
inside the fact text before embedding/encryption.

Wall-clock time is the honest answer at this site because the hook
fires on agent_end — milliseconds after the conversation. This is
the highest-leverage WALM-55 win on the agent side: every Claude
Code / Claude Desktop / openclaw user benefits with no API surface
change.

The 'server must not silently default to now()' rule is preserved:
this is the caller passing now() with real knowledge of when the
event occurred, not the server inventing a fallback for an absent
field.
The memory_store agent tool now exposes an optional `occurredAt` arg
that flows through to the server's analyze() AnalyzeRequest.occurred_at.
Agents can now anchor recounted past events to specific dates ('user
mentioned they moved last March') by populating occurredAt with the
date they reason the event actually happened.

Distinguishes from the auto-capture hook (prior commit): there,
new Date() is the best honest approximation because capture fires
right after the conversation. Here, the agent has to reason about
when the event happened — which is why occurredAt is optional with
no default ('Omit when unknown; do not guess' is in the description).

Bumps the openclaw-memory-memwal SDK dependency from published ^0.0.2
to workspace:* so the AnalyzeOptions signature from the SDK commit is
visible. The published 0.0.2 wasn't being actively consumed anyway
(workspace apps already use workspace:* for the SDK); this just
aligns openclaw with that pattern.

The matching MCP analyze tool (services/server/scripts/mcp/tools/
analyze.ts) needs the same treatment but is deferred — services/
server/scripts/ is not currently a pnpm-workspace member, so wiring
it to the workspace SDK is a separate restructuring task.

Build verified clean: pnpm build passes on openclaw-memory-memwal
with the new AnalyzeOptions signature linked from the workspace SDK.
Replaces 162 instances of "MemWal" with "Walrus Memory" across 53
.md/.mdx files. Only prose references are updated — code identifiers
(MemWal, MemWalManual, withMemWal, MemWalConfig, etc.), package names
(@mysten-incubation/memwal), environment variables (MEMWAL_*), URLs
(memwal.ai), inline code, and code blocks are all preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CHANGELOG files should preserve original branding at time of release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Updates prose across docs to reflect the new messaging framework:

- Category: "Portable agent memory"
- Tagline: "Take your agent's memory anywhere"
- Pillar 1: Portable by Design — memory across agents, apps, workflows
- Pillar 2: Fully Under Your Control — programmable permissions, explicit ownership
- Pillar 3: Built for Agent Coordination — shared memory spaces, verifiable integrity

Replaces "privacy-first", "persistent, encrypted", and "long-term memory"
framing with portability, owner control, and coordination language.

Reverts changelog files from prior commit — release notes should
preserve original branding at time of release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds release metadata for the WALM-55 SDK changes shipping in this PR:

- Changeset: @mysten-incubation/memwal patch (0.0.6 → 0.0.7) for the
  new AnalyzeOptions overload exposing optional occurredAt.
- Changeset: @mysten-incubation/oc-memwal patch (0.0.3 → 0.0.4) for
  the auto-capture hook timestamping new captures with new Date(),
  the optional occurredAt arg on memory_store, and the workspace:*
  dep bump.
- Python SDK manual bump: 0.1.3 → 0.1.4 across pyproject.toml +
  __init__.py + CHANGELOG.md entry, covering the new occurred_at
  kwarg + the SDK-boundary input validation rules.

Patch bumps are sufficient because every new field is optional and
omitted from the wire body when unset — existing callers see
byte-identical request payloads. Verified with @changesets/cli:
`pnpm changeset status` shows both TS packages slated for patch.
…urning wallet retries

A wallet job that fails because a Sui owned object/version is locked to a
competing transaction was falling through classify_sidecar_error to the
generic Transient arm. Apalis then retried across every wallet and fired a
misleading "wallet retries exhausted" alert, even though the object is
locked until the lock clears (typically the next epoch boundary) and every
immediate retry re-fails against the same object.

- Add WalletJobError::ObjectLockedUntilEpoch — a third disposition that is
  neither Transient (don't retry into the same lock) nor Permanent (the
  input can succeed in a later epoch). aborts_retries() gates both the
  Apalis Abort and the exhausted-retries alert so a single locked object no
  longer consumes the whole wallet budget.
- Classifier branch matches "already locked by a different transaction",
  ">1/3 of validators by stake", "non-retriable", "equivocated/equivocation",
  and "reserved for another transaction", ahead of the MoveAbort->Permanent
  catch and distinct from the recoverable "locked at version" case.
- Parse locked object id / version / locking digest from the error string
  to enrich the alert and the persisted row.
- New WalrusObjectLockedAlert with copy that names the real cause and
  surfaces the object id, version, and locking digest; deduped per
  (network, object_id) via a reusable AlertDedup so an equivocation burst
  doesn't spam. Window env-overridable (WALRUS_OBJECT_LOCK_ALERT_DEDUP_SECS).
- Aborting errors now persist status='failed' instead of leaving the row
  stuck on 'running' forever (no further retries will touch it).

Tests pin the exact production error string and cover abort disposition,
the no-exhaust gate, metadata parsing, phrase variants, and no regression
of balance::split / EWrongVersion recoverable classes.
…assifier

Mirror the Rust classifier so sidecar-side wallet metrics categorize the
non-recoverable owned-object lock separately from the recoverable
"locked at version" class. Adds isWalrusObjectLockEquivocation (kept in
sync with the Rust classify_sidecar_error patterns) and a dedicated
walletObjectLockEquivocationTotal counter surfaced via /metrics/wallet.

Also corrects the adjacent metric comments: the earlier "Sui no longer
permanently locks coin objects on equivocation" claim is contradicted by
the production incident, so the equivocation counter is now the canary for
whether concurrent uploads are still equivocating owned objects.

Tests cover the exact production error string, each phrase variant,
case-insensitivity, and that the recoverable lock-at-version class does
NOT match.
Closes two silent-data-loss paths flagged in code review:

(1) extractor.rs: `ChatMessageResp.content: null` previously degraded
to empty string and returned a successful 202 with facts=[]. The
SDK/harness can't distinguish that from 'no memorable content' and
will not retry, so the turn was silently dropped. Now maps to
AppError::UpstreamUnavailable → HTTP 503 so the retry policy
(_RETRY_STATUS = 429, 502, 503, 504) recovers. The explicit string
"NONE" from the LLM remains the valid no-facts path (handled by
parse_extracted_facts, unchanged).

(2) extractor.rs + embedder.rs: non-success upstream HTTP statuses
(429 + 5xx) previously routed to AppError::Internal → HTTP 500,
which the harness does not retry by design. Now classified via new
is_upstream_status_transient helper: 429 + 5xx → UpstreamUnavailable
(retryable); other 4xx → Internal (real bug, retrying won't fix).
Mirrors the 200-wrapped envelope handling for the case where the
upstream surfaces the same condition as a raw HTTP status.

The harness retry tuple stays (429, 502, 503, 504) — no 500 retry —
so unknown internal errors still surface as genuine bugs.

New unit test pins is_upstream_status_transient across the 429 +
5xx transient set and the 4xx + 2xx non-transient set. Rust suite:
268/268.
Three doc/description fixes from code review:

(a) memory_store tool description (openclaw): previously instructed
the agent to 'pass the date the event occurred' for recounted past
events — exactly inverted from the server's actual semantics where
occurredAt is the timestamp of the source text/conversation, not the
historical event date. Failure mode: user says 'remember I called
Alex last Friday' on Monday, agent reads our description and passes
last Friday's date as occurredAt, extractor then resolves 'last
Friday' relative to that — fact stamped a week early. New
description matches the server prompt's contract: pass the
conversation time for real-time stores; OMIT for recounted past
events.

(b) Python SDK docstring: said 'naïve datetimes are assumed UTC' but
the implementation rejects them with ValueError (deliberately, to
avoid silent timezone-off-by-N anchors for non-UTC callers).
Docstring now matches behaviour: aware datetimes only; naïve raises;
string inputs must carry Z or UTC offset.

(c) Dropped the 'same approach as Mem0's memory_store' comment from
store.ts header — kept the architectural rationale (analyze() vs
remember() for fact extraction) without the third-party vendor
attribution.
Feat: WALM-55 — extract.v6 timestamp injection for temporal recall
Address review: a bare "non-retriable" / ">1/3 of validators by stake"
preamble is not lock-specific — a generic invalid transaction or a
non-lock MoveAbort is also non-retriable — so matching on it alone
misclassified those as ObjectLockedUntilEpoch.

Now the object-lock branch requires either a lock/equivocation-specific
anchor ("already locked by a different transaction", "reserved for
another transaction", "equivocated"/"equivocation") OR the non-retriable
preamble corroborated by object-lock evidence in the same message
("object (" + "locked"). Mirrored in the TS detector.

Also fix the upload/set-metadata log lines: retryable was derived from
!is_permanent(), which printed retryable=true for ObjectLockedUntilEpoch
even though it aborts. Use !aborts_retries() so the log matches the
actual Apalis disposition.

Adds negative tests: a bare non-retriable invalid tx stays Transient and
a bare non-retriable MoveAbort stays Permanent — neither becomes an
object lock.
…ck-classifier

fix(server): WALM-77 detect Sui object-lock / equivocation, stop burning wallet retries
@ducnmm ducnmm merged commit 5fe59d7 into staging Jun 2, 2026
23 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants