Repository navigation
fix(workbuddy): reduce the host prompt to the submitted turn before recall - #1703
Conversation
…ecall WorkBuddy submits every user message of the session joined into one prompt, and each of those messages carries its injected context block. For one real session the joined prompt measured 339526 characters and 413769 bytes, far beyond the 8192 character bound of the request's query. The hook passed that text through as the retrieval query, so `/v1/context/prepare` rejected it and the recall came back empty without reporting anything. Retrieve with the trailing `<user_query>` element instead. It holds the turn being submitted, and that turn is what the session's earlier memories should be retrieved against. A prompt the element cannot be read from falls back to the joined text trimmed to the request bounds, so the hook keeps working against a host that changes shape. Report a reduction on stderr: the recall itself fails open, so a query that was silently rewritten is otherwise invisible.
…ecall Review of oceanbase#1703 reproduced two defects in the reduction added there. `_last_user_query` matched the tags wherever they occur, so a prompt quoting this markup - a user pasting it, or a summary the host writes about this code - was read as the wrapper and sent a fragment of the quoting sentence as the query. A pair opening before the current question also dropped that question entirely. The element is now accepted only where its boundaries match the host's wrapper: the opening tag starts a line and the closing tag ends the message, with the start of the next host block or the end of the prompt after it. Candidates are tried from the end backwards, so a later block that quotes the tags no longer hides the turn before it. A prompt no verified element can be read from falls back to the bounded joined text. The `and query` guard added to the recall also gated the Source capture and the optional flush, so a turn reducing to nothing silently skipped the Source write. Capture is back under the `scope_id` guard and only the recall depends on a usable query. Replaying the session transcript of oceanbase#1702 with the tags quoted in a later block: the query went from `"`、`_USER_QUERY_CLOSE="` to `缺陷 1 的 hook 侧防御也顺手做`, the turn the hook retrieved against when the reduction was verified. The new cases in `tests/workbuddy_plugin/test_hook.py` fail against the previous reduction.
…block Self-review of the boundary check added here found a false positive left in it. A pair followed by anything starting with `<` was accepted, but a pair quoted inside another element is followed by that element's own closing tag. Enumerating all 19 candidate pairs of the session transcript of oceanbase#1702, 9 of them - every turn the cb_summary blocks quote - passed the check. That transcript still resolved to the submitted turn because the last verified pair happened to come after them, so the difference only shows when a block that quotes the tags arrives after the submitted turn, which is the usual shape once a session gets long enough to be summarised. The check now requires the next non-whitespace content after the closing tag to be an opening tag or the end of the prompt. That separates all 19 pairs of that transcript and both pairs of the current session: the 5 real wrappers are followed by the next host block, the 9 quoted pairs by `</previous_user_message>`. Walking the remainder by index also replaces the `str.strip` and `str.lstrip` copies made once per candidate. On a 411 KB prompt carrying 2,000 pairs the reduction takes 0.51 ms.
Self-review of the boundary checkI re-read the check added in The hole. The check accepted any pair followed by content starting with That transcript still sent the right query only because the last verified pair happened to come after them. Once a session is long enough to be summarised, a block that quotes the tags is exactly what lands after the submitted turn, so the ordering that saved this transcript is not something to rely on. My earlier reply reported this transcript as working, which was true of the outcome and wrong about the rule. The fix (
What the check still cannot separate. A pair that takes a line of its own and is followed by an opening tag is structurally indistinguishable from a wrapper, so it is still read as one. Reaching that needs a block that quotes the markup and leaves its own element unclosed straight after the quote; neither transcript produces it, and when it does happen the fallback carries the whole turn. The host's injected Also measured. Walking the remainder by index replaces the Replayed against the running server, the reduction sends the submitted turn (28 characters) and DocsThe One piece of drift in the same table that I left alone: |
The Runtime behavior list described Recall as sending the prompt to `/v1/context/prepare`, which stopped being true when the reduction landed, and the diagnostics bullet claimed status and byte counts only after the `query_reduction` event added a source and a truncation flag.
…nt-fix # Conflicts: # tests/workbuddy_plugin/__init__.py # tests/workbuddy_plugin/conftest.py # tests/workbuddy_plugin/test_hook.py
The reduction accepted an opening tag as the host wrapper as soon as any closing tag after it reached a message boundary. A pair the turn quotes is nested, or left unclosed, and then a literal opener paired with the host's own closing tag, so the text between the two became the query. On the fenced example from the review the hook sent `example</user_query>` followed by the example's closing fence instead of the turn, and an unclosed literal opener gave the turn up to the bounded fallback, whose tail is the injected context block rather than the turn. A candidate now resolves its own close, counting nested pairs as turn content, and a candidate enclosed by an earlier one gives way to its container. Reading the outer element covers the case the previous revision left out, where a quoted pair ends exactly where the next host block opens and no boundary test can separate it from the wrapper. An element no close balances still ends at the first close that reaches a message boundary, so a turn whose literal opener is never closed stays whole. Constraint: WorkBuddy joins every user message of the session into one prompt Scope-risk: narrow Tested: uv run --no-sync pytest tests/workbuddy_plugin tests/claude_code_plugin tests/codex_plugin tests/e2e/test_workbuddy_service_chain.py tests/test_cli_workbuddy.py -q Tested: uv run --no-sync ruff check integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py tests/workbuddy_plugin Tested: uv run --no-sync ruff format --check integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py tests/workbuddy_plugin Tested: uv run --no-sync ty check --python-version 3.11 integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py tests/workbuddy_plugin Tested: uv run --no-sync pytest tests/workbuddy_plugin -q against the previous hook: the three new regressions fail
|
@Teingi Conflicts resolved in |
WorkBuddy can append cb_summary blocks after the submitted user_query, so the recall reducer must treat them as host-level boundaries instead of falling back to the joined transcript tail. Constraint: WorkBuddy joined prompts can include post-turn compact summaries Scope-risk: narrow Tested: uv run --no-sync pytest tests/workbuddy_plugin/test_hook.py::test_recall_query_keeps_the_current_turn_when_cb_summary_follows_it -q Tested: uv run --no-sync pytest tests/workbuddy_plugin/test_hook.py -q -k not-drip filter Tested: uv run --no-sync ruff check integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py tests/workbuddy_plugin/test_hook.py Tested: uv run --no-sync ruff format --check integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py tests/workbuddy_plugin/test_hook.py Tested: uv run --no-sync ty check --python-version 3.11 integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py tests/workbuddy_plugin/test_hook.py Not-tested: tests/workbuddy_plugin/test_hook.py::test_prompt_hook_fails_open_within_the_http_budget_when_recall_reads_drip is failing locally on Windows with elapsed 6.59s before this change path Co-authored-by: OmX <omx@oh-my-codex.dev>
Which issue or RFC does this PR close?
Closes #1702.
Rationale for this change
WorkBuddy submits every user message of the session joined into one prompt, rather than the turn being submitted (#1702). Each of those messages carries its injected context block, so for one real session the joined prompt measured 339,526 characters and 413,769 bytes against a query bound of 8,192 characters. The hook passed that text through as the retrieval query,
/v1/context/prepareanswered 422, and the recall returnedadditionalContext: "".The hook fails open, so none of that is visible: an empty recall looks exactly like a scope that holds no memory. Retrieving with the whole session also embeds the wrong text, because the earlier turns dilute the query the session should actually be recalled against.
What changes are included in this PR?
_recall_queryreduces the host prompt before it becomes a retrieval query:<user_query>element, which holds the turn being submitted, and only where its boundaries match the host's wrapper. The host gives the element a line of its own and closes it where the message ends, so what follows its closing tag is the next host block, which opens there, or the end of the prompt. The same tags reach the prompt verbatim wherever a user or a host-written block quotes them, and a quoted pair is inside a sentence or inside another element instead, which leaves the sentence's own text or an enclosing closing tag on one side of it. Candidates are read from the end of the prompt backwards, because the host appends messages that carry no turn of their own, such as task notifications.The recall is the only path that depends on a usable query: a turn that reduces to nothing asks nothing of memory, so it retrieves nothing.
The prompt capture path is unchanged. It still stores the full prompt, because a Source records what the host submitted rather than what was retrieved against, and it stays under the
scope_idguard so a turn with no query still writes its Source.tests/workbuddy_plugin/is new and mirrors the existingclaude_code_pluginandcodex_pluginlayout, so the WorkBuddy hook gets focused tests of its own.Are there any user-facing changes?
query_reductionevent.How was this change tested?
tests/workbuddy_plugin/test_hook.pycovers the reduction through the hook's observable output, which is the query actually sent to/v1/context/prepare:<user_query>element: the query stays inside both bounds, keeps the most recent text, and reportstruncated: true.Each case fails when
_recall_contextis given the whole prompt instead of the reduced query, or when the reduction reads an unverified pair, so they pin the behavior rather than the implementation.Replaying the real failing submission against a running server:
additionalContext""Replaying the same session with a current transcript, where a later host block quotes the tags verbatim (347,475 chars / 423,732 bytes), the query sent is the submitted turn rather than a fragment of that block.
Replaying a live session against the running server, the query sent is the submitted turn (28 characters) and
/v1/context/prepareanswers 200. The prepared context there is empty because that Scope holds two entries and neither is about the turn.Commands run:
→ 294 passed, 4 skipped.
AI usage statement
Built with WorkBuddy, an agentic coding assistant. The host behavior was confirmed by recording a real hook payload and reading the WorkBuddy CLI bundle, and the before and after measurements come from replaying that payload against a locally running server.