Skip to content

feat(ai): add lazy tool discovery for chat()#360

Open
AlemTuzlak wants to merge 11 commits intomainfrom
feat/lazy-tool-discovery
Open

feat(ai): add lazy tool discovery for chat()#360
AlemTuzlak wants to merge 11 commits intomainfrom
feat/lazy-tool-discovery

Conversation

@AlemTuzlak
Copy link
Contributor

@AlemTuzlak AlemTuzlak commented Mar 10, 2026

Summary

  • Add lazy: true flag to tool definitions — lazy tools are withheld from the LLM upfront
  • A synthetic __lazy__tool__discovery__ tool lets the LLM discover lazy tools by name, receiving their descriptions and schemas on demand
  • Discovered tools are dynamically injected as normal tools in subsequent agent loop iterations
  • Multi-turn support: previous discoveries are restored from message history automatically
  • Self-correcting: if the LLM calls an undiscovered lazy tool, it gets an error directing it to discover first
  • Zero overhead when no tools are marked lazy

Changes

Core (@tanstack/ai):

  • lazy?: boolean on Tool, ClientTool, ToolDefinitionConfig
  • New LazyToolManager class (lazy-tool-manager.ts)
  • TextEngine integration: mutable tools, dynamic refresh after discovery, undiscovered tool error handling
  • 15 unit tests + 4 integration tests

Example (ts-react-chat):

  • 3 new lazy tools: compareGuitars, calculateFinancing, searchGuitars
  • README updated with test prompts for trying out lazy discovery

Docs:

  • New guide: docs/guides/lazy-tool-discovery.md
  • Added to docs sidebar config

Test plan

  • All 19 new tests pass (unit + integration)
  • Full test suite passes
  • Types clean
  • Build succeeds (24 projects)
  • Manual testing with ts-react-chat example (prompts in README)

Summary by CodeRabbit

  • New Features

    • Introduced lazy tool discovery: mark tools lazy to have them discovered on-demand, reducing tokens and improving handling of large toolsets; discovered tools are injected dynamically.
  • Documentation

    • Added a guide and navigation entry describing lazy tool usage, discovery flow, error handling, and multi-turn examples.
  • Examples

    • Added lazy-discovered example tools and a walkthrough demonstrating discovery and multi-turn behavior.
  • Types

    • Exposed an optional lazy flag on tool types.
  • Tests

    • Added comprehensive tests for discovery flows, errors, and state tracking.

AlemTuzlak and others added 4 commits March 10, 2026 19:04
Tools marked with `lazy: true` are withheld from the LLM. A synthetic
__lazy__tool__discovery__ tool lets the LLM discover them by name,
receiving descriptions and schemas on demand. Discovered tools are
dynamically injected as normal tools in subsequent agent loop iterations.

- Add `lazy` flag to Tool, ClientTool, and ToolDefinitionConfig
- Add LazyToolManager class with message history scanning for multi-turn
- Integrate into TextEngine: dynamic tool refresh, self-correcting errors
- 15 unit tests + 4 integration tests
Add three lazy tools (compareGuitars, calculateFinancing, searchGuitars)
to the guitar store example for testing lazy tool discovery.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 10, 2026

📝 Walkthrough

Walkthrough

Adds lazy tool discovery: tools annotated lazy: true are omitted from initial tool lists and exposed via a synthetic __lazy__tool__discovery__ tool that returns tool descriptions and input schemas on request; discovered tools are injected at runtime. Implements manager, chat integration, examples, docs, and tests.

Changes

