Skip to content

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

Open

Description

@Fengzdadi

Describe the bug

POST /v1/context/prepare embeds the query before retrieval. Since #1857 that embedding call is bounded by a module constant:

  • # 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)
  • 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: on TimeoutError in auto mode 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()
  1. 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
  2. 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\"}"
  3. 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'
  4. Change _CONTEXT_MEMORY_EMBEDDING_TIMEOUT_SECONDS to 2.0, restart the Server, and repeat step 3 on a fresh Scope.

Expected behavior

  • The query embedding inside prepare_context is 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 reason on PreparedContext like the one BootstrapContext already 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 (master 06794f66), SQLite backend, POWERCONTEXT_SERVER_ACCESS_MODE=enforced
  • Embedding openai:openai/text-embedding-3-small (1536 dims) through the OpenRouter base URL; generation openrouter: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/pi package, OpenCode 1.18.33 with the integrations/opencode plugin, Bub 0.5.1 with the integrations/bub plugin

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.

Activity

  1. jasondeng1997 commented on Oct 21, 2026

    @jasondeng1997
    Contributor

    Claiming the part #2042 left open: the reason on PreparedContext.

    #2042 landed two of the three items in your proposed fix — the runtime setting (recall_embedding_timeout_seconds, default unchanged at 0.25) and the log_safely fallback event. Your summary there says the remaining two are "a reason on PreparedContext and 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:

    • add reason to PreparedContext, nullable, mirroring BootstrapContext.reason (required list untouched so existing clients keep parsing);
    • a PreparedContextReason enum with one value per cause the log already distinguishes: the budget expiring versus the provider being unavailable;
    • carry the marker from _resolve_query_embedding through the Atomic Memory and Topic Memory search pages into the preparation, so the field is filled from the same signal that emits memory.search.embedding_fallback, not from a second guess;
    • null on 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:

    1. a preparation whose embedding budget expires returns status as before and reason="embedding_timeout";
    2. a preparation with no embedding profile configured returns reason=null — a lexical-only deployment is not degraded;
    3. the same query with a budget that fits returns reason=null and the same status, so the field cannot be used to infer quality it does not have.

    If maintainers would rather the value be a single retrieval_degraded rather 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.

  2. jasondeng1997 commented on Oct 21, 2026

    @jasondeng1997
    Contributor

    The first item in that comment is up as #2144: a nullable reason on PreparedContext with embedding_timeout / embedding_unavailable, filled from the same classification that emits the fallback event, and null on a lexical-only deployment. The default value and the recall-statistics item stay open here.

  3. jasondeng1997 commented on Oct 21, 2026

    @jasondeng1997
    Contributor

    One correction to the plan above, from #2144 review of the Hosts: the reason is opt-in ("explain": true on 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 even reason: null on 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 gets reason only when a channel was actually lost.

  4. Fengzdadi commented on Oct 24, 2026

    @Fengzdadi
    ContributorAuthor

    The explain / reason versus RFC 1435 Receipt question now lives in #2218; this issue stays open for the default recall_embedding_timeout_seconds (still 0.25 s) and the recall statistics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions