Skip to content

Commit 21720dd

Browse files
fix(ai): keep lazy-tool discovery callable when re-requested after all tools are discovered (#795)
* fix(ai): keep lazy-tool discovery callable when re-requested after all tools are discovered Once every lazy tool has been discovered, the synthetic `__lazy__tool__discovery__` tool is dropped from the set advertised to the model (a deliberate token optimization). But if the model re-requested discovery anyway — common in long-context turns or when it overlooks that a tool is already available — the call fell through to tool execution and came back as `Unknown tool: __lazy__tool__discovery__`. Decouple the advertised tool set from the executable one: the discovery tool is now kept executable for the turn whenever a pending call references it, even after it leaves the advertised set, so re-discovery returns the schemas again instead of erroring. The advertised set is unchanged. Discovery is also now idempotent — re-requesting an already-discovered tool returns its schema without triggering a redundant tool-list refresh. Also export `DISCOVERY_TOOL_NAME` so custom message-compaction logic can reference the discovery tool by constant instead of hard-coding the string. Closes #788 * test(ai): use DISCOVERY_TOOL_NAME constant and fix import order in lazy-tool tests Address review feedback on #795: reorder the value import ahead of the type-only import in lazy-tool-manager.test.ts, and replace the hard-coded '__lazy__tool__discovery__' literals in the re-discovery regression tests with the exported DISCOVERY_TOOL_NAME constant. * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 81e3aee commit 21720dd

6 files changed

Lines changed: 335 additions & 9 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@tanstack/ai': patch
3+
---
4+
5+
fix: don't error when an already-discovered lazy tool's discovery is re-requested
6+
7+
Lazy tools use progressive disclosure: a synthetic `__lazy__tool__discovery__`
8+
tool is advertised so the model can reveal a lazy tool's schema on demand. Once
9+
**every** lazy tool has been discovered, that discovery tool is (intentionally)
10+
dropped from the set advertised to the model to save tokens. But if the model
11+
then re-requested discovery anyway — common in long-context or when it overlooks
12+
that a tool is already available — the call fell through to tool execution and
13+
came back as `Unknown tool: __lazy__tool__discovery__`.
14+
15+
The discovery tool is now kept _executable_ for the turn whenever a pending call
16+
references it, even after it leaves the advertised set, so re-discovery returns
17+
the schemas again instead of erroring. The advertised set is unchanged. The
18+
discovery tool is also now idempotent — re-requesting an already-discovered tool
19+
returns its schema without triggering a redundant tool-list refresh.
20+
21+
Additionally, `DISCOVERY_TOOL_NAME` is now exported from `@tanstack/ai` so custom
22+
message-compaction / history-trimming logic can reference the discovery tool by
23+
constant instead of hard-coding the string.

packages/ai/src/activities/chat/index.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,6 +1202,23 @@ class TextEngine<
12021202
}
12031203
}
12041204

1205+
/**
1206+
* Tools available for execution this turn. The discovery tool is dropped
1207+
* from the advertised set (`this.tools`) once every lazy tool is discovered,
1208+
* but a model may still re-request discovery; this widens execution lookup
1209+
* to include it so such calls don't fail with "Unknown tool". Centralised so
1210+
* both execution sites (`processToolCalls` and `checkForPendingToolCalls`)
1211+
* stay in sync.
1212+
*/
1213+
private resolveExecutableTools(
1214+
toolCalls: ReadonlyArray<ToolCall>,
1215+
): ReadonlyArray<AnyTool> {
1216+
return this.lazyToolManager.getExecutableTools(
1217+
this.tools,
1218+
toolCalls.map((tc) => tc.function.name),
1219+
)
1220+
}
1221+
12051222
private async *checkForPendingToolCalls(): AsyncGenerator<
12061223
StreamChunk,
12071224
ToolPhaseResult,
@@ -1250,7 +1267,7 @@ class TextEngine<
12501267