Cohort / File(s) Summary
Type System & Tool Definitions
packages/typescript/ai/src/types.ts, packages/typescript/ai/src/activities/chat/tools/tool-definition.ts
Added optional lazy?: boolean to Tool, ClientTool, and ToolDefinitionConfig to mark tools for lazy discovery.
Core Lazy Tool Manager
packages/typescript/ai/src/activities/chat/tools/lazy-tool-manager.ts
New LazyToolManager class: separates eager vs lazy tools, scans message history for pre-discovered tools, creates a synthetic __lazy__tool__discovery__ tool, tracks discovered tools, and exposes active tools and helper APIs.
Chat Activity Integration
packages/typescript/ai/src/activities/chat/index.ts
Integrates LazyToolManager: initialize with tools/messages, use active tools for LLM calls, emit errors for undiscovered lazy tool calls, refresh tools after discovery, and reinstantiate ToolCallManager when discoveries occur.
OpenRouter Adapter
packages/typescript/ai-openrouter/src/adapters/text.ts
Internal refactor: removed lastFinishReason tracking and simplified finishReason/tool-call emission logic; adjusted private method signature accordingly.
Examples
examples/ts-react-chat/src/lib/guitar-tools.ts, examples/ts-react-chat/src/routes/api.tanchat.ts, examples/ts-react-chat/README.md
Added lazy guitar tools (searchGuitars, compareGuitars, calculateFinancing) marked lazy: true, server implementations, and README text demonstrating lazy discovery scenarios.
Docs & Changesets
.changeset/lazy-tool-discovery.md, .changeset/sixty-lions-accept.md, docs/config.json, docs/guides/lazy-tool-discovery.md
Added changeset entries and a new guide plus nav entry explaining lazy tool discovery, usage, examples, and migration notes.
Tests
packages/typescript/ai/tests/lazy-tool-manager.test.ts, packages/typescript/ai/tests/chat.test.ts, packages/typescript/ai/tests/tool-definition.test.ts
Extensive tests for LazyToolManager, discovery flows, message-history prepopulation, error handling for undiscovered tools, integration with chat activity, and preserving lazy flag in tool definitions.
Examples README
examples/ts-react-chat/README.md
Added a "Trying Out Lazy Tool Discovery" section with test prompts and expected behavior.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant ChatActivity as ChatActivity
    participant LazyManager as LazyToolManager
    participant LLM as LLM
    participant DiscoveryTool as __lazy__tool__discovery__
    participant Tool as Tool

    User->>ChatActivity: send prompt
    ChatActivity->>LazyManager: init(tools, messages)
    LazyManager-->>ChatActivity: activeTools (eager + discovery tool if needed)
    ChatActivity->>LLM: send prompt with activeTools
    LLM->>ChatActivity: tool_call(__lazy__tool__discovery__, names)
    ChatActivity->>DiscoveryTool: invoke discovery tool
    DiscoveryTool->>LazyManager: request metadata for names
    LazyManager-->>DiscoveryTool: return descriptions + inputSchemas
    DiscoveryTool-->>ChatActivity: return discovery result
    ChatActivity-->>LLM: tool_result (metadata)
    LLM->>ChatActivity: tool_call(discoveredTool, args)
    ChatActivity->>LazyManager: verify discovered(discoveredTool)
    ChatActivity->>Tool: execute discoveredTool
    Tool-->>ChatActivity: tool result
    ChatActivity-->>LLM: tool result
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • fix: anthropic tool call issues #275 — Modifies chat activity and tool-call/finish-reason handling; likely related to the OpenRouter adapter and chat integration changes in this PR.

Poem

🐇 I tuck my tools in cozy nests,

Wait for names to wake my quests.
Ask me once, I'll bring the kit,
Hop to help — then vanish, wit by wit.
Tokens saved, and tunes remain best.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the core changes, includes sections for summary, changes, and test plan, but does not follow the provided template structure with the required '🎯 Changes', '✅ Checklist', and '🚀 Release Impact' sections. Restructure the description to match the template: add '🎯 Changes' section header, include the '✅ Checklist' with items (mark completed ones), and add the '🚀 Release Impact' section with changeset status.
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(ai): add lazy tool discovery for chat()' clearly and concisely summarizes the main feature being added, matching the primary objective of implementing lazy tool discovery.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/lazy-tool-discovery

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 and usage tips.

@nx-cloud
Copy link

nx-cloud bot commented Mar 10, 2026

🤖 Nx Cloud AI Fix Eligible

An automatically generated fix could have helped fix failing tasks for this run, but Self-healing CI is disabled for this workspace. Visit workspace settings to enable it and get automatic fixes in future runs.

To disable these notifications, a workspace admin can disable them in workspace settings.


