Skip to content

fix(workbuddy): reduce the host prompt to the submitted turn before recall - #1703

Merged
Teingi merged 8 commits into
oceanbase:masterfrom
AlexStocks:fix/workbuddy-hook-query-reduction
Sep 24, 2026
Merged

Teingi merged 8 commits into
oceanbase:masterfrom
AlexStocks:fix/workbuddy-hook-query-reduction

Conversation

@AlexStocks

@AlexStocks AlexStocks commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

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/prepare answered 422, and the recall returned additionalContext: "".

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_query reduces the host prompt before it becomes a retrieval query:

  • Read the most recent <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.
  • Fall back to the joined text when no verified element can be read, so a host that changes shape still gets a query rather than a failure.
  • Trim a fallback query to the request bounds, keeping the most recent text. The character bound comes from the request contract; the byte bound is the hook's own margin, so the query stays acceptable to a server that still measures the storage layer limit as well.
  • Report a reduction on stderr. The recall fails open, so a query that was silently rewritten is otherwise invisible.

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_id guard so a turn with no query still writes its Source.

tests/workbuddy_plugin/ is new and mirrors the existing claude_code_plugin and codex_plugin layout, so the WorkBuddy hook gets focused tests of its own.

Are there any user-facing changes?

  • The WorkBuddy hook recalls memory in sessions whose joined prompt exceeds the query bound, which previously meant every session carrying an injected context block.
  • A reduction is reported on stderr as a query_reduction event.
  • A turn that reduces to nothing no longer recalls. The Source write is unaffected.
  • No API, persisted format, or configuration change.

How was this change tested?

tests/workbuddy_plugin/test_hook.py covers the reduction through the hook's observable output, which is the query actually sent to /v1/context/prepare:

  • A transcript of four earlier turns plus the submitted one, over 8,192 characters: the query sent is the submitted turn, and the prepared context is injected.
  • A prompt with no <user_query> element: the query stays inside both bounds, keeps the most recent text, and reports truncated: true.
  • A non-ASCII prompt: both the character and the byte bound hold.
  • A plain prompt: passed through unchanged, with no stderr event.
  • A prompt quoting the tags in prose: passed through unchanged, because the pair is not a host wrapper.
  • An unmarked turn followed by the current question: the query keeps the current question rather than the turn before it.
  • A host block after the turn quoting the tags: the query is the turn before the block, not the quoted pair.
  • A host summary quoting a turn on a line of its own: the query is the turn in hand, not the question the summary quotes.
  • A wrapped turn that itself quotes the tags: the whole turn survives in the bounded fallback instead of a fragment.
  • A turn that reduces to nothing: no query is sent and the full prompt is still captured.

Each case fails when _recall_context is 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:

before after
joined prompt 339,526 chars / 413,769 bytes unchanged
query sent whole prompt, HTTP 422 19 chars (the submitted turn)
additionalContext "" 697 characters of recalled memory

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/prepare answers 200. The prepared context there is empty because that Scope holds two entries and neither is about the turn.

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/workbuddy_plugin tests/claude_code_plugin tests/codex_plugin tests/e2e/test_workbuddy_service_chain.py

→ 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.

…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.
@AlexStocks

AlexStocks commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Self-review of the boundary check

I re-read the check added in 55ea8a45 and found a false positive still in it.

The hole. The check accepted any pair followed by content starting with <, but a pair quoted inside another element is followed by that element's own closing tag. Enumerating all 19 candidate pairs of the #1702 session transcript, 14 passed and 9 of those are quoted pairs — every turn the cb_summary blocks quote:

#01  接受  next=<task-notification>       content='缺陷 1 的 hook 侧防御也顺手做'   ← 真包裹
#02  接受  next=<task-notification>       content='你先提个 PR'                     ← 真包裹
#03  接受  next=</previous_user_message>  content='<task-notification>...'          ← 引用
#05  接受  next=</previous_user_message>  content='采用方案3 吧'                    ← 引用
#08  接受  next=</previous_user_message>  content='我已经 重启 codex 和 WorkBuddy'  ← 引用

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 (cfa28d1a). The next non-whitespace content after the closing tag must now be an opening tag or the end of the prompt. A real wrapper is the last element of its host message, so the next host block opens there; a quoted pair is nested, so its enclosing element closes there. That separates all 19 pairs of the #1702 transcript and both pairs of the session I am running this from: 5 real wrappers followed by the next host block, 9 quoted pairs by </previous_user_message>.

test_recall_query_ignores_a_line_isolated_pair_inside_a_host_summary covers it, and it fails against both b0e9f0c0 and 55ea8a45, each of which sends the question the summary quotes instead of the turn in hand.

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 <system-reminder data-role="user-context"> marks the real wrapper and separates every pair in both transcripts, but making the reduction depend on an attribute value would turn a host rename into a silent loss of the reduction, so I left it out. Happy to take that trade if you would rather have the tighter rule.

Also measured. Walking the remainder by index replaces the str.strip and str.lstrip copies the check made once per candidate. On a 411 KB prompt carrying 2,000 pairs the reduction takes 0.51 ms.

Replayed against the running server, the reduction sends the submitted turn (28 characters) and /v1/context/prepare answers 200. The prepared context is empty there because that Scope holds two entries and neither is about the turn.

Docs

The integrations/workbuddy/README.md Runtime behavior list still described Recall as sending the prompt to /v1/context/prepare, and the diagnostics bullet claimed status and byte counts only. 3d6bfb3c records the reduction, and broadens that bullet to the counts and the query source the events actually carry, which leaves its privacy guarantee unchanged.

One piece of drift in the same table that I left alone: POWERCONTEXT_WORKBUDDY_REQUEST_TIMEOUT_SECONDS and POWERCONTEXT_WORKBUDDY_HTTP_BUDGET_SECONDS still read 1.0 and 4.0 while master's settings default to 3.0 and 6.0 after #1700, which changed them without the table. This branch sits 15 commits behind master, so correcting the table here would leave the branch's own tree contradicting itself. A one-line follow-up, or a rebase before merge.

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
@AlexStocks

AlexStocks commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

@Teingi Conflicts resolved in 4de7fd6b: the branch now merges master at e7ef406b. The conflict was that #1729 landed tests/workbuddy_plugin/ on master while this PR introduces the same package, plus the same hook file; the merge keeps master's scope_module fixture and WORKBUDDY_ROOT helper with the isolation fixture this PR adds, and both test sets now run together. The P2 from the review thread is fixed in the same push — details in the thread.

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>
@Teingi
Teingi merged commit 7829fa1 into oceanbase:master Sep 24, 2026
24 checks passed
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.

WorkBuddy joins the whole session into the hook prompt, so recall returns nothing

1 participant