openclaw - 💡(How to fix) Fix MemoryCorpusSupplement.search forced to re-run manager.search; engine already has the candidates [1 pull requests]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Fix Action

Fixed

Code Example

// Engine call
rawResults = await memory.manager.search(query, { maxResults, ... });
// ... filter / decorate / recall tracking ...

// Supplement call — happens AFTER engine's, serially
const supplementResults = shouldQuerySupplements
  ? await searchMemoryCorpusSupplements({
      query, maxResults, agentSessionKey, corpus
    })
  : [];

---

type MemoryCorpusSupplement = {
  search(params: {
    query: string;
    maxResults?: number;
    agentSessionKey?: string;
    // NEW: engine's just-computed candidates, available when the engine
    // already ran manager.search for this turn. Supplement can use these
    // directly (rerank / filter / annotate) instead of re-querying.
    // Optional so existing supplements keep working unchanged.
    engineCandidates?: MemorySearchResult[];
  }): Promise<MemoryCorpusSearchResult[]>;
  get(params: { /* unchanged */ }): Promise<MemoryCorpusGetResult | null>;
};

---

const supplementResults = shouldQuerySupplements
  ? await searchMemoryCorpusSupplements({
      query, maxResults, agentSessionKey, corpus,
      engineCandidates: rawResults,
    })
  : [];

---

await registration.supplement.search({
  ...params,
  engineCandidates: params.engineCandidates,
});

---

async search({ query, engineCandidates }) {
  if (!engineCandidates?.length) return [];
  const scores = await callReranker(query, engineCandidates.map(c => c.snippet));
  return engineCandidates
    .map((c, i) => ({ ...c, corpus: 'my-reranker', score: scores[i] }))
    .sort((a, b) => b.score - a.score)
    .slice(0, maxResults);
}
RAW_BUFFERClick to expand / collapse

Problem

The MemoryCorpusSupplement interface (plugin-sdk/src/plugins/memory-state.d.ts) is designed for reranker-style plugins that want to consume the engine's memory-search hits and rearrange them. But the current contract doesn't pass the engine's already-computed candidates to the supplement — the supplement only receives { query, maxResults?, agentSessionKey? }. To get candidates to rerank, the supplement must call manager.search() itself, after the engine already called manager.search() with the same query a few lines earlier.

This double-call has two real costs:

  1. Latency. The engine's manager.search and the supplement's manager.search run serially (per tools-Bu6mk-dQ.js, the supplement is awaited after the engine completes). For a reranker plugin to fit inside a sensible per-tool budget, the supplement's manager.search either repeats expensive embedding/index/MMR work, or shares a cache. Empirically, calling getActiveMemorySearchManager({cfg, agentId}) twice in succession yields two different manager instances, and the second instance's manager.search (for the same query the engine just did) takes ~1.8s instead of the engine's ~5s but still doesn't drop to the in-process-cache <5ms hot path. The reranker can't reasonably finish inside a tight budget.

  2. Conceptual waste. The engine has the candidates in scope. Passing them to the supplement is one extra parameter; not passing them forces every reranker plugin to re-implement what the engine just did.

Reproduction

Any registerMemoryCorpusSupplement plugin that wants to rerank the engine's hits. The current MemoryCorpusSupplement.search signature provides no way to receive the engine's MemorySearchResult[].

Code reference

In the engine's memory-search tool execute path:

// Engine call
rawResults = await memory.manager.search(query, { maxResults, ... });
// ... filter / decorate / recall tracking ...

// Supplement call — happens AFTER engine's, serially
const supplementResults = shouldQuerySupplements
  ? await searchMemoryCorpusSupplements({
      query, maxResults, agentSessionKey, corpus
    })
  : [];

At the point searchMemoryCorpusSupplements is called, rawResults is fully computed and in scope. It's currently discarded for the supplement's purposes.

Proposed change

Extend the supplement signature to optionally receive the engine's results:

type MemoryCorpusSupplement = {
  search(params: {
    query: string;
    maxResults?: number;
    agentSessionKey?: string;
    // NEW: engine's just-computed candidates, available when the engine
    // already ran manager.search for this turn. Supplement can use these
    // directly (rerank / filter / annotate) instead of re-querying.
    // Optional so existing supplements keep working unchanged.
    engineCandidates?: MemorySearchResult[];
  }): Promise<MemoryCorpusSearchResult[]>;
  get(params: { /* unchanged */ }): Promise<MemoryCorpusGetResult | null>;
};

Then searchMemoryCorpusSupplements passes rawResults through:

const supplementResults = shouldQuerySupplements
  ? await searchMemoryCorpusSupplements({
      query, maxResults, agentSessionKey, corpus,
      engineCandidates: rawResults,
    })
  : [];

And the call site in searchMemoryCorpusSupplements forwards it:

await registration.supplement.search({
  ...params,
  engineCandidates: params.engineCandidates,
});

The field is optional so existing plugins (e.g., wiki-corpus supplements that produce their own results) are unaffected.

Why

Reranker plugins are a natural use case for the supplement API — bge-reranker, Cohere Rerank, custom signal mergers, etc. The current contract structurally requires them to redo work the engine already finished. With engineCandidates available, a reranker becomes:

async search({ query, engineCandidates }) {
  if (!engineCandidates?.length) return [];
  const scores = await callReranker(query, engineCandidates.map(c => c.snippet));
  return engineCandidates
    .map((c, i) => ({ ...c, corpus: 'my-reranker', score: scores[i] }))
    .sort((a, b) => b.score - a.score)
    .slice(0, maxResults);
}

vs. the current pattern which has to re-fetch candidates first.

Compatibility

engineCandidates is optional in the proposed type. Plugins that don't use it (wiki/compiled-corpus supplements that produce their own results from external sources) ignore it. Plugins that need it can opt in. No breaking change to existing plugins.

Notes

Happy to draft a PR if there's interest. I've been building a reranker plugin against the current API and have a working version (with a 3-second timeout, accepting the doubled latency); the proposed change would let it land under a 500ms budget.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - 💡(How to fix) Fix MemoryCorpusSupplement.search forced to re-run manager.search; engine already has the candidates [1 pull requests]