Repository navigation
bug: prepare_context abandons the query embedding after 0.25 s, so Hook recall silently degrades to lexical search with a remote embedding provider #2039
Description
Describe the bug
POST /v1/context/prepare embeds the query before retrieval. Since #1857 that embedding call is bounded by a module constant:
- (
powercontext/src/powercontext/builtin/runtime/application.py
Lines 362 to 364 in 06794f6
# Leave room for database reads and assembly within the default one-second Hook request. _CONTEXT_TOPIC_EMBEDDING_TIMEOUT_SECONDS = 0.25 _CONTEXT_MEMORY_EMBEDDING_TIMEOUT_SECONDS = 0.25 _CONTEXT_MEMORY_EMBEDDING_TIMEOUT_SECONDS = 0.25, used at L1553) - (
powercontext/src/powercontext/builtin/runtime/atomic_memory.py
Lines 639 to 665 in 06794f6
async def _resolve_query_embedding( self, query, mode, reuse, embedding_timeout_seconds, allow_embedding, *, public_search=False ): application = self.application capabilities = application.index.capabilities profile = capabilities.embedding_profile requested_mode = mode if mode == "auto": mode = "hybrid" if profile is not None else "text" if not allow_embedding and capabilities.fts: return "text", None, None, 0 if mode not in {"vector", "hybrid"}: return mode, None, None, 0 model = application.embedding_model if profile is None or model is None or model.profile != profile: raise AtomicMemoryIndexError("embedding-profile", "Requested vector retrieval is unavailable") if reuse is not None and reuse.embedding_profile == profile: return mode, reuse.query_vector, reuse, 0 try: async with asyncio.timeout(embedding_timeout_seconds): result = await embed_query(model, (query,)) except (InferenceUnavailableError, InferenceTimeoutError, TimeoutError) as exc: if public_search and isinstance(exc, TimeoutError) and not isinstance(exc, InferenceTimeoutError): raise InferenceUnavailableError("embedding", "Atomic query embedding timed out") from exc if requested_mode != "auto" or not capabilities.fts: raise return "text", None, None, 1 _resolve_query_embedding: onTimeoutErrorinautomode with FTS available it returns"text"mode and the preparation continues with lexical retrieval only; nothing is logged and the response does not say so)
A remote embedding round trip is longer than 250 ms. From the Server host, openai/text-embedding-3-small through OpenRouter measured 0.27–0.41 s per call (five calls each in two samples half an hour apart); #1700 measured 315–1019 ms. So whenever the Server uses a remote embedding provider, the Hook's prepare request loses the semantic path on most calls and keeps only lexical retrieval. The lexical path applies the admission floor (25 % of the query terms matched, at least 2 terms). A Hook query is the whole user prompt; a prompt that carries task instructions next to the question fails the floor, and the preparation returns status: "empty" although the Scope holds the matching Memory.
The failure is a race, not a constant: ten replays of the same prepare request on the same Scope returned ready 3/10 on one Scope and 7/10 on another, each call taking 0.27–0.30 s. With a fixed 300 ms delay in front of the provider it is 0/10; with the constant raised to 2.0 s it is 10/10 (both tables below).
Downstream effect, measured with the e2e/bub paired OFF/ON harness (project-decision-continuation, Pi 0.82.1 and OpenCode 1.18.33, qwen/qwen3.7-flash as the agent model): in 3 of 4 ON recall sessions the Hook injected nothing (ready_preparations 0, recalled_tokens 0) and the model recovered by calling pc_search, because /v1/memory/search has no such cap (it waits the configured inference embedding timeout). That rescue costs one extra model call per session: recall input tokens 56.4k vs 37.7k on Pi (+18.7k), 42.6k vs 28.3k on OpenCode (+14k). Pass rates hide the problem because the model's own search still finds the Memory. The Bub plugin, which prepares context before every model call, got ready on 3 of its 7 preparations in the same run.
Regression context: #1700 (closes #1699) raised the host Hook budgets to 3 s request / 6 s total precisely so that a prepare round trip can include a remote embedding, and reported Hook stability going from 13/20 to 20/20. #1857 then introduced the Server-side 0.25 s cap on the new Atomic Memory recall path, where the Hook budget cannot help. The constant's comment says "within the default one-second Hook request"; today the Pi, OpenCode, Codex and Claude Code Hooks use 3 s requests, ZCode 1 s, Bub 10 s.
Steps to reproduce
Deterministic version: put a fixed 300 ms delay in front of the embedding provider so the result does not depend on network conditions. delay_proxy.py forwards OpenAI-compatible requests to UPSTREAM after DELAY_MS:
import os, sys, time, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
UPSTREAM = os.environ.get("UPSTREAM", "https://openrouter.ai/api/v1").rstrip("/")
DELAY = float(os.environ.get("DELAY_MS", "300")) / 1000.0
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length") or 0))
time.sleep(DELAY)
req = urllib.request.Request(UPSTREAM + self.path, data=body, method="POST")
for name in ("Authorization", "Content-Type"):
if self.headers.get(name):
req.add_header(name, self.headers[name])
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data, status = resp.read(), resp.status
except urllib.error.HTTPError as err:
data, status = err.read(), err.code
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_forever()-
Start the proxy and a Server whose embedding base URL points at it (any remote provider works; a local embedding service that answers in under 250 ms does not show the problem):
DELAY_MS=300 UPSTREAM=https://openrouter.ai/api/v1 python3 delay_proxy.py 8111 & export POWERCONTEXT_SERVER_ACCESS_MODE=enforced export POWERCONTEXT_SERVER_AUTH_TOKEN=replace-me export POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL=openrouter:deepseek/deepseek-v4-pro export POWERCONTEXT_SERVER_INFERENCE_GENERATION_TIMEOUT_SECONDS=120 export POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_MODEL=openai:openai/text-embedding-3-small export POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_BASE_URL=http://127.0.0.1:8111 export POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_PROFILE_ID=openrouter-text-embedding-3-small-1536 export POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_DIMENSION=1536 export OPENAI_API_KEY=<openrouter key> powercontext server run --no-env-file --host 127.0.0.1 --port 8000
-
Create a Scope, capture one Source that states a fact, and flush it into Memory:
H=(-H "Authorization: Bearer replace-me" -H "Content-Type: application/json") SCOPE=$(curl -s -X POST http://127.0.0.1:8000/v1/scopes "${H[@]}" -d '{"title":"prepare timeout repro","summary":"one fact, one flush","idempotency_key":"repro-1"}' | jq -r .scope_id) curl -s -X POST http://127.0.0.1:8000/v1/sources/content "${H[@]}" -d "{\"scope_id\":\"$SCOPE\",\"source_id\":\"repro-1-src\",\"content\":\"Some context before we start: last week the team decided that the ticket service database will run on OceanBase with the shard count fixed at 12, because it has to stay compatible with our existing MySQL clients. No action is needed on that yet. For now, please fix the spelling mistake recieve in README.md.\"}" curl -s -X POST http://127.0.0.1:8000/v1/memory/flush "${H[@]}" -d "{\"scope_id\":\"$SCOPE\"}"
-
Prepare context ten times with the prompt a user would type in the next session, and three times with a short query:
LONG='Which database did we decide to use for the ticket service, and what shard count did we agree on? Write your answer to /workspace/answer.json as a JSON object with only two keys, a `database` string and a `shard_count` integer, for example {"database": "<name>", "shard_count": <number>}. Use null for any value you do not know.' for i in $(seq 1 10); do jq -n --arg s "$SCOPE" --arg q "$LONG" '{scope_id:$s, query:$q, max_bytes:8000}' \ | curl -s -o /tmp/prepare.json -w 'time %{time_total}s ' -X POST http://127.0.0.1:8000/v1/context/prepare "${H[@]}" -d @- jq -r '.status' /tmp/prepare.json done jq -n --arg s "$SCOPE" '{scope_id:$s, query:"ticket service database shard count", max_bytes:8000}' \ | curl -s -X POST http://127.0.0.1:8000/v1/context/prepare "${H[@]}" -d @- | jq -r '.status'
-
Change
_CONTEXT_MEMORY_EMBEDDING_TIMEOUT_SECONDSto2.0, restart the Server, and repeat step 3 on a fresh Scope.
Expected behavior
- The query embedding inside
prepare_contextis given a budget that a remote provider can meet, consistent with the Hook budgets fix(recall): honor the query contract bound and the remote embedding budget #1700 set (3 s request / 6 s total on most hosts): either a runtime setting with a default that fits the smallest supported Hook request, or a budget derived from the Hook request itself. - When retrieval does degrade, the preparation says so: a
reasononPreparedContextlike the oneBootstrapContextalready carries, an operational log event, and recall statistics that separate "nothing matched" from "matched without the semantic path".
Actual behavior
Same Scope, same long query, ten consecutive POST /v1/context/prepare calls:
| Server | embedding path | status | time per call |
|---|---|---|---|
| master, 0.25 s | OpenRouter direct, 0.27–0.41 s | ready 3/10 and 7/10 on two Scopes |
0.27–0.30 s |
| master, 0.25 s | OpenRouter direct, a later sample answering in about 0.2 s | ready 10/10 |
0.22–0.28 s |
| master, 0.25 s | OpenRouter through a 300 ms delay | ready 0/10 |
0.28–0.29 s |
| master with the constant at 2.0 s | OpenRouter through a 300 ms delay | ready 10/10 |
0.55–0.69 s |
The outcome follows the provider's latency of the moment, which is the point. The short query ticket service database shard count returns ready on every Server, because it passes the lexical floor. The Server log shows only operation="prepare_context" outcome="success" lines of 285–300 ms for the degraded calls; nothing records that the embedding was abandoned, and POST /v1/stats counts the preparation as a plain empty one.
Scope statistics for a paired ON run on master (recall session): preparations 2, ready_preparations 0, recalled_tokens 0, Memory present, memory_recall embedding requests 1 of 3 retrieval operations. With the 2.0 s constant: ready_preparations 1, recalled_tokens 180–189, and the agent answered without calling pc_search.
Environment
- PowerContext
1.3.1.dev87(master06794f66), SQLite backend,POWERCONTEXT_SERVER_ACCESS_MODE=enforced - Embedding
openai:openai/text-embedding-3-small(1536 dims) through the OpenRouter base URL; generationopenrouter:deepseek/deepseek-v4-pro - Python 3.13, macOS (Darwin 25.4), Docker via OrbStack for the host containers
- Hosts in the paired runs: Pi 0.82.1 with the
integrations/pipackage, OpenCode 1.18.33 with theintegrations/opencodeplugin, Bub 0.5.1 with theintegrations/bubplugin
Are you willing to submit a PR to fix this bug?
Yes. The smallest change I would propose: a runtime setting POWERCONTEXT_SERVER_RUNTIME_RECALL_EMBEDDING_TIMEOUT_SECONDS (default sized to the Hook budgets, applied to both the Memory and Topic Memory constants), a reason field on PreparedContext for the degraded case, and one log_safely event when _resolve_query_embedding falls back to text mode. Happy to adjust to whatever shape maintainers prefer.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTodo
Activity
fix(runtime): make the recall embedding budget configurable and log i…
jasondeng1997 commentedon Oct 21, 2026
Claiming the part #2042 left open: the
reasononPreparedContext.#2042 landed two of the three items in your proposed fix — the runtime setting (
recall_embedding_timeout_seconds, default unchanged at 0.25) and thelog_safelyfallback event. Your summary there says the remaining two are "areasononPreparedContextand a decision on the default value", so this is scoped to the first one only. I am not proposing to change the default in the same PR; that is a product decision and mixing it in would make the diff argue with itself.What I intend to change:
reasontoPreparedContext, nullable, mirroringBootstrapContext.reason(requiredlist untouched so existing clients keep parsing);PreparedContextReasonenum with one value per cause the log already distinguishes: the budget expiring versus the provider being unavailable;_resolve_query_embeddingthrough the Atomic Memory and Topic Memory search pages into the preparation, so the field is filled from the same signal that emitsmemory.search.embedding_fallback, not from a second guess;nullon every preparation that kept the semantic path, and on every preparation that never wanted it (mode="text", or no embedding profile configured).Acceptance I will assert in tests:
statusas before andreason="embedding_timeout";reason=null— a lexical-only deployment is not degraded;reason=nulland the samestatus, so the field cannot be used to infer quality it does not have.If maintainers would rather the value be a single
retrieval_degradedrather than per-cause, or prefer it inside an existing diagnostics struct instead of a new field, say so before review and I will reshape it — the plumbing is the same either way.jasondeng1997 commentedon Oct 21, 2026
The first item in that comment is up as #2144: a nullable
reasononPreparedContextwithembedding_timeout/embedding_unavailable, filled from the same classification that emits the fallback event, andnullon a lexical-only deployment. The default value and the recall-statistics item stay open here.feat(context): say why a preparation lost its semantic recall path (o…
feat(context): say why a preparation lost its semantic recall path (o…
feat(context): report why a preparation lost its semantic recall path (…
feat(context): report why a preparation lost its semantic recall path (…
feat(context): report why a preparation lost its semantic recall path (…
jasondeng1997 commentedon Oct 21, 2026
One correction to the plan above, from #2144 review of the Hosts: the
reasonis opt-in ("explain": trueon the request) rather than always present. Nine Host validators compare the prepared-context key set exactly (zcode,opencode,dsh,pi,claude-code,codex,workbuddy,kimi-code,openclaw), so evenreason: nullon an ordinary response makes them reject every preparation and drop the injected context — RFC 1435 says the same: the injection value stays four fields, explainability is opt-in. A request that does not explain gets a byte-identical envelope, and the request that does getsreasononly when a channel was actually lost.feat(context): report why a preparation lost its semantic recall path (…
Fengzdadi commentedon Oct 24, 2026
The
explain/reasonversus RFC 1435 Receipt question now lives in #2218; this issue stays open for the defaultrecall_embedding_timeout_seconds(still 0.25 s) and the recall statistics.fix(context): bound a preparation's query embedding waits by one budget