12511268
const generator = executeToolCalls(
12521269
executablePendingCalls,
1253-
this.tools,
1270+
this.resolveExecutableTools(executablePendingCalls),
12541271
approvals,
12551272
clientToolResults,
12561273
(eventName, data) => this.createCustomEventChunk(eventName, data),
@@ -1412,7 +1429,7 @@ class TextEngine<
14121429

14131430
const generator = executeToolCalls(
14141431
executableToolCalls,
1415-
this.tools,
1432+
this.resolveExecutableTools(executableToolCalls),
14161433
approvals,
14171434
clientToolResults,
14181435
(eventName, data) => this.createCustomEventChunk(eventName, data),

packages/ai/src/activities/chat/tools/lazy-tool-manager.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { convertSchemaToJsonSchema } from './schema-converter'
2-
import type { Tool } from '../../../types'
2+
import type { AnyTool, Tool } from '../../../types'
33

4-
const DISCOVERY_TOOL_NAME = '__lazy__tool__discovery__'
4+
/**
5+
* Name of the synthetic tool the LLM calls to discover lazy tools.
6+
*
7+
* Exported so callers building custom message-compaction / history-trimming
8+
* logic can reference the discovery tool by constant instead of hard-coding
9+
* the string (which is an internal contract that could change).
10+
*/
11+
export const DISCOVERY_TOOL_NAME = '__lazy__tool__discovery__'
512

613
/**
714
* Manages lazy tool discovery for the chat agent loop.
@@ -87,6 +94,35 @@ export class LazyToolManager {
8794
return active
8895
}
8996

97+
/**
98+
* Returns the tools that should be available for *execution* this turn.
99+
*
100+
* This is the advertised set (`getActiveTools()`, passed in as `activeTools`)
101+
* plus the discovery tool when a pending call references it but it is no
102+
* longer advertised. Once every lazy tool has been discovered the discovery
103+
* tool is dropped from the advertised set, but a model may still re-request
104+
* discovery (long context / hallucination); keeping it executable lets that
105+
* call return the schemas again instead of failing with "Unknown tool".
106+
*
107+
* The advertised set is intentionally left unchanged — only execution lookup
108+
* is widened. Operates on the already-built `activeTools`: it must NOT call
109+
* `getActiveTools()`, which would reset `hasNewDiscoveries` before the
110+
* post-execution refresh check in the agent loop.
111+
*/
112+
getExecutableTools(
113+
activeTools: ReadonlyArray<AnyTool>,
114+
pendingToolCallNames: ReadonlyArray<string>,
115+
): ReadonlyArray<AnyTool> {
116+
if (
117+
this.discoveryTool &&
118+
pendingToolCallNames.includes(DISCOVERY_TOOL_NAME) &&
119+
!activeTools.some((t) => t.name === DISCOVERY_TOOL_NAME)
120+
) {
121+
return [...activeTools, this.discoveryTool]
122+
}
123+
return activeTools
124+
}
125+
90126
/**
91127
* Returns whether new tools have been discovered since the last getActiveTools() call.
92128
*/
@@ -221,8 +257,14 @@ export class LazyToolManager {
221257
for (const name of args.toolNames) {
222258
const tool = lazyToolMap.get(name)
223259
if (tool) {
224-
manager.discoveredTools.add(name)
225-
manager.hasNewDiscoveries = true
260+
// Only flag a refresh for genuinely new discoveries. Re-requesting
261+
// an already-discovered tool still returns its schema below (the
262+
// model asked for it), but must not trigger a redundant tool-list
263+
// refresh + continue in the agent loop.
264+
if (!manager.discoveredTools.has(name)) {
265+
manager.discoveredTools.add(name)
266+
manager.hasNewDiscoveries = true
267+
}
226268
const jsonSchema = tool.inputSchema
227269
? convertSchemaToJsonSchema(tool.inputSchema)
228270
: undefined

packages/ai/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ export {
8282
// Tool call management
8383
export { ToolCallManager } from './activities/chat/tools/tool-calls'
8484

85+
// Lazy tool discovery (name of the synthetic discovery tool, for custom
86+
// message-compaction logic that needs to reference it)
87+
export { DISCOVERY_TOOL_NAME } from './activities/chat/tools/lazy-tool-manager'
88+
8589
// Provider tool type
8690
export type { ProviderTool } from './tools/provider-tool'
8791
export { brandProviderTool } from './tools/provider-tool'

packages/ai/tests/chat.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it, vi } from 'vitest'
22
import { chat, createChatOptions } from '../src/activities/chat/index'
3+
import { DISCOVERY_TOOL_NAME } from '../src/activities/chat/tools/lazy-tool-manager'
34
import { EventType } from '../src/types'
45
import type { StreamChunk, Tool } from '../src/types'
56
import {
@@ -1767,6 +1768,158 @@ describe('chat()', () => {
17671768
expect(toolNames).not.toContain('__lazy__tool__discovery__')
17681769
expect(toolNames).toContain('normalTool')
17691770
})
1771+
1772+
it('should not error when the model re-requests discovery after all lazy tools are discovered (#788)', async () => {
1773+
const weatherExecute = vi.fn().mockReturnValue({ temp: 72 })
1774+
const toolNamesPerCall: Array<Array<string>> = []
1775+
1776+
let callCount = 0
1777+
const { adapter } = createMockAdapter({
1778+
chatStreamFn: (opts: any) => {
1779+
callCount++
1780+
toolNamesPerCall.push((opts.tools ?? []).map((t: any) => t.name))
1781+
1782+
if (callCount === 1) {
1783+
// Discover the only lazy tool -> all discovered, so the discovery
1784+
// tool is dropped from the advertised set.
1785+
return (async function* () {
1786+
yield ev.runStarted()
1787+
yield ev.toolStart('c1', DISCOVERY_TOOL_NAME)
1788+
yield ev.toolArgs(
1789+
'c1',
1790+
JSON.stringify({ toolNames: ['getWeather'] }),
1791+
)
1792+
yield ev.runFinished('tool_calls')
1793+
})()
1794+
} else if (callCount === 2) {
1795+
// Model overlooks that getWeather is already available and asks to
1796+
// discover it again, even though the discovery tool is no longer
1797+
// advertised.
1798+
return (async function* () {
1799+
yield ev.runStarted()
1800+
yield ev.toolStart('c2', DISCOVERY_TOOL_NAME)
1801+
yield ev.toolArgs(
1802+
'c2',
1803+
JSON.stringify({ toolNames: ['getWeather'] }),
1804+
)
1805+
yield ev.runFinished('tool_calls')
1806+
})()
1807+
}
1808+
return (async function* () {
1809+
yield ev.runStarted()
1810+
yield ev.textStart()
1811+
yield ev.textContent('done')
1812+
yield ev.textEnd()
1813+
yield ev.runFinished('stop')
1814+
})()
1815+
},
1816+
})
1817+
1818+
const stream = chat({
1819+
adapter,
1820+
messages: [{ role: 'user', content: 'Weather?' }],
1821+
tools: [lazyServerTool('getWeather', weatherExecute)],
1822+
})
1823+
1824+
const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)
1825+
1826+
// The discovery tool was removed from the advertised set on the 2nd call,
1827+
// proving we fixed execution without re-advertising it.
1828+
expect(toolNamesPerCall[1]).not.toContain(DISCOVERY_TOOL_NAME)
1829+
expect(toolNamesPerCall[1]).toContain('getWeather')
1830+
1831+
// Re-requesting discovery must NOT produce an "Unknown tool" error.
1832+
const toolResults = chunks.filter(
1833+
(c) => c.type === 'TOOL_CALL_RESULT',
1834+
) as Array<any>
1835+
const unknownToolError = toolResults.find(
1836+
(c: any) =>
1837+
typeof c.content === 'string' && c.content.includes('Unknown tool'),
1838+
)
1839+
expect(unknownToolError).toBeUndefined()
1840+
1841+
// The run progressed past the re-discovery turn to the final answer.
1842+
const text = chunks
1843+
.filter((c) => c.type === 'TEXT_MESSAGE_CONTENT')
1844+
.map((c: any) => c.delta)
1845+
.join('')
1846+
expect(text).toContain('done')
1847+
})
1848+
1849+
it('should handle a discovery call batched with an already-available tool in one turn', async () => {
1850+
const lazyAExecute = vi.fn().mockReturnValue({ a: 1 })
1851+
const lazyBExecute = vi.fn().mockReturnValue({ b: 2 })
1852+
const toolNamesPerCall: Array<Array<string>> = []
1853+
1854+
let callCount = 0
1855+
const { adapter } = createMockAdapter({
1856+
chatStreamFn: (opts: any) => {
1857+
callCount++
1858+
toolNamesPerCall.push((opts.tools ?? []).map((t: any) => t.name))
1859+
1860+
if (callCount === 1) {
1861+
// Discover lazyA only (lazyB stays undiscovered).
1862+
return (async function* () {
1863+
yield ev.runStarted()
1864+
yield ev.toolStart('d1', DISCOVERY_TOOL_NAME)
1865+
yield ev.toolArgs('d1', JSON.stringify({ toolNames: ['lazyA'] }))
1866+
yield ev.runFinished('tool_calls')
1867+
})()
1868+
} else if (callCount === 2) {
1869+
// One batch: call the already-discovered lazyA AND discover lazyB.
1870+
return (async function* () {
1871+
yield ev.runStarted()
1872+
yield ev.toolStart('a1', 'lazyA')
1873+
yield ev.toolArgs('a1', '{}')
1874+
yield ev.toolStart('d2', DISCOVERY_TOOL_NAME)
1875+
yield ev.toolArgs('d2', JSON.stringify({ toolNames: ['lazyB'] }))
1876+
yield ev.runFinished('tool_calls')
1877+
})()
1878+
} else if (callCount === 3) {
1879+
// lazyB is now available -> call it.
1880+
return (async function* () {
1881+
yield ev.runStarted()
1882+
yield ev.toolStart('b1', 'lazyB')
1883+
yield ev.toolArgs('b1', '{}')
1884+
yield ev.runFinished('tool_calls')
1885+
})()
1886+
}
1887+
return (async function* () {
1888+
yield ev.runStarted()
1889+
yield ev.textStart()
1890+
yield ev.textContent('ok')
1891+
yield ev.textEnd()
1892+
yield ev.runFinished('stop')
1893+
})()
1894+
},
1895+
})
1896+
1897+
const stream = chat({
1898+
adapter,
1899+
messages: [{ role: 'user', content: 'go' }],
1900+
tools: [
1901+
lazyServerTool('lazyA', lazyAExecute),
1902+
lazyServerTool('lazyB', lazyBExecute),
1903+
],
1904+
})
1905+
1906+
const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)
1907+
1908+
// lazyA executed despite sharing a batch with a discovery call.
1909+
expect(lazyAExecute).toHaveBeenCalledTimes(1)
1910+
// lazyB became available after the batched discovery and executed.
1911+
expect(toolNamesPerCall[2]).toContain('lazyB')
1912+
expect(lazyBExecute).toHaveBeenCalledTimes(1)
1913+
1914+
const toolResults = chunks.filter(
1915+
(c) => c.type === 'TOOL_CALL_RESULT',
1916+
) as Array<any>
1917+
const unknownToolError = toolResults.find(
1918+
(c: any) =>
1919+
typeof c.content === 'string' && c.content.includes('Unknown tool'),
1920+
)
1921+
expect(unknownToolError).toBeUndefined()
1922+
})
17701923
})
17711924

17721925
// ==========================================================================

0 commit comments

Comments
 (0)