Skip to content

Commit 37c025e

Browse files
committed
fix(memory): run extended-memory predictor concurrently with rerank
The per-turn recall has a hard 5s budget, and queryAtomsWithPrediction ran two LLM calls sequentially: the paid rerank of literal-query results followed by predictor.Predict. A slow memory LLM (by default the main reasoning model) let the rerank consume most of the window, so Predict inherited a nearly-expired context and failed immediately: extended memory: predictor LLM failed: context deadline exceeded extended memory: predicted-intent generation failed: predictor: context deadline exceeded Run the predictor concurrently with the literal query instead, so both LLM calls share the full window in parallel. The predictor only touches the LLM (no shared state) and the store/index are mutex-guarded; the result channel is buffered so the early-error path cannot leak the goroutine. Adds TestPredictionRunsConcurrentWithRerank, which deadlocks under the old sequential behavior.
1 parent fbe2045 commit 37c025e

2 files changed

Lines changed: 98 additions & 3 deletions

File tree

internal/memory/extended/recall.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,26 @@ func (r *Recall) trackErr(err error) {
112112
// blended ranking with a pure retention ordering.
113113
func (r *Recall) queryAtomsWithPrediction(ctx context.Context, query string, recent []string, state UserState) ([]MemoryAtom, error) {
114114
all := make(map[string]scoredAtomMeta)
115+
116+
// Run intent prediction concurrently with the literal query (which may
117+
// include a paid LLM rerank). Sequentially, the rerank can consume most of
118+
// the recall context deadline, starving the predictor of budget and making
119+
// it fail with context-deadline-exceeded before its LLM call even starts.
120+
predicting := r.predictor != nil && r.cfg.PredictiveIntents > 0 &&
121+
r.cfg.FollowUpAnticipationEnabled != nil && *r.cfg.FollowUpAnticipationEnabled
122+
type predictResult struct {
123+
intents []PredictedIntent
124+
err error
125+
}
126+
var predCh chan predictResult
127+
if predicting {
128+
predCh = make(chan predictResult, 1)
129+
go func() {
130+
intents, err := r.predictor.Predict(ctx, query, recent, state)
131+
predCh <- predictResult{intents, err}
132+
}()
133+
}
134+
115135
literal, err := r.queryAtomsScored(ctx, query, false)
116136
if err != nil {
117137
r.trackErr(err)
@@ -121,9 +141,9 @@ func (r *Recall) queryAtomsWithPrediction(ctx context.Context, query string, rec
121141
all[s.atom.ID] = s
122142
}
123143

124-
if r.predictor != nil && r.cfg.PredictiveIntents > 0 &&
125-
r.cfg.FollowUpAnticipationEnabled != nil && *r.cfg.FollowUpAnticipationEnabled {
126-
intents, err := r.predictor.Predict(ctx, query, recent, state)
144+
if predicting {
145+
res := <-predCh
146+
intents, err := res.intents, res.err
127147
if err != nil {
128148
r.trackErr(err)
129149
log.Printf("extended memory: predicted-intent generation failed: %v", err)

internal/memory/extended/recall_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"strings"
7+
"sync"
78
"testing"
89
"time"
910

@@ -309,6 +310,80 @@ func TestQueryAtomsWithPredictionCompositeOrdering(t *testing.T) {
309310
}
310311
}
311312

313+
// rerankGateLLM blocks its SimpleCall until the predictor has started,
314+
// simulating a slow rerank that would otherwise consume the recall deadline.
315+
type rerankGateLLM struct {
316+
wait <-chan struct{}
317+
}
318+
319+
func (m *rerankGateLLM) SimpleCall(ctx context.Context, _, _ string) (string, error) {
320+
select {
321+
case <-m.wait:
322+
return "", nil
323+
case <-ctx.Done():
324+
return "", ctx.Err()
325+
}
326+
}
327+
328+
// predictorSignalLLM closes the channel on its first call so the test can
329+
// observe that prediction started while the rerank was still blocked.
330+
type predictorSignalLLM struct {
331+
ch chan struct{}
332+
once sync.Once
333+
resp string
334+
}
335+
336+
func (m *predictorSignalLLM) SimpleCall(_ context.Context, _, _ string) (string, error) {
337+
m.once.Do(func() { close(m.ch) })
338+
return m.resp, nil
339+
}
340+
341+
// TestPredictionRunsConcurrentWithRerank is a regression test for the
342+
// "predictor: context deadline exceeded" failure: the predictor must run
343+
// concurrently with the literal query (including its paid rerank) so a slow
344+
// rerank cannot consume the shared recall deadline before prediction starts.
345+
// Under the old sequential behavior the rerank gate never opens and the
346+
// recall deadlocks, failing this test via the outer timeout.
347+
func TestPredictionRunsConcurrentWithRerank(t *testing.T) {
348+
dir := t.TempDir()
349+
cfg := DefaultConfig()
350+
cfg.Enabled = boolPtr(true)
351+
cfg.SemanticSearchRerank = boolPtr(true)
352+
cfg.SemanticSearchMinScore = 0.01
353+
cfg.PredictiveIntents = 1
354+
cfg.FollowUpAnticipationEnabled = boolPtr(true)
355+
// The mock embedder rates these two English sentences as near-duplicates;
356+
// disable semantic dedup so both atoms reach the rerank path.
357+
cfg.SemanticDedupThreshold = floatPtr(0)
358+
359+
predictorStarted := make(chan struct{})
360+
em := New(dir, &rerankGateLLM{wait: predictorStarted}, cfg)
361+
em.index.newEmb = func() embedding.TextEmbedder { return newMockEmbedder(vectorDim) }
362+
em.index.emb = newMockEmbedder(vectorDim)
363+
em.recall.predictor = NewPredictor(&predictorSignalLLM{
364+
ch: predictorStarted,
365+
resp: `[{"text":"follow-up question","confidence":0.9}]`,
366+
}, cfg)
367+
defer em.Close()
368+
369+
_ = em.AddAtom(context.Background(), makeSearchableAtom("User prefers Go for backend services"))
370+
_ = em.AddAtom(context.Background(), makeSearchableAtom("Run go test ./... to verify"))
371+
372+
done := make(chan error, 1)
373+
go func() {
374+
_, err := em.recall.queryAtomsWithPrediction(context.Background(), "Go backend", nil, UserState{})
375+
done <- err
376+
}()
377+
select {
378+
case err := <-done:
379+
if err != nil {
380+
t.Fatalf("queryAtomsWithPrediction failed: %v", err)
381+
}
382+
case <-time.After(10 * time.Second):
383+
t.Fatal("recall deadlocked: predictor did not run concurrently with the rerank")
384+
}
385+
}
386+
312387
// newIntentWeightedRecallEM builds an enabled ExtendedMemory whose recall
313388
// uses the mock embedder, no rerank, and prediction enabled. The corpus is
314389
// two atoms with (almost) disjoint character sets so the literal query

0 commit comments

Comments
 (0)