Skip to content

fix(server,xai): enforce the request's tool selection after a response repair - #5376

Merged
lidge-jun merged 4 commits into
devfrom
codex/260921-l5-tool-scope-after-repair
Sep 20, 2026
Merged

lidge-jun merged 4 commits into
devfrom
codex/260921-l5-tool-scope-after-repair

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • A request that narrowed its tool selection could still receive the call it excluded. createGrokResponsesSparseTerminalBlockRewrite rebuilds a terminal output from the output_item.done events it collected, and it received a translator budget but nothing about what the request had selected. The undeclared-tool guard answers a different question — whether a name was declared — so tool_choice: "none", a forced selector naming another tool, and an allowed_tools list that excludes the call all passed it. Trigger: a Grok-compat destination answers with the durable items in output_item.done and an empty terminal output. Before, the reconstructed terminal carried {"type":"function_call","name":"apply_patch"} for a request sent with tool_choice: "none"; after, that call is left out of the reconstruction.
  • The boundary is read from the final outbound body, after every removal, rename and translation, in the new src/server/responses-request-tool-scope.ts. A catalog that ends up empty there authorizes no client call whatever the selector still says. An absent catalog states no boundary, exactly as it states none for the declaration guard, so a passthrough request that omits tools and receives a call the client understands is unaffected.
  • The failure stays narrow and stops looking like a clean finish. Only the offending item is withheld, so the assistant text that arrived in the same turn still reaches the client instead of being discarded with it, and the withheld position is kept so the contiguity proof still covers the whole output. Because the turn no longer ended the way the upstream said it did, the reconstructed terminal is published as response.incomplete with incomplete_details.reason: forbidden_tool_call and a bounded message naming the tool, rather than as a response.completed with a quietly shorter output. The repair edits nothing but the terminal it synthesizes; the raw stream is still forwarded untouched and remains the declaration guard's to police.
  • The outbound half of the same rule, in src/adapters/xai-web-search.ts: a selector that normalization left with nothing to select is omitted, because xAI answers that request with a 400 — which is what happens to a cached-only web-search declaration that is dropped rather than widened to live search. A forced function selector is preserved, because a selector this proxy cannot honor is a client input error the request-build path already answers with a 400.
  • auto and none are not the same omission, and that distinction is this lane's change to the carried one. auto selects from the catalog, so removing it from a request with an empty catalog states nothing new. none is a prohibition, and on a request whose catalog the same normalizer just emptied it is the only place the turn's client-call boundary is written down. Dropping the word alone left the scope above reading a body that authorized more than the caller did, and nothing behind it catches that: the repair runs on the grok client surface while the declaration guard stands down whenever the provider's authMode is forward, which is what the xAI OAuth lane is. The prohibition is therefore restated as the explicit empty catalog, which carries the same deny-all, which the request scope and the declaration guard both already read that way, and which this destination receives unchanged whenever a caller sends one itself. auto is still dropped without inventing a catalog the caller never declared.
  • The selector-omission change is adapted from fix(adapters): preserve Grok request scope and tool-result continuations #5350 and credited to its author with a Co-authored-by trailer; the auto/none split above is the part added here.
  • fix(server,providers): bound upstream error bodies and keep account-scoped transports honest #5336 threads the outbound body into the same repair and refuses the call by tainting the whole reconstruction. Its call-site change is kept as-is and credited with a Co-authored-by trailer; the enforcement it added is extended so one forbidden call no longer costs the caller the accompanying text, and so a stream the repair will not reconstruct faithfully says so on the terminal.
  • structure/transports/responses.md records the split between the declaration question and the selection question, where the scope is read from, why the refusal is narrow and visible, and why one of the two omitted selectors has to be restated.

