#2380·MemOS

[Bug] Overview/health model slots misreport "Not called yet" once the slot's newest api_logs row falls >500 below the head (fixed top-N scan, role filtered after the limit)

Author: chiefmojoCreated Sep 16, 2026Updated Sep 16, 2026
Labelstypes:bugai:taskarea:pluginai:testingstatus:in-progress

Pre-submission checklist

  • I have searched existing issues and this hasn't been mentioned before
  • I have read the project documentation and confirmed this issue doesn't already exist
  • This issue is specific to MemOS and not a general software issue

Bug Description

Summary

The overview/health model cards silently misreport a per-model slot as "Not called yet" whenever the slot's most recent system_model_status row has fallen further than 500 rows below the head of api_logs — even though the row exists and the slot is working.

findLatestPersistedModelStatus() applies limit: 500 before it filters by role, so it scans the newest 500 system_model_status rows and then looks for a matching role / provider / model within them. system_model_status rows are dominated by high-frequency llm and embedding calls (a busy install writes thousands per day), so a lower-frequency slot — skillEvolver is the one that shows this in practice — is crowded out of the window within a few days of uptime.

When no matching row is found, the endpoint falls back to the per-process in-memory counter from the LLM facade. That counter is null at boot, so the card reads "Not called yet" — and since it resets on every bridge restart, it is very easy to mistake for a slot that has never worked.

This is independent of the wiring bug in #2362 (a slot can be genuinely unused and be unreachable in the lookup); the two compound, which is what made it hard to attribute.

Root cause

apps/memos-local-plugin/core/pipeline/memory-core.ts

typescript
// findLatestPersistedModelStatus() — :5900
const rows = repos.apiLogs.list({
    toolName: "system_model_status",
    limit: 500,        // :5913 — applied BEFORE the role filter below
    offset: 0,
});
for (const row of rows) {
    const out = JSON.parse(row.outputJson) as { role?: unknown; /* … */ };
    if (out.role !== role) continue;              // role filter runs after the limit
    if (String(out.provider ?? "") !== provider) continue;
    if (String(out.model ?? "") !== model) continue;
    // …
}

Because limit is applied first, the 500 rows are filled almost entirely with llm/embedding entries and the target role is never reached.

Suggested Fix

Filter by role and model in the query rather than in a post-scan over a bounded top-N — i.e. order by called_at DESC and select the newest row matching tool_name='system_model_status' and the wanted role/provider/model, and let the index do the work. That removes the window entirely and is also cheaper than deserializing 500 JSON payloads on every health poll. A pragmatic stopgap would be to raise/remove the limit, but the unbounded version has the same correctness problem on a large enough table.

How to Reproduce

  1. On an install with a distinct skillEvolver model configured, let the bridge run until api_logs holds more than ~500 system_model_status rows whose role is llm or embedding after the most recent skillEvolver row.
  2. GET /api/v1/health (or open the overview) — skillEvolver.lastOkAt is null and the card reads "Not called yet", despite skillEvolver rows existing in api_logs.
  3. Confirm the row is present but out of window:
sql
SELECT count(*) FROM api_logs
 WHERE tool_name='system_model_status'
   AND called_at > (SELECT max(called_at) FROM api_logs
        WHERE tool_name='system_model_status'
          AND json_extract(output_json,'$.role')='skillEvolver');
  1. Restart the bridge: the card still reads "Not called yet" (the fallback counter is reset), which is the misleading part.

Environment

  • MemOS: memos-local-plugin v2.0.16
  • Host: Hermes agent adapter, Linux
  • Observed on three independent production installs. Measured offsets between the head of system_model_status and the newest skillEvolver row: 0 rows on one install (no rows at all in the retained window), 2,193 on the second, 4,896 on the third — the latter two both read "Not called yet" while holding 144 and 195 historical skillEvolver rows.
  • api_logs appears capped at 10,000 rows, so the retained history is roughly a week of busy traffic; the window can be exceeded within days of uptime.

Additional Context

Found while confirming #2362 on the same installs. #2362 explains why the slot stopped being invoked (dedicated bgReflectLlm never passed to the skill subscriber); this issue explains why the card cannot tell "never invoked" apart from "invoked a while ago" in the first place. Fixing the wiring alone will restore fresh rows and hide this for a while, but any install whose evolver goes quiet for a few thousand status rows will misreport again.

Relevant precedent in the same function: the surrounding comment notes the deliberate decision to drive card colour from in-memory stats so a stale historical error row cannot mask a freshly-booted process. The persisted lookup exists precisely to give that decision a floor — it just needs to be able to reach the row it is looking for.

Happy to provide raw row dumps, timestamps, or the health payloads on request.