@tanstack/ai
Version:
Core TanStack AI library - Open source AI SDK
255 lines (226 loc) • 7.36 kB
text/typescript
import { convertSchemaToJsonSchema } from './schema-converter'
import type { Tool } from '../../../types'
const DISCOVERY_TOOL_NAME = '__lazy__tool__discovery__'
/**
* Manages lazy tool discovery for the chat agent loop.
*
* Lazy tools are not sent to the LLM initially. Instead, a synthetic
* "discovery tool" is provided that lets the LLM discover lazy tools
* by name, receiving their full descriptions and schemas on demand.
*/
export class LazyToolManager {
private readonly eagerTools: ReadonlyArray<Tool>
private readonly lazyToolMap: Map<string, Tool>
private readonly discoveredTools: Set<string>
private hasNewDiscoveries: boolean
private readonly discoveryTool: Tool | null
constructor(
tools: ReadonlyArray<Tool>,
messages: ReadonlyArray<{
role: string
content?: any
toolCalls?: Array<{
id: string
type: string
function: { name: string; arguments: string }
}>
toolCallId?: string
}>,
) {
const eager: Array<Tool> = []
this.lazyToolMap = new Map()
this.discoveredTools = new Set()
this.hasNewDiscoveries = false
// Separate tools into eager and lazy
for (const tool of tools) {
if (tool.lazy) {
this.lazyToolMap.set(tool.name, tool)
} else {
eager.push(tool)
}
}
this.eagerTools = eager
// If no lazy tools, no discovery tool needed
if (this.lazyToolMap.size === 0) {
this.discoveryTool = null
return
}
// Scan message history to pre-populate discoveredTools
this.scanMessageHistory(messages)
// Create the synthetic discovery tool
this.discoveryTool = this.createDiscoveryTool()
}
/**
* Returns the set of tools that should be sent to the LLM:
* eager tools + discovered lazy tools + discovery tool (if undiscovered tools remain).
* Resets the hasNewDiscoveries flag.
*/
getActiveTools(): Array<Tool> {
this.hasNewDiscoveries = false
const active: Array<Tool> = [...this.eagerTools]
// Add discovered lazy tools
for (const name of this.discoveredTools) {
const tool = this.lazyToolMap.get(name)
if (tool) {
active.push(tool)
}
}
// Add discovery tool if there are still undiscovered lazy tools
if (
this.discoveryTool &&
this.discoveredTools.size < this.lazyToolMap.size
) {
active.push(this.discoveryTool)
}
return active
}
/**
* Returns whether new tools have been discovered since the last getActiveTools() call.
*/
hasNewlyDiscoveredTools(): boolean {
return this.hasNewDiscoveries
}
/**
* Returns true if the given name is a lazy tool that has not yet been discovered.
*/
isUndiscoveredLazyTool(name: string): boolean {
return this.lazyToolMap.has(name) && !this.discoveredTools.has(name)
}
/**
* Returns a helpful error message for when an undiscovered lazy tool is called.
*/
getUndiscoveredToolError(name: string): string {
return `Error: Tool '${name}' must be discovered first. Call ${DISCOVERY_TOOL_NAME} with toolNames: ['${name}'] to discover it.`
}
/**
* Scans message history to find previously discovered lazy tools.
* Looks for assistant messages with discovery tool calls and their
* corresponding tool result messages.
*/
private scanMessageHistory(
messages: ReadonlyArray<{
role: string
content?: any
toolCalls?: Array<{
id: string
type: string
function: { name: string; arguments: string }
}>
toolCallId?: string
}>,
): void {
// Collect tool call IDs for discovery tool invocations
const discoveryCallIds = new Set<string>()
for (const msg of messages) {
if (msg.role === 'assistant' && msg.toolCalls) {
for (const tc of msg.toolCalls) {
if (tc.function.name === DISCOVERY_TOOL_NAME) {
discoveryCallIds.add(tc.id)
}
}
}
}
if (discoveryCallIds.size === 0) return
// Find corresponding tool result messages
for (const msg of messages) {
if (
msg.role === 'tool' &&
msg.toolCallId &&
discoveryCallIds.has(msg.toolCallId)
) {
try {
const content =
typeof msg.content === 'string'
? msg.content
: JSON.stringify(msg.content)
const parsed = JSON.parse(content)
if (parsed && Array.isArray(parsed.tools)) {
for (const tool of parsed.tools) {
if (
tool &&
typeof tool.name === 'string' &&
this.lazyToolMap.has(tool.name)
) {
this.discoveredTools.add(tool.name)
}
}
}
} catch {
// Malformed JSON — skip gracefully
}
}
}
}
/**
* Creates the synthetic discovery tool that the LLM can call
* to discover lazy tools' descriptions and schemas.
*/
private createDiscoveryTool(): Tool {
const undiscoveredNames = (): Array<string> => {
const names: Array<string> = []
for (const [name] of this.lazyToolMap) {
if (!this.discoveredTools.has(name)) {
names.push(name)
}
}
return names
}
const lazyToolMap = this.lazyToolMap
// Build the static description with all lazy tool names
const allLazyNames = Array.from(this.lazyToolMap.keys())
const description = `You have access to additional tools that can be discovered. Available tools: [${allLazyNames.join(', ')}]. Call this tool with a list of tool names to discover their full descriptions and argument schemas before using them.`
// Use the arrow function to capture `this` context
const manager = this
return {
name: DISCOVERY_TOOL_NAME,
description,
inputSchema: {
type: 'object',
properties: {
toolNames: {
type: 'array',
items: { type: 'string' },
description:
'List of tool names to discover. Each name must match one of the available tools.',
},
},
required: ['toolNames'],
},
execute: (args: { toolNames: Array<string> }) => {
const tools: Array<{
name: string
description: string
inputSchema?: any
}> = []
const errors: Array<string> = []
for (const name of args.toolNames) {
const tool = lazyToolMap.get(name)
if (tool) {
manager.discoveredTools.add(name)
manager.hasNewDiscoveries = true
const jsonSchema = tool.inputSchema
? convertSchemaToJsonSchema(tool.inputSchema)
: undefined
tools.push({
name: tool.name,
description: tool.description,
...(jsonSchema ? { inputSchema: jsonSchema } : {}),
})
} else {
errors.push(
`Unknown tool: '${name}'. Available tools: [${undiscoveredNames().join(', ')}]`,
)
}
}
const result: {
tools: typeof tools
errors?: Array<string>
} = { tools }
if (errors.length > 0) {
result.errors = errors
}
return result
},
}
}
}