View your CI Pipeline Execution ↗ for commit 788e476

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ❌ Failed 1m 8s View ↗
nx run-many --targets=build --exclude=examples/** ✅ Succeeded 43s View ↗

☁️ Nx Cloud last updated this comment at 2026-03-12 09:12:13 UTC

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 10, 2026

Open in StackBlitz

@tanstack/ai

npm i https://pkg.pr.new/@tanstack/ai@360

@tanstack/ai-anthropic

npm i https://pkg.pr.new/@tanstack/ai-anthropic@360

@tanstack/ai-client

npm i https://pkg.pr.new/@tanstack/ai-client@360

@tanstack/ai-devtools-core

npm i https://pkg.pr.new/@tanstack/ai-devtools-core@360

@tanstack/ai-elevenlabs

npm i https://pkg.pr.new/@tanstack/ai-elevenlabs@360

@tanstack/ai-event-client

npm i https://pkg.pr.new/@tanstack/ai-event-client@360

@tanstack/ai-fal

npm i https://pkg.pr.new/@tanstack/ai-fal@360

@tanstack/ai-gemini

npm i https://pkg.pr.new/@tanstack/ai-gemini@360

@tanstack/ai-grok

npm i https://pkg.pr.new/@tanstack/ai-grok@360

@tanstack/ai-groq

npm i https://pkg.pr.new/@tanstack/ai-groq@360

@tanstack/ai-ollama

npm i https://pkg.pr.new/@tanstack/ai-ollama@360

@tanstack/ai-openai

npm i https://pkg.pr.new/@tanstack/ai-openai@360

@tanstack/ai-openrouter

npm i https://pkg.pr.new/@tanstack/ai-openrouter@360

@tanstack/ai-preact

npm i https://pkg.pr.new/@tanstack/ai-preact@360

@tanstack/ai-react

npm i https://pkg.pr.new/@tanstack/ai-react@360

@tanstack/ai-react-ui

npm i https://pkg.pr.new/@tanstack/ai-react-ui@360

@tanstack/ai-solid

npm i https://pkg.pr.new/@tanstack/ai-solid@360

@tanstack/ai-solid-ui

npm i https://pkg.pr.new/@tanstack/ai-solid-ui@360

@tanstack/ai-svelte

npm i https://pkg.pr.new/@tanstack/ai-svelte@360

@tanstack/ai-vue

npm i https://pkg.pr.new/@tanstack/ai-vue@360

@tanstack/ai-vue-ui

npm i https://pkg.pr.new/@tanstack/ai-vue-ui@360

@tanstack/preact-ai-devtools

npm i https://pkg.pr.new/@tanstack/preact-ai-devtools@360

@tanstack/react-ai-devtools

npm i https://pkg.pr.new/@tanstack/react-ai-devtools@360

@tanstack/solid-ai-devtools

npm i https://pkg.pr.new/@tanstack/solid-ai-devtools@360

commit: 2dd7bfd

@AlemTuzlak AlemTuzlak requested a review from jherr March 10, 2026 18:11
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
packages/typescript/ai/tests/chat.test.ts (1)

1218-1411: Add regressions for discovery payload contents and multi-turn rehydration.

These tests script the later adapter iterations independently of what __lazy__tool__discovery__ actually returned, so they still pass if discovery stops surfacing the tool description/schema or if previously discovered tools stop being restored from message history. Please add one case that asserts the discovery tool result contains the requested schema/description, and one that seeds prior discovery messages and verifies the lazy tool is available on the next turn’s first adapter call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai/tests/chat.test.ts` around lines 1218 - 1411, Add two
regression test cases: (1) extend the existing "should create discovery tool
when lazy tools are provided" or add a new it() that, after the discovery tool
run (look for ev.toolStart with '__lazy__tool__discovery__' and ev.toolArgs),
parses the discovery tool's toolArgs payload and asserts it contains the
expected schema/description fields for 'getWeather' (use
createMockAdapter/chat/lazyServerTool and inspect the adapter call's toolArgs
emitted by ev.toolArgs); (2) add a multi-turn rehydration test that seeds the
adapter/messages with prior discovery events (simulate earlier discovery by
including a run that yields ev.toolStart('__lazy__tool__discovery__') and
ev.toolArgs containing the 'getWeather' schema in the chat history) and then
calls chat again and asserts the first adapter call's tools (calls[0].tools via
createMockAdapter) already include 'getWeather' (i.e., lazy tool is restored on
the next turn). Ensure tests reference createMockAdapter, chat, lazyServerTool,
ev.toolArgs, and inspect calls array to validate payload contents and presence
on first turn.
packages/typescript/ai/tests/lazy-tool-manager.test.ts (1)

1-3: Fix import order per ESLint rules.

Static analysis flagged import ordering issues: expect should be sorted alphabetically within the vitest import, and the type import should come after the regular import.

♻️ Fix import order
-import { describe, it, expect } from 'vitest'
-import type { Tool } from '../src/types'
-import { LazyToolManager } from '../src/activities/chat/tools/lazy-tool-manager'
+import { describe, expect, it } from 'vitest'
+import { LazyToolManager } from '../src/activities/chat/tools/lazy-tool-manager'
+import type { Tool } from '../src/types'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai/tests/lazy-tool-manager.test.ts` around lines 1 - 3,
Reorder the imports to satisfy ESLint: fix the vitest import to alphabetize the
named imports as describe, expect, it (change import { describe, it, expect } to
import { describe, expect, it }) and move the type-only import for Tool to after
the regular LazyToolManager import so the non-type imports come first; adjust
the three symbols in this file: the vitest named imports, the LazyToolManager
import, and the type import for Tool.
packages/typescript/ai/src/activities/chat/index.ts (1)

715-749: Consider extracting duplicate undiscovered-tool filtering logic.

This block duplicates the logic from checkForPendingToolCalls (lines 611-641). While the duplication is acceptable given the different contexts (pending vs. streaming tool calls), consider extracting a helper method if this pattern is needed elsewhere.

♻️ Optional: Extract helper method
private filterUndiscoveredLazyTools(
  toolCalls: Array<ToolCall>,
  finishEvent: RunFinishedEvent,
): { executable: Array<ToolCall>; errorChunks: Array<StreamChunk> } {
  const undiscoveredResults: Array<ToolResult> = []
  const executable = toolCalls.filter((tc) => {
    if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) {
      undiscoveredResults.push({
        toolCallId: tc.id,
        toolName: tc.function.name,
        result: {
          error: this.lazyToolManager.getUndiscoveredToolError(tc.function.name),
        },
        state: 'output-error',
      })
      return false
    }
    return true
  })
  const errorChunks = undiscoveredResults.length > 0
    ? this.emitToolResults(undiscoveredResults, finishEvent)
    : []
  return { executable, errorChunks }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai/src/activities/chat/index.ts` around lines 715 - 749,
The duplicated undiscovered lazy-tool filtering in the streaming branch should
be extracted into a helper to avoid repeating logic from
checkForPendingToolCalls: create a private method (e.g.,
filterUndiscoveredLazyTools) that accepts toolCalls and finishEvent and returns
{ executable: ToolCall[], errorChunks: Iterable<StreamChunk> } by using
lazyToolManager.isUndiscoveredLazyTool and
lazyToolManager.getUndiscoveredToolError to build ToolResult entries, then call
emitToolResults for any errors; replace the inline filter/emit in the streaming
code with a call to this helper and keep existing behavior (clearing
toolCallManager and calling setToolPhase('continue') when executable is empty).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/guides/lazy-tool-discovery.md`:
- Around line 181-195: The example's input and runtime checks are too weak:
tighten calculateFinancingDef.inputSchema to require months be a positive
integer (e.g., z.number().int().min(1)) and validate productId, then in the
calculateFinancing server handler check that db.products.findUnique(...)
returned a product and that product.price is a finite positive number before
dividing; if not, throw a clear error/return a structured failure so you never
divide by zero or access undefined product fields (refer to
calculateFinancingDef, inputSchema, calculateFinancing, and
db.products.findUnique).

In `@examples/ts-react-chat/src/lib/guitar-tools.ts`:
- Around line 121-144: The compareGuitars server handler currently assumes
selected (from args.guitarIds) is non-empty which makes Math.min/Math.max and
the ! assertions on selected.find unsafe; update compareGuitars to validate that
selected.length >= 2 (or at least >0 per your business rule) after building
selected, and if not, return a clear error/result: either throw a descriptive
error or return a comparison object with an empty comparison array and
null/undefined for cheapest and mostExpensive. Ensure you avoid calling
Math.min/Math.max on an empty prices array and remove the non-null assertions on
selected.find, using guarded values instead.

---

Nitpick comments:
In `@packages/typescript/ai/src/activities/chat/index.ts`:
- Around line 715-749: The duplicated undiscovered lazy-tool filtering in the
streaming branch should be extracted into a helper to avoid repeating logic from
checkForPendingToolCalls: create a private method (e.g.,
filterUndiscoveredLazyTools) that accepts toolCalls and finishEvent and returns
{ executable: ToolCall[], errorChunks: Iterable<StreamChunk> } by using
lazyToolManager.isUndiscoveredLazyTool and
lazyToolManager.getUndiscoveredToolError to build ToolResult entries, then call
emitToolResults for any errors; replace the inline filter/emit in the streaming
code with a call to this helper and keep existing behavior (clearing
toolCallManager and calling setToolPhase('continue') when executable is empty).

In `@packages/typescript/ai/tests/chat.test.ts`:
- Around line 1218-1411: Add two regression test cases: (1) extend the existing
"should create discovery tool when lazy tools are provided" or add a new it()
that, after the discovery tool run (look for ev.toolStart with
'__lazy__tool__discovery__' and ev.toolArgs), parses the discovery tool's
toolArgs payload and asserts it contains the expected schema/description fields
for 'getWeather' (use createMockAdapter/chat/lazyServerTool and inspect the
adapter call's toolArgs emitted by ev.toolArgs); (2) add a multi-turn
rehydration test that seeds the adapter/messages with prior discovery events
(simulate earlier discovery by including a run that yields
ev.toolStart('__lazy__tool__discovery__') and ev.toolArgs containing the
'getWeather' schema in the chat history) and then calls chat again and asserts
the first adapter call's tools (calls[0].tools via createMockAdapter) already
include 'getWeather' (i.e., lazy tool is restored on the next turn). Ensure
tests reference createMockAdapter, chat, lazyServerTool, ev.toolArgs, and
inspect calls array to validate payload contents and presence on first turn.

In `@packages/typescript/ai/tests/lazy-tool-manager.test.ts`:
- Around line 1-3: Reorder the imports to satisfy ESLint: fix the vitest import
to alphabetize the named imports as describe, expect, it (change import {
describe, it, expect } to import { describe, expect, it }) and move the
type-only import for Tool to after the regular LazyToolManager import so the
non-type imports come first; adjust the three symbols in this file: the vitest
named imports, the LazyToolManager import, and the type import for Tool.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0fc45c9b-577e-424f-ac40-d84262b7ecfe

📥 Commits

Reviewing files that changed from the base of the PR and between 86be1c8 and 1f3ab5f.

📒 Files selected for processing (13)
  • .changeset/lazy-tool-discovery.md
  • docs/config.json
  • docs/guides/lazy-tool-discovery.md
  • examples/ts-react-chat/README.md
  • examples/ts-react-chat/src/lib/guitar-tools.ts
  • examples/ts-react-chat/src/routes/api.tanchat.ts
  • packages/typescript/ai/src/activities/chat/index.ts
  • packages/typescript/ai/src/activities/chat/tools/lazy-tool-manager.ts
  • packages/typescript/ai/src/activities/chat/tools/tool-definition.ts
  • packages/typescript/ai/src/types.ts
  • packages/typescript/ai/tests/chat.test.ts
  • packages/typescript/ai/tests/lazy-tool-manager.test.ts
  • packages/typescript/ai/tests/tool-definition.test.ts

tools is typed as ReadonlyArray<Tool>, never nullish, so optional
chaining is unnecessary and triggers eslint error.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/typescript/ai/src/activities/chat/index.ts (1)

645-696: ⚠️ Potential issue | 🟠 Major

Refresh the active tool set after pending discovery calls finish.

This path can execute __lazy__tool__discovery__, but unlike processToolCalls() it never rehydrates this.tools / this.toolCallManager afterward. The next model request still sees the pre-discovery tool list, so a resumed conversation with an outstanding discovery call cannot use the newly discovered tool on the following turn.

Proposed fix
     const toolResultChunks = this.emitToolResults(
       executionResult.results,
       finishEvent,
     )
 
     for (const chunk of toolResultChunks) {
       yield chunk
     }
+
+    if (this.lazyToolManager.hasNewlyDiscoveredTools()) {
+      this.tools = this.lazyToolManager.getActiveTools()
+      this.toolCallManager = new ToolCallManager(this.tools)
+    }
 
     return 'continue'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai/src/activities/chat/index.ts` around lines 645 - 696,
The executeToolCalls + drainToolCallGenerator path can trigger
__lazy__tool__discovery__ but never rehydrates the active tool set, so after
getting executionResult you must refresh this.tools and this.toolCallManager the
same way processToolCalls does; add a call (or shared helper) to re-fetch/update
the active tools/toolCallManager immediately after executionResult is obtained
(before returning 'wait' or 'continue') so subsequent turns see newly discovered
tools (reference executeToolCalls, drainToolCallGenerator, processToolCalls,
this.tools, this.toolCallManager, __lazy__tool__discovery__).
🧹 Nitpick comments (2)
packages/typescript/ai/src/activities/chat/index.ts (2)

611-641: Factor the undiscovered-lazy filtering into one helper.

Both branches build the same output-error payload and executable-call split. Pulling that into a shared helper will keep pending-call recovery and same-turn execution from drifting the next time this error contract changes.

Also applies to: 715-749

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai/src/activities/chat/index.ts` around lines 611 - 641,
The code duplicates logic that filters pendingToolCalls into
executablePendingCalls and builds undiscoveredLazyResults with the same
'output-error' payload; refactor by extracting a helper (e.g.,
buildUndiscoveredLazyFilter) that accepts pendingToolCalls and
this.lazyToolManager and returns { executablePendingCalls,
undiscoveredLazyResults } using this.lazyToolManager.isUndiscoveredLazyTool and
getUndiscoveredToolError to construct the ToolResult entries, then replace the
two duplicated blocks (the current block around pendingToolCalls ->
executablePendingCalls and the similar block at 715-749) to call the helper and
reuse its returned arrays before emitting via this.emitToolResults with
finishEvent.

258-264: Keep emitted toolNames in sync with the runtime tool surface.

this.tools is now mutable, but event metadata is still seeded from the original params.tools. After a discovery batch, later aiEventClient.emit(...) calls will keep reporting the stale pre-discovery tool list instead of the actual active tools.

Possible follow-up
-    const { tools, temperature, topP, maxTokens, metadata } = this.params
+    const { temperature, topP, maxTokens, metadata } = this.params
@@
-    this.eventToolNames = tools?.map((t) => t.name)
+    this.eventToolNames = this.tools.map((t) => t.name)
     if (this.lazyToolManager.hasNewlyDiscoveredTools()) {
       this.tools = this.lazyToolManager.getActiveTools()
       this.toolCallManager = new ToolCallManager(this.tools)
+      this.eventToolNames = this.tools.map((tool) => tool.name)
       this.setToolPhase('continue')
       return
     }

Also applies to: 804-808

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai/src/activities/chat/index.ts` around lines 258 - 264,
The emitted event metadata is seeded from the static config.params.tools but
runtime tools are mutable (discovery updates), so change event emission to use
the active runtime tool list (this.lazyToolManager.getActiveTools() or
this.tools after assignment) instead of config.params.tools; update the
initialization so this.tools is set from this.lazyToolManager.getActiveTools(),
ensure any aiEventClient.emit(...) calls (including where toolNames are
assembled) read from this.tools (or call getActiveTools()) at emit time, and
after discovery update this.tools = this.lazyToolManager.getActiveTools() so
subsequent emits reflect the true active tool surface.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/typescript/ai/src/activities/chat/index.ts`:
- Around line 645-696: The executeToolCalls + drainToolCallGenerator path can
trigger __lazy__tool__discovery__ but never rehydrates the active tool set, so
after getting executionResult you must refresh this.tools and
this.toolCallManager the same way processToolCalls does; add a call (or shared
helper) to re-fetch/update the active tools/toolCallManager immediately after
executionResult is obtained (before returning 'wait' or 'continue') so
subsequent turns see newly discovered tools (reference executeToolCalls,
drainToolCallGenerator, processToolCalls, this.tools, this.toolCallManager,
__lazy__tool__discovery__).

---

Nitpick comments:
In `@packages/typescript/ai/src/activities/chat/index.ts`:
- Around line 611-641: The code duplicates logic that filters pendingToolCalls
into executablePendingCalls and builds undiscoveredLazyResults with the same
'output-error' payload; refactor by extracting a helper (e.g.,
buildUndiscoveredLazyFilter) that accepts pendingToolCalls and
this.lazyToolManager and returns { executablePendingCalls,
undiscoveredLazyResults } using this.lazyToolManager.isUndiscoveredLazyTool and
getUndiscoveredToolError to construct the ToolResult entries, then replace the
two duplicated blocks (the current block around pendingToolCalls ->
executablePendingCalls and the similar block at 715-749) to call the helper and
reuse its returned arrays before emitting via this.emitToolResults with
finishEvent.
- Around line 258-264: The emitted event metadata is seeded from the static
config.params.tools but runtime tools are mutable (discovery updates), so change
event emission to use the active runtime tool list
(this.lazyToolManager.getActiveTools() or this.tools after assignment) instead
of config.params.tools; update the initialization so this.tools is set from
this.lazyToolManager.getActiveTools(), ensure any aiEventClient.emit(...) calls
(including where toolNames are assembled) read from this.tools (or call
getActiveTools()) at emit time, and after discovery update this.tools =
this.lazyToolManager.getActiveTools() so subsequent emits reflect the true
active tool surface.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d1fc5c5f-7d48-41ae-aafe-9d1173a784f1

📥 Commits

Reviewing files that changed from the base of the PR and between 1f3ab5f and 04394e9.

📒 Files selected for processing (1)
  • packages/typescript/ai/src/activities/chat/index.ts

@changeset-bot
Copy link

changeset-bot bot commented Mar 12, 2026

🦋 Changeset detected

Latest commit: 788e476

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 29 packages
Name Type
@tanstack/ai Minor
@tanstack/ai-openrouter Major
@tanstack/ai-anthropic Major
@tanstack/ai-client Patch
@tanstack/ai-devtools-core Patch
@tanstack/ai-elevenlabs Major
@tanstack/ai-event-client Major
@tanstack/ai-fal Major
@tanstack/ai-gemini Major
@tanstack/ai-grok Major
@tanstack/ai-groq Major
@tanstack/ai-ollama Major
@tanstack/ai-openai Major
@tanstack/ai-preact Major
@tanstack/ai-react Major
@tanstack/ai-solid Major
@tanstack/ai-svelte Major
@tanstack/ai-vue Major
@tanstack/tests-adapters Patch
@tanstack/smoke-tests-e2e Patch
ts-svelte-chat Patch
ts-vue-chat Patch
@tanstack/ai-react-ui Major
@tanstack/ai-solid-ui Major
vanilla-chat Patch
@tanstack/preact-ai-devtools Patch
@tanstack/react-ai-devtools Patch
@tanstack/solid-ai-devtools Patch
@tanstack/ai-vue-ui Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/typescript/ai/src/activities/chat/index.ts (2)

688-774: ⚠️ Potential issue | 🟠 Major

Refresh the active tool set after replaying pending discovery calls.

executeToolCalls() can execute the lazy discovery tool here, but this path never rebuilds this.tools / ToolCallManager afterward. If a run resumes with a pending discovery call in history, the next model request is still sent with the pre-discovery tool list, so the newly discovered tool remains unavailable until some later discovery happens.

Suggested fix
     // Consume the async generator, yielding custom events and collecting the return value
     const executionResult = yield* this.drainToolCallGenerator(generator)
 
+    if (this.lazyToolManager.hasNewlyDiscoveredTools()) {
+      this.tools = this.lazyToolManager.getActiveTools()
+      this.toolCallManager.clear()
+      this.toolCallManager = new ToolCallManager(this.tools)
+    }
+
     // Check if middleware aborted during pending tool execution
     if (this.isMiddlewareAborted()) {
       this.setToolPhase('stop')
       return 'stop'
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai/src/activities/chat/index.ts` around lines 688 - 774,
After draining the generator returned by executeToolCalls (i.e., after const
executionResult = yield* this.drainToolCallGenerator(generator)) refresh the
active tool set / ToolCallManager so any tools discovered during pending
discovery calls become available for the next model request; specifically,
reinitialize or rebuild this.tools / the ToolCallManager from the updated
discovery state (or call the helper that re-registers tools) before calling
middlewareRunner.runOnToolPhaseComplete and before building chunks with
buildToolResultChunks/buildApprovalChunks/buildClientToolChunks so the
subsequent request uses the updated tool list.

654-732: ⚠️ Potential issue | 🟡 Minor

Include undiscovered-tool errors in the tool-phase completion payload.

These branches emit undiscoveredLazyResults as TOOL_CALL_END chunks, then call runOnToolPhaseComplete() with only executionResult.results. Middleware/devtools will see a partial aggregate for this path, because the undiscovered calls have an emitted outcome but never appear in the completion payload.

Suggested fix
-    await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
-      toolCalls: pendingToolCalls,
-      results: executionResult.results,
+    const phaseResults = [...undiscoveredLazyResults, ...executionResult.results]
+    await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
+      toolCalls: pendingToolCalls,
+      results: phaseResults,
       needsApproval: executionResult.needsApproval,
       needsClientExecution: executionResult.needsClientExecution,
     })

Apply the same merge in processToolCalls().

Also applies to: 793-878

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai/src/activities/chat/index.ts` around lines 654 - 732,
The completion payload omits undiscovered lazy-tool errors: merge
undiscoveredLazyResults into the final results before calling
this.middlewareRunner.runOnToolPhaseComplete so middleware/devtools receive a
complete aggregate; specifically, after building undiscoveredLazyResults (from
processToolCalls's pendingToolCalls branch using buildToolResultChunks) combine
undiscoveredLazyResults with executionResult.results (and update
needsApproval/needsClientExecution if applicable) and pass that merged results
array to runOnToolPhaseComplete (same fix also apply to the other
processToolCalls branch around lines 793-878).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/typescript/ai/src/activities/chat/index.ts`:
- Around line 688-774: After draining the generator returned by executeToolCalls
(i.e., after const executionResult = yield*
this.drainToolCallGenerator(generator)) refresh the active tool set /
ToolCallManager so any tools discovered during pending discovery calls become
available for the next model request; specifically, reinitialize or rebuild
this.tools / the ToolCallManager from the updated discovery state (or call the
helper that re-registers tools) before calling
middlewareRunner.runOnToolPhaseComplete and before building chunks with
buildToolResultChunks/buildApprovalChunks/buildClientToolChunks so the
subsequent request uses the updated tool list.
- Around line 654-732: The completion payload omits undiscovered lazy-tool
errors: merge undiscoveredLazyResults into the final results before calling
this.middlewareRunner.runOnToolPhaseComplete so middleware/devtools receive a
complete aggregate; specifically, after building undiscoveredLazyResults (from
processToolCalls's pendingToolCalls branch using buildToolResultChunks) combine
undiscoveredLazyResults with executionResult.results (and update
needsApproval/needsClientExecution if applicable) and pass that merged results
array to runOnToolPhaseComplete (same fix also apply to the other
processToolCalls branch around lines 793-878).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1f86ef1d-b2ad-44e2-b301-4217c5f6df71

📥 Commits

Reviewing files that changed from the base of the PR and between 04394e9 and 994129a.

📒 Files selected for processing (4)
  • docs/config.json
  • examples/ts-react-chat/src/routes/api.tanchat.ts
  • packages/typescript/ai/src/activities/chat/index.ts
  • packages/typescript/ai/tests/chat.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/config.json
  • packages/typescript/ai/tests/chat.test.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
.changeset/sixty-lions-accept.md (1)

5-5: Make the changeset note describe the shipped behavior.

Fix issue with tool calling is too vague for a user-facing changelog entry, especially for a PR centered on lazy tool discovery. Please name the actual behavior change so consumers can tell what was added or fixed from the release notes.

Suggested wording
-Fix issue with tool calling
+Add lazy tool discovery support for tool calling and improve undiscovered-tool handling
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.changeset/sixty-lions-accept.md at line 5, Update the changeset note text
to a clear, user-facing description of the shipped behavior change: replace the
vague phrase "Fix issue with tool calling" with a concise statement that
describes the actual change introduced by the PR (e.g., "Fix lazy tool discovery
so tools are discovered only when first invoked" or similar), ensuring the
changeset file ".changeset/sixty-lions-accept.md" contains that explicit
behavior wording so consumers can understand what was added or fixed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In @.changeset/sixty-lions-accept.md:
- Line 5: Update the changeset note text to a clear, user-facing description of
the shipped behavior change: replace the vague phrase "Fix issue with tool
calling" with a concise statement that describes the actual change introduced by
the PR (e.g., "Fix lazy tool discovery so tools are discovered only when first
invoked" or similar), ensuring the changeset file
".changeset/sixty-lions-accept.md" contains that explicit behavior wording so
consumers can understand what was added or fixed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 43b1c670-3df3-467e-8370-05af2582b761

📥 Commits

Reviewing files that changed from the base of the PR and between 994129a and 788e476.

📒 Files selected for processing (2)
  • .changeset/sixty-lions-accept.md
  • packages/typescript/ai-openrouter/src/adapters/text.ts

@AlonMiz
Copy link

AlonMiz commented Mar 13, 2026

This is perfect. lazy tool discovery is the future of tools.
I imagned it for a while. most libs will probably going to steal this soon :)
I do wonder about the ability to load tools with description only and add the schema lazily on demand. this would further improve token management

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants