Repository navigation
fix(ai): make generate_image and web_search callable on the Global Assistant - #2241
Conversation
…sistant
The Global Assistant route hand-rolled its own core/non-core tool split using
only CORE_TOOL_NAMES. generate_image and web_search are runtime composer
toggles, not core page/drive tools, so they landed in nonCoreTools and were
never sent to the model as callable tools — while their bare names WERE still
injected into the system prompt by buildNonCoreToolNamesPrompt. The model read
the names, called them directly, and the AI SDK rejected them as unknown tools.
A user asking for image generation got three failed tool calls and an incorrect
"image gen isn't wired into the tool set yet" reply.
The page-chat route already solved this via ALWAYS_UPFRONT_TOOLS passed to
applyToolExposureMode; the Global Assistant never got that treatment.
- Add pure helper splitToolsForExposure(tools, alwaysUpfront) to tool-exposure.ts:
a tool is upfront when CORE_TOOL_NAMES.has(name) || alwaysUpfront.has(name).
- Wire the global route to it with ALWAYS_UPFRONT_TOOLS = {web_search,
generate_image}, mirroring chat/route.ts, with a comment linking the two so
they don't drift apart again.
- nonCoreTools now excludes them, so they also stop being advertised as
execute_tool targets in the system prompt.
Reachability only: the admin/credit gate (shouldExposeImageGen,
filterToolsForImageGen) is untouched, so this does not widen who can generate
images. The route's own prompt assembly and TOOL_DISCOVERY_PROMPT ordering are
left as-is to preserve prompt-cache stability.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ukUtoKKTgQDH35docNcE6
📝 WalkthroughWalkthroughThe change adds reusable helpers for always-upfront tool exposure, applies them to the global messages route for ChangesTool exposure override
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant GlobalMessagesRoute
participant ExposureHelpers
participant Model
participant tool_search
participant execute_tool
GlobalMessagesRoute->>ExposureHelpers: Split filtered tools with always-upfront overrides
ExposureHelpers-->>GlobalMessagesRoute: Return coreTools and nonCoreTools
GlobalMessagesRoute->>Model: Expose direct and deferred tools
Model->>tool_search: Search deferred tool catalog
tool_search-->>Model: Return searchable tools
Model->>execute_tool: Dispatch selected deferred tool
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Follow-through on the same defect class this branch fixes. The Global Assistant
still passed the full filteredAllTools as the tool_search catalog, so
generate_image and web_search remained discoverable there — but they are no
longer in execute_tool's dispatch map (they are served upfront now).
TOOL_DISCOVERY_PROMPT tells the model "All other tools ... call execute_tool to
run them. Use tool_search to get parameter schemas first." So a model that ran
tool_search("image") would find generate_image and could dispatch it through
execute_tool, hitting `Unknown tool "generate_image"` — the same
advertised-but-not-callable dead end, one level deeper.
- Add pure helper selectToolSearchCatalog(tools, alwaysUpfront): the catalog
minus the always-upfront tools. Core tools stay searchable since they are
directly callable.
- Wire the global route's createToolSearchTool through it.
- Use it for `deferrable` inside applyToolExposureMode too. Behaviour-identical
(same predicate, same key order); the sibling route already excluded
always-upfront tools from its catalog and its existing tests, which pin that,
pass unmodified.
Adds a test asserting the invariant directly: every tool left in the catalog is
either core (directly callable) or present in nonCoreTools (callable via
execute_tool). Nothing may be discoverable yet unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ukUtoKKTgQDH35docNcE6
|
@coderabbitai review |
✅ Action performedReview finished.
|
…tionale
Follow-up from a review pass over the previous two commits.
1. The rationale comment was wrong for this route. It was copied from
chat/route.ts and claimed deferral "trips execute_tool's allowlist check".
That check is `enabledTools != null && !enabledTools.includes(name)`, and the
Global Assistant route never sets enabledTools in experimental_context — the
check is a permanent no-op there. The actual failure on this route is that a
deferred tool is advertised by name in the system prompt without its schema
ever reaching the model. Wrong reasoning attached to correct code is worse
than none: a maintainer verifying the stated reason would conclude the set is
unnecessary and delete it, reintroducing the bug.
2. The set was duplicated as a literal in two route files, kept in sync only by
that comment. It is now exported once from tool-exposure.ts and imported by
both api/ai/chat and api/ai/global/[id]/messages. The parameter stays, so a
route wanting a different set still passes one — /v1/chat/completions keeps
its deliberately narrower {web_search}. The doc comment now records both
routes' distinct failure modes. Behaviour is unchanged: both literals were
already identical.
3. Renamed selectToolSearchCatalog -> excludeAlwaysUpfront. It has two callers —
the tool_search catalog and the deferral candidate set inside
applyToolExposureMode — and the old name described only the first, so
`const deferrable = selectToolSearchCatalog(...)` read as a contradiction.
4. applyToolExposureMode now derives nonCoreTools from splitToolsForExposure
rather than re-filtering `deferrable`, so the rule for what gets deferred is
defined once. Provably identical: !alwaysUpfront && !CORE equals
!(CORE || alwaysUpfront), and both preserve input key order, so the object fed
to buildNonCoreToolNamesPrompt and createExecuteTool is byte-identical.
Adds tests pinning the shared set's contents and that its members are promoted
past the core-only split. Trims two assertions that restated what the preceding
lines already proved.
Not fixed here, deliberately: the web-search toggle also gates web_fetch
(WEB_SEARCH_TOOLS in core/tool-filtering.ts), which is in no always-upfront set.
On page chat that is a live instance of this same bug; on the Global Assistant
it is harmless since no allowlist is set. Promoting it changes page-chat
behaviour and upfront context, so it belongs in its own change. Recorded in the
constant's doc comment and the PR body.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ukUtoKKTgQDH35docNcE6
|
@coderabbitai review |
✅ Action performedReview finished.
|
Review-pass summary (for human reviewers)Automated review returned no findings, so I ran a four-angle critique pass (reuse / simplification / efficiency / altitude) over my own diff and acted on it. Three things are worth a reviewer's attention: 1. I had written a factually wrong comment — now fixed. Commit 1 copied the rationale from 2. 3. A real adjacent bug I found and deliberately did NOT fix — please sanity-check this call. The web-search toggle gates two tools —
I left it out because promoting it changes page-chat behaviour and upfront context on a route this PR otherwise only refactors, and it deserves its own tests. It's recorded in the shared constant's doc comment so it can't be lost. Happy to fold it in here if you'd rather. Also flagged but not fixed: the admin global-prompt viewer ( Validation: typecheck 16/16, lint 14/14, |
Additional findings from adversarial verification (all pre-existing, none fixed here)I had the riskiest claim in this PR — that commit 3's change to
Neither is introduced by this PR and both are outside its mission, so I've left them for separate changes rather than widening the diff further — same call I made on Local security suite note: |
The previous note said only "belongs in its own change", which reads as an unexamined punt. The actual reason is a trade-off worth recording, because the constant's own definition implies web_fetch should be in the set: - Page chat: genuinely broken. An agent whose saved allowlist omits web_fetch but who toggles web search on gets it rejected at dispatch. - Global Assistant: not broken. That route sets no allowlist, so web_fetch still dispatches through execute_tool. Adding it there only spends context on the one surface deliberately engineered to keep upfront schemas small (#1310 dropped 29 schemas for ~5-12k tokens/turn). So promoting it in the SHARED set would charge the Global Assistant for a page-chat-only fix. The likely correct fix is page-chat-specific, which this design already allows without further change: alwaysUpfront remains a per-call parameter, so that route can pass `new Set([...ALWAYS_UPFRONT_TOOLS, 'web_fetch'])` in isolation. Comment-only change; no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ukUtoKKTgQDH35docNcE6
|
Followed up on my own By the shared constant's own definition
So promoting it in the shared default would charge the Global Assistant for a page-chat-only fix. The likely-correct fix is page-chat-specific — and this design already allows it with no further change, since That reasoning is now in the constant's doc comment ( |
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai full review |
✅ Action performedFull review finished. Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 24 minutes. |
The previous comment claimed web_fetch is a live page-chat bug being deferred.
That is wrong, and the error was mine — I took it from a review finding and
verified only that WEB_SEARCH_TOOLS contains web_fetch, never that the toggle
override re-adds it. It does not.
web_fetch appears nowhere in chat/route.ts. It is never destructured as an
override (Step 2 takes only web_search and generate_image) and never re-added
after the allowlist (Steps 4/4b). So it flows through filterToolsForAgentAllowlist
like any other tool, giving two outcomes and no third:
- allowlist permits it -> present, and execute_tool's re-check passes. Works.
- allowlist omits it -> removed entirely at Step 3. Not upfront, not in
execute_tool's map, not in the tool_search catalog.
The model never sees it.
There is no present-but-rejected state, so there is nothing to rescue. The
earlier framing also had the premise backwards: web_fetch does not merely fail a
cost/benefit test, it does not meet the constant's definition at all.
Replaces the false "KNOWN GAP" note with the actual membership rule: a tool
belongs in ALWAYS_UPFRONT_TOOLS iff it bypasses the agent allowlist on the way in
(destructured before the allowlist, re-added after it), because that is exactly
what makes execute_tool's re-check reject it on the way out. web_search and
generate_image have that property; web_fetch does not.
Adds an explicit assertion that web_fetch is absent, so the rule is pinned rather
than described.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ukUtoKKTgQDH35docNcE6
Correction — retracting my
|
Problem
A user asked the Global Assistant to generate three images. The model emitted three
generate_imagetool calls, all three failed, and it told the user "image gen isn't wired into the tool set yet on this environment."Image generation is fully built and working — the OpenRouter backend, the admin gate, the credit check and the UI renderer are all correct. The only thing broken was tool exposure on the Global Assistant route.
apps/web/src/app/api/ai/global/[id]/messages/route.tshand-rolled its own core/non-core split using onlyCORE_TOOL_NAMES(nine page/drive tools).generate_imageis not one of them, so it landed innonCoreToolsand was never sent to the model as a callable tool — while its bare name was still injected into the system prompt bybuildNonCoreToolNamesPrompt. The model read the name, called it directly, and the AI SDK rejected it as an unknown tool.web_searchon the same route had the identical latent defect. Both are fixed here.The page-chat route already solved exactly this with an
ALWAYS_UPFRONT_TOOLSset passed toapplyToolExposureMode. The Global Assistant never got that treatment.Changes
1. Make the tools callable (
8134af3)splitToolsForExposure(tools, alwaysUpfront)inapps/web/src/lib/ai/tools/tool-exposure.ts— the file that already owns this logic. A tool is upfront whenCORE_TOOL_NAMES.has(name) || alwaysUpfront.has(name).Object.fromEntries(...filter...)blocks collapse to one call to it.buildNonCoreToolNamesPromptnow receives the reducednonCoreTools, so the upfront tools correctly stop being advertised asexecute_tooltargets.2. Close the same dead end in the
tool_searchcatalog (defb1f5)Found while self-reviewing commit 1. The route still passed the full
filteredAllToolsas thetool_searchcatalog, sogenerate_image/web_searchstayed discoverable there — but they are no longer inexecute_tool's dispatch map.TOOL_DISCOVERY_PROMPT(system-prompt.ts:32) tells the model: "All other tools ... callexecute_tool({tool_name, parameters})to run them. Usetool_search("select:tool_name")to get parameter schemas first." So a model runningtool_search("image")would findgenerate_imageand dispatch it viaexecute_tool→Unknown tool "generate_image". Same advertised-but-not-callable dead end, one level deeper.excludeAlwaysUpfront(tools, alwaysUpfront), wired into the route'screateToolSearchTool. Core tools stay searchable (they're directly callable, so a lookup is harmless).3. Share the constant, and fix its rationale (
60059cd)From a four-angle review pass (reuse / simplification / efficiency / altitude) over commits 1–2:
chat/route.tsand claimed deferral "trips execute_tool's allowlist check". That check isenabledTools != null && !enabledTools.includes(name)— and the Global Assistant route never setsenabledToolsinexperimental_context, so it is a permanent no-op there. The real failure on this route is prompt advertising without schemas. Wrong reasoning attached to correct code is worse than none: a maintainer who verified the stated reason would correctly conclude the set was unnecessary and delete it, reintroducing the bug.tool-exposure.tsand imported by bothapi/ai/chatandapi/ai/global/[id]/messages. ThealwaysUpfrontparameter stays, so a route wanting a different set still passes one —/v1/chat/completionskeeps its deliberately narrower{web_search}. Behaviour unchanged: both literals were already identical.selectToolSearchCatalog→excludeAlwaysUpfront. It has two callers — thetool_searchcatalog and the deferral candidate set insideapplyToolExposureMode— and the old name described only the first, makingconst deferrable = selectToolSearchCatalog(...)read as a contradiction.applyToolExposureModederivesnonCoreToolsfromsplitToolsForExposureinstead of re-filteringdeferrable, so the deferral rule is defined once. Provably identical:!alwaysUpfront && !CORE≡!(CORE || alwaysUpfront), and both preserve input key order, so the object fed tobuildNonCoreToolNamesPromptandcreateExecuteToolis byte-identical.Correction: the
web_fetchclaim in earlier revisions was wrongEarlier revisions of this description (and two comments below) claimed
web_fetchwas a live page-chat bug being deferred. That was incorrect — retracted inb2560f9. Recording it here rather than quietly deleting it, since reviewers may have read it.The claim came from a review finding I under-verified: I confirmed
WEB_SEARCH_TOOLS = {web_search, web_fetch}(core/tool-filtering.ts:128) and inferred the rest. Butweb_fetchappears nowhere inchat/route.ts. It is never destructured as a runtime override (Step 2 takes onlyweb_searchandgenerate_image) and never re-added after the allowlist (Steps 4/4b), so it flows throughfilterToolsForAgentAllowlistlike any ordinary tool. That yields two outcomes and no third:null) → present, andexecute_tool's re-check passes → dispatches fine;execute_tool's dispatch map, not in thetool_searchcatalog → the model never sees it.There is no present-but-rejected state, so there is no bug to fix.
The actual membership rule for
ALWAYS_UPFRONT_TOOLSThe correction is sharper than the mistake. Membership is not "shares a composer toggle" — it is:
That bypass is precisely what makes
execute_tool's re-check reject the tool on the way out: it is present in the tool set but absent fromenabledTools, so deferring it produces "not permitted" exactly when the user toggled it on.web_searchandgenerate_imageare the only two tools with that property.web_fetchhas no bypass, so it has nothing to be rescued from — it is correctly absent, not deferred.This rule is now stated in the constant's doc comment and pinned by an explicit
expect(ALWAYS_UPFRONT_TOOLS.has('web_fetch')).toBe(false), so the set's boundary is asserted rather than merely described.Scope guarantees
filterToolsForImageGenandshouldExposeImageGenare untouched. Verified both gates remove the tool from the set entirely when off (tool-filtering.ts:250-259,344-353), soALWAYS_UPFRONT_TOOLScan only promote a tool that already survived the gate — it cannot widen who can generate images.image-gen-exposure.test.tspasses unmodified (verified: empty diff).applyToolExposureModewholesale swap on the global route. That route injectsTOOL_DISCOVERY_PROMPTitself under an explicit "Stable order" comment (prompt-cache sensitive); the helper would emit a second copy and reorder it, and it short-circuits when there are no non-core tools, whereas the route always wants theexecute_toolscaffolding.applyToolExposureMode'scoreTools/upfrontOverridesspread left alone. It orders core-then-overrides, whereassplitToolsForExposurepreserves input key order — collapsing them would change the tool array order sent to the provider, which is prompt-cache sensitive, so it would not be behaviour-identical.Testing (TDD)
Both helpers were written RED first and observed failing before implementation:
splitToolsForExposure— 100% branch coverage (core=trueshort-circuit,core=false+always=true,core=false+always=false, explicit-empty vs defaultedalwaysUpfront, empty tool set): the four required cases plus "no tool appears in both halves; union equals the input".excludeAlwaysUpfront— omits always-upfront tools, handles empty/defaulted args, plus an invariant test: every tool left in the catalog is either core (directly callable) or present innonCoreTools(callable viaexecute_tool). Nothing may be discoverable yet unreachable.ALWAYS_UPFRONT_TOOLS— pins the set's contents (so a new toggle can't be added to one route and forgotten in the other) and asserts its members are promoted past the core-only split.Gates
bun run typecheckbun run lintbun test .../tool-exposure.test.tsbun test .../image-gen-exposure.test.ts(unmodified)bun run test:unit—@pagespace/libbun run test:unit—webReproducing locally:
test:unitneedsDATABASE_URLpointed at the migrated test Postgres (asscripts/test-with-db.shdoes); without it ~24 DB-backed tests fail onrelation "users" does not exist. Runtypecheckandlintsequentially — both triggerweb:build, and racing them leaves.next/typespartially written, producing spuriousTS6053errors.Pre-existing / flaky failures (not caused by this PR)
apps/web/src/lib/messages/__tests__/grouping.test.ts > isFirstInGroup > breaks the group when messages cross midnight even within the 5-minute window—AssertionError: expected false to be true. Verified pre-existing: the same test fails identically onmasterin a separate checkout, and this diff changes no files it touches. Timezone-dependent, environmental.packages/lib/src/security/__tests__/distributed-rate-limit.integration.test.ts > weighted sliding window— failed once under full-suite parallel load, then passed 7/7 twice in isolation. Timing-sensitive flake; this diff touches zeropackages/libfiles.bun run test:securitylocally reports 6/50 suites failing (Session Service, Device Auth Utilities, Permissions, Login Route, Signup Route, Mobile Login) — all auth/session suites, none touching AI tool exposure. Running the identical command onmasterin a separate checkout fails 9/50, a strict superset of the same six. So this branch introduces zero new security-suite failures, and CI's Security Test Suite check passes on the PR.Files changed
apps/web/src/lib/ai/tools/tool-exposure.tsapps/web/src/app/api/ai/global/[id]/messages/route.tsapps/web/src/app/api/ai/chat/route.ts(import shared constant; literal removed)apps/web/src/lib/ai/tools/__tests__/tool-exposure.test.ts🤖 Generated with Claude Code
https://claude.ai/code/session_018ukUtoKKTgQDH35docNcE6