Verification

  • local checks: NOT RUN. No local suite, focused test, typecheck, build, install, proxy or service was started for this lane, by instruction. The review below is static source reading; hosted CI on the exact head is the executed evidence.
  • The first hosted run on this branch was red, and it found a real defect rather than a stale assertion: test 1/4 failed on tests/providers/xai/xai-web-search-compat.test.ts, where a cached-only search request normalized to tool_choice: "none" and the new omission erased it, leaving an outbound body that stated no boundary at all. That is the regression the fifth bullet describes and fixes; the assertion was updated only to record the new wire shape, together with the reason.
  • Static review of the changed paths: the new scope module returns undefined for a request that restricts nothing, so an ordinary turn keeps the exact path it had; the repair's retain/withhold bookkeeping shares one MAX_COMPLETED_OUTPUT_ITEMS bound and releases every charged byte through the existing reset/dispose; a withheld index participates in the contiguity proof but never fills a gap the stream actually left; and the refusal rewrites the event: line together with the payload type so the two cannot disagree. The restated catalog is the byte shape this destination already receives from a caller who sends tools: [] itself, so it introduces no wire form the xAI path had not been sending.
  • Regression coverage lands beside the existing tests for each subsystem, in new sibling files rather than growing one at its ratchet cap: tests/responses/responses-sparse-terminal-tool-scope.test.ts (twelve cases: refusal with and without surviving text, the untouched raw stream, a permitting selector, a forced selector for another tool, an allow-list, both flattened namespaced spellings, an emptied catalog with and without a selector, an absent catalog, a selection with nothing to refuse, a gapped stream, and budget release) and tests/providers/xai/xai-empty-catalog-tool-choice.test.ts (eight cases, including the restated prohibition, the caller's own none on a request with no catalog, auto inventing no catalog, and one cross-layer case that drives the normalized body through the repair and asserts the forbidden call is withheld while the assistant text survives). Both are registered in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json. The tests import the terminal type, the reason and the message builder from the source rather than restating them.
  • Not covered here: the non-sparse path. A terminal the upstream sent with authoritative output is not rewritten by this repair and keeps its current behaviour; selection enforcement on that path is a separate contract, not part of this change.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

lidge-jun and others added 3 commits September 21, 2026 07:25
…tool selection

The sparse-terminal repair rebuilds a terminal output from the output_item.done
events it collected, and it received a budget but nothing about what the request
had actually selected. The undeclared-tool guard answers a different question --
whether a name was declared -- so a request sent with tool_choice: "none", with a
forced selector naming another tool, or with an allowed_tools list that excludes
the call still received that call back through the repair.

Read the boundary from the final outbound body, after every removal, rename and
translation, and apply it to what the repair publishes. A catalog that ends up
empty there authorizes no client call whatever the selector still says; an absent
catalog states no boundary, exactly as it states none for the declaration guard.

Keep the failure narrow and visible. Only the offending item is withheld, so the
assistant text that arrived in the same turn still reaches the client instead of
being discarded with it, and the withheld position is kept so the contiguity proof
still covers the whole output. Because the turn no longer ended the way the
upstream said it did, the reconstructed terminal is published as
response.incomplete with incomplete_details.reason forbidden_tool_call rather than
as a clean response.completed with a quietly shorter output. The raw stream is
still forwarded untouched; policing it remains the declaration guard's job.

Co-authored-by: luvs01 <luvs01@users.noreply.github.com>
xAI rejects a Responses request whose tool_choice survives a catalog the adapter
had to empty, which is what happens to a cached-only web-search declaration: it is
omitted rather than widened to live search, and the request then selects from a
catalog it no longer has.

Omit an auto or none selector once no tool remains in either the top-level catalog
or additional_tools. A forced function selector is preserved: a selector this proxy
cannot honor is a client input error, and the request-build path already answers it
with a 400 rather than silently turning "call this tool" into "answer however you
like".

This is the outbound half of the same rule the response-side repair applies --
compatibility is judged on the final request and the final response, after every
removal, rename and translation. The change is carried unmodified from #5350.

Co-authored-by: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com>
The Responses transport document described declaration enforcement and said
nothing about selection, so the next person to touch this area would have read the
undeclared-tool guard as the whole contract. Write down the boundary between the
two questions, where the scope is read from, and why the refusal keeps the text and
marks the terminal instead of finishing quietly.
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 20, 2026 22:27
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-20T22:32:16.790953Z 377269a PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 20, 2026
@coderabbitai

coderabbitai Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Warning

Review limit reached

Next included review available in 31 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: lidge-jun/opencodex/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 70a9b225-70c0-4f2c-8ccf-4475c6e76029

📥 Commits

Reviewing files that changed from the base of the PR and between c58e0d6 and e1d799b.

📒 Files selected for processing (11)
  • scripts/test-layout/layout.json
  • src/adapters/xai-web-search.ts
  • src/server/grok-responses-snapshot-repair.ts
  • src/server/responses-request-tool-scope.ts
  • src/server/responses-undeclared-tool-guard.ts
  • src/server/responses/passthrough-delivery.ts
  • structure/transports/responses.md
  • tests/fixtures/test-layout-expected.json
  • tests/providers/xai/xai-empty-catalog-tool-choice.test.ts
  • tests/providers/xai/xai-web-search-compat.test.ts
  • tests/responses/responses-sparse-terminal-tool-scope.test.ts
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 377269aa23

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

: undefined,
grokClientCompatibilityEnabled
? createGrokResponsesSparseTerminalBlockRewrite(translatorBudget)
? createGrokResponsesSparseTerminalBlockRewrite(translatorBudget, nativeExchange.outboundRequestBody)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep refused sparse turns out of replay state

When a declared tool is excluded by tool_choice (for example, declared apply_patch with tool_choice: "none") and the provider sends its output_item.done followed by a sparse completed terminal, this rewrite emits an incomplete terminal without the call, but the parallel raw-stream inspector still reconstructs that call and passes it to rememberPassthroughResponseChecked. Because that persistence guard checks only whether the tool was declared, the refused turn enters previous_response_id replay state as completed and later continuations receive a tool call the client terminal never contained or answered. Propagate the selection refusal to inspection/persistence or apply the same scope before storing the response.

Useful? React with 👍 / 👎.

? item.namespace
: undefined;
if (!namespace) return [name];
return [name, namespacedToolName(namespace, name), dottedToolName(namespace, name)];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Match qualified selectors by full tool identity

With two declared tools such as docs.search and admin.search, forcing {type: "function", namespace: "docs", name: "search"} also authorizes an emitted {namespace: "admin", name: "search"} because both callNameSpellings and selectorNameSpellings include the bare spelling search. The declaration guard permits both names because both tools are declared, so the sparse repair can reconstruct the explicitly unselected admin call. Qualified identities should match by their canonical namespace/name, with bare aliases resolved only when the catalog proves them unambiguous.

Useful? React with 👍 / 👎.

const names = new Set(selectorNameSpellings(choice));
return names.size > 0 ? { kind: "allow", names } : UNRESTRICTED;
}
return UNRESTRICTED;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject client calls under forced hosted choices

When tool_choice forces a hosted tool such as {type: "web_search"} and the catalog also contains a client function, returning UNRESTRICTED allows a sparse terminal to reconstruct that function call even though the request selected web search specifically. This also disagrees with the allowed_tools branch immediately above, where a hosted-only selection correctly denies every client call. Treat a forced non-client selector as deny-all for CLIENT_EXECUTED_CALL_TYPES rather than as unrestricted.

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 64 / 80

이 PR은 Grok 쪽 “빈 terminal을 다시 만드는 수리”가 tool_choice를 무시하던 구멍을 막는다. 예전에는 tool_choice: "none"인데도 upstream이 apply_patch 같은 호출을 output_item.done으로 보내면, 수리기가 그걸 다시 붙여 response.completed로 내려줬다. 이제는 최종 outbound 요청에서 “이 턴에 클라이언트가 써도 되는 도구” 경계를 읽고(responses-request-tool-scope.ts), 금지된 호출만 빼고 같이 온 텍스트는 남긴다. 그리고 조용히 끝내지 않고 response.incomplete + forbidden_tool_call로 “이건 요청이 막은 호출이다”라고 표시한다. xAI 쪽에서는 도구 목록이 비었는데 auto/none selector가 남아 400 나던 경우, 그 selector를 빼는 outbound 수정도 같이 들어왔다. base는 dev이고, 테스트·문서도 같이 생겼다.

라인 - tests/providers/xai/xai-web-search-compat.test.ts:82 — CI test 1/4가 이미 빨갛다. 기대값은 tool_choice === "none"인데, 이번 변경은 빈 카탈로그에서 none을 아예 지워서 undefined가 된다. 새 테스트는 새 동작을 보지만, 기존 compat 테스트는 안 맞춰 둔 상태다. 머지 전에 이 한 줄을 새 계약에 맞게 고치거나, 의도적으로 none을 남겨야 한다면 outbound 생략 조건을 다시 좁혀야 한다.

라인 - src/server/responses-request-tool-scope.ts callNameSpellings / selectorNameSpellings — 네임스페이스가 있는 도구는 bare 이름(search)도 맞춤 후보에 넣는다. 그래서 {namespace:"docs", name:"search"}로 강제해도, 선언만 되어 있으면 {namespace:"admin", name:"search"} 호출의 bare search가 통과할 수 있다. 자격 있는 선택은 namespace+name 통째로 맞추고, bare 별칭은 카탈로그에서 유일할 때만 써야 한다.

라인 - src/server/responses-request-tool-scope.ts toolSelectiontool_choice{type:"web_search"} 같은 hosted 강제면 지금 UNRESTRICTED로 빠진다. 카탈로그에 클라이언트 함수가 있으면 sparse 수리기가 그 함수 호출을 다시 붙일 수 있다. allowed_tools가 호스티드만 남으면 클라이언트 호출을 막는 것과 맞추려면, 강제 hosted selector는 클라이언트 호출에 대해 deny-all에 가깝게 다루는 편이 맞다.

라인 - 재구성 terminal은 호출을 빼는데 raw output_item.done은 그대로 흘린다(의도). 그런데 병렬 inspector / rememberPassthroughResponseChecked가 “선언만 됐는지”만 보면, 거절한 턴이 previous_response_id 재생 상태에 completed 호출로 남을 수 있다. 다음 이어하기가 클라이언트가 본 적도, 답한 적도 없는 tool call을 받게 된다. 선택 거절을 저장 가드까지 같이 전달해야 한다.

라인 - PR 본문도 말했듯 non-sparse(upstream이 이미 output을 채운 terminal)에는 이 경계가 안 걸린다. sparse만 고친 범위로는 이해되지만, 같은 tool_choice 계약이 경로마다 다르게 보이는 건 남는다.

메인테이너의 판단이 필요한 지점

CI 빨간 compat 테스트를 “새 동작이 맞고 테스트를 고친다”로 볼지, “none을 비운 카탈로그에 남기는 쪽이 xAI/클라이언트에 더 안전하다”로 볼지. 또 hosted tool_choice와 raw-stream 재생 상태를 이번 PR에서 같이 막을지, 의도적으로 다음 이슈로 미룰지.

너의 추천

머지하지 말고, 먼저 xai-web-search-compat를 새 outbound 계약에 맞추어 CI를 초록으로 만든 뒤, 네임스페이스 전체 식별자 매칭과 hosted 강제 deny를 같은 패치(또는 바로 이어지는 고정 PR)에 넣는 쪽을 추천한다. replay 저장 구멍은 재현 케이스 하나만 붙이면 우선순위가 분명해진다. #5336/#5350 계열 중복·무효 PR이 열려 있으면 이 쪽으로 모으고 나머지는 닫는 편이 좋다.

이 댓글은 grok-bot이 작성했습니다

… removes

Omitting an auto/none selector that normalization left with nothing to select keeps
xAI from answering 400, but the two words are not interchangeable. auto selects from
the catalog, so removing it from a request with an empty one states nothing new. none
is a prohibition, and on a request whose catalog this normalizer just emptied it is
the only place the turn's client-call boundary is written down.

The sparse-terminal repair reads that boundary from the final outbound body, so
dropping the word alone handed the reconstruction a request that authorized more than
the caller did -- and nothing behind it catches that: the repair runs on the grok
client surface while the declaration guard stands down whenever the provider's
authMode is forward, which is what the xAI OAuth lane is. A caller who forbade every
client tool could get one back inside a terminal the upstream never sent.

Restate the prohibition as the explicit empty catalog. It carries the same deny-all,
the request scope and the declaration guard both already read it that way, and this
destination receives it unchanged whenever a caller sends one itself. auto is still
dropped without inventing a catalog, because an absent catalog states no boundary.

Co-authored-by: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com>
@lidge-jun
lidge-jun merged commit b20acc7 into dev Sep 20, 2026
35 checks passed
@lidge-jun
lidge-jun deleted the codex/260921-l5-tool-scope-after-repair branch September 20, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant