Skip to content

fix(recall): honor the query contract bound and the remote embedding budget - #1700

Merged
Teingi merged 2 commits into
oceanbase:masterfrom
AlexStocks:fix/recall-query-contract-bound
Sep 21, 2026
Merged

Teingi merged 2 commits into
oceanbase:masterfrom
AlexStocks:fix/recall-query-contract-bound

Conversation

@AlexStocks

Copy link
Copy Markdown
Contributor

Which issue or RFC does this PR close?

Closes #1699.

Rationale for this change

Two independent defects kept the host hooks from recalling anything for non-ASCII users. Both fail open, so neither produced a user-visible error.

1. Retrieval queries were held to the storage layer's byte bound. MemoryService.search normalized a transient query through normalize_text, the normalizer for a durable entry body. Its 8192 UTF-8 byte limit therefore applied to queries the contract accepts at up to 8192 characters (#1699). A 2731 character CJK query is 8193 bytes, so both /v1/memory/search and /v1/context/prepare answered HTTP 500 to input the contract declares valid.

The bare _CanonicalValueError that escaped matched no handler and fell through to the generic one. The remember path already wraps the same failure as InvalidMemoryCandidateError, which the server maps to 422 — that asymmetry is why remember answers 422 while search answered 500 for the same class of input.

2. The hook request budget was too tight for a remote embedding round trip. Preparing context embeds the query. Against a remote provider the round trip measured 315–1019 ms, while the plugins defaulted to a 1.0 s per-request timeout, so _prepare_context failed on 7 of 20 calls at the ceiling. The hook fails open, so the tighter budget showed up as an empty recall rather than an error.

What changes are included in this PR?

Memory normalization

  • Add normalize_query in artifacts/memory/canonical.py. A query is never persisted, so it does not carry the durable entry body's byte bound; the function normalizes and rejects empty input only. The HTTP contract keeps its own bound, which is stated in characters.
  • MemoryService.search uses it and wraps failures as _InvalidMemoryOperationError("search-query"), so an over-long query stays a 422 instead of a 500.

Host plugin budgets

  • Raise the default request_timeout_seconds from 1.0 to 3.0 and http_budget_seconds from 4.0 to 6.0 in the workbuddy, claude-code, and codex plugins, which share the setting. 3.0 s is roughly three times the slowest observed round trip; the larger total budget covers the preceding scope resolution.

The HTTP contract is unchanged: maxLength: 8192 was always a character bound, and the implementation now agrees with it.

Are there any user-facing changes?

  • A query inside the 8192 character contract bound is now accepted instead of answering HTTP 500. An over-long query answers 422, consistent with POST /v1/memory/remember.
  • Host hooks recall more reliably, because the default request budget now covers a remote embedding round trip.
  • No API shape, persisted format, or migration change. The plugin timeouts keep their existing environment variable overrides.

How was this change tested?

Two regression tests, both confirmed to fail against the previous behavior by temporarily restoring normalize_text in the search path:

  • tests/builtin/artifacts/memory/test_canonical.py::test_retrieval_query_is_not_held_to_the_entry_body_byte_bound covers normalize_query directly.
  • tests/e2e/test_context_text_assembly.py::test_multibyte_query_stays_within_the_contract_bound_for_search_and_prepared_context drives a 2731 character CJK query through both /v1/memory/search and /v1/context/prepare against a live server, and asserts the character/byte split that used to fail.

End-to-end against a local server, before and after:

query characters UTF-8 bytes before after
ASCII × 9000 9000 9000 422 422
CJK × 2731 2731 8193 500 200
CJK × 2800 2800 8400 500 200
CJK × 5000 5000 15000 500 200

The 15000-byte query returns "mode": "hybrid", so retrieval including the remote embedding call completes.

Hook stability over 20 consecutive _prepare_context calls against a remote embedding provider: 13/20 before, 20/20 after.

Commands run:

uv run ruff check <changed files>
uv run ruff format --check <changed files>
uv run ty check --python-version 3.11 <changed files>
uv run pytest tests/builtin/artifacts/memory tests/e2e/test_context_text_assembly.py tests/claude_code_plugin tests/codex_plugin

AI usage statement

Built with WorkBuddy, an agentic coding assistant. The failing behavior, root cause, and before/after measurements were verified against a locally running server, and the regression tests were confirmed to fail without the fix.

… byte bound

The retrieval path normalized a transient query through `normalize_text`, the
normalizer for a durable entry body, so the storage layer's 8192 UTF-8 byte bound
applied to queries the HTTP contract accepts at up to 8192 characters. A 2731
character CJK query is 8193 bytes, so `/v1/memory/search` and
`/v1/context/prepare` answered an internal Server error to input the contract
declares valid.

The remember path already wraps canonical failures as
`InvalidMemoryCandidateError`, which the server maps to 422. Search raised the
bare canonical error, which matched no handler and fell through to the generic
one as 500.

Add `normalize_query`, which only normalizes and rejects empty input, and wrap its
failures as an invalid memory operation so an over-long query stays a 422.
…quest budget

The host plugins default to a 1.0 s per-request timeout inside a 4.0 s total
budget. Preparing context needs the query embedded, and a remote provider round
trip measured 315-1019 ms, so `_prepare_context` failed on 7 of 20 calls at the
1.0 s ceiling. The hook fails open, so the shorter budget showed up as an empty
recall rather than an error.

Raise the request timeout to 3.0 s, roughly three times the slowest observed round
trip, and the total budget to 6.0 s to leave room for the preceding scope
resolution. Apply the same defaults to the workbuddy, claude-code, and codex
plugins, which share the setting.
@AlexStocks

Copy link
Copy Markdown
Contributor Author

Context for reviewers: this PR covers the server side of the empty-recall symptom. The host side is #1702, fixed in #1703 — WorkBuddy joins the whole session into the hook prompt, which exceeds the query bound regardless of how the server measures it. The two changes are independent and can land in either order.

@Teingi
Teingi merged commit 61ebcd8 into oceanbase:master Sep 21, 2026
22 checks passed
PsiACE pushed a commit that referenced this pull request Sep 30, 2026
…g budget (#1806)

#1700 raised the hook request timeout from 1.0 s to 3.0 s and the total
budget from 4.0 s to 6.0 s in the Python plugins (Codex, Claude Code,
WorkBuddy), because preparing context embeds the query and a remote
embedding round trip can exceed one second. The TypeScript Pi and
OpenCode plugins kept 1000 ms and 4000 ms, so their recall still fails
open to an empty result when the round trip is slow.

Apply the same defaults to both, and update the Pi configuration tables.
Rebuilding the OpenCode bundle also picks up `get_memory_capacity`, which
its generated operations gained in 4f5e36e without a rebuild.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-ASCII retrieval queries answer 500 inside the contract length bound

1 participant