@tanstack/ai 0.8.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { devtoolsMiddleware } from '@tanstack/ai-event-client'
9
9
  import { streamToText } from '../../stream-to-response.js'
10
+ import { LazyToolManager } from './tools/lazy-tool-manager'
10
11
  import {
11
12
  MiddlewareAbortError,
12
13
  ToolCallManager,
@@ -237,7 +238,8 @@ class TextEngine<
237
238
  private systemPrompts: Array<string>
238
239
  private tools: Array<Tool>
239
240
  private readonly loopStrategy: AgentLoopStrategy
240
- private readonly toolCallManager: ToolCallManager
241
+ private toolCallManager: ToolCallManager
242
+ private readonly lazyToolManager: LazyToolManager
241
243
  private readonly initialMessageCount: number
242
244
  private readonly requestId: string
243
245
  private readonly streamId: string
@@ -273,10 +275,8 @@ class TextEngine<
273
275
  this.adapter = config.adapter
274
276
  this.params = config.params
275
277
  this.systemPrompts = config.params.systemPrompts || []
276
- this.tools = config.params.tools || []
277
278
  this.loopStrategy =
278
279
  config.params.agentLoopStrategy || maxIterationsStrategy(5)
279
- this.toolCallManager = new ToolCallManager(this.tools)
280
280
  this.initialMessageCount = config.params.messages.length
281
281
 
282
282
  // Extract client state (approvals, client tool results) from original messages BEFORE conversion
@@ -293,6 +293,14 @@ class TextEngine<
293
293
  this.messages = convertMessagesToModelMessages(
294
294
  config.params.messages as Array<any>,
295
295
  )
296
+
297
+ // Initialize lazy tool manager after messages are converted (needs message history for scanning)
298
+ this.lazyToolManager = new LazyToolManager(
299
+ config.params.tools || [],
300
+ this.messages,
301
+ )
302
+ this.tools = this.lazyToolManager.getActiveTools()
303
+ this.toolCallManager = new ToolCallManager(this.tools)
296
304
  this.requestId = this.createId('chat')
297
305
  this.streamId = this.createId('stream')
298
306
  this.effectiveRequest = config.params.abortController
@@ -643,10 +651,42 @@ class TextEngine<
643
651
 
644
652
  const finishEvent = this.createSyntheticFinishedEvent()
645
653
 
654
+ // Handle undiscovered lazy tool calls with self-correcting error messages
655
+ const undiscoveredLazyResults: Array<ToolResult> = []
656
+ const executablePendingCalls = pendingToolCalls.filter((tc) => {
657
+ if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) {
658
+ undiscoveredLazyResults.push({
659
+ toolCallId: tc.id,
660
+ toolName: tc.function.name,
661
+ result: {
662
+ error: this.lazyToolManager.getUndiscoveredToolError(
663
+ tc.function.name,
664
+ ),
665
+ },
666
+ state: 'output-error',
667
+ })
668
+ return false
669
+ }
670
+ return true
671
+ })
672
+
673
+ if (undiscoveredLazyResults.length > 0) {
674
+ for (const chunk of this.buildToolResultChunks(
675
+ undiscoveredLazyResults,
676
+ finishEvent,
677
+ )) {
678
+ yield chunk
679
+ }
680
+ }
681
+
682
+ if (executablePendingCalls.length === 0) {
683
+ return 'continue'
684
+ }
685
+
646
686
  const { approvals, clientToolResults } = this.collectClientState()
647
687
 
648
688
  const generator = executeToolCalls(
649
- pendingToolCalls,
689
+ executablePendingCalls,
650
690
  this.tools,
651
691
  approvals,
652
692
  clientToolResults,
@@ -750,12 +790,47 @@ class TextEngine<
750
790
 
751
791
  this.addAssistantToolCallMessage(toolCalls)
752
792
 
793
+ // Handle undiscovered lazy tool calls with self-correcting error messages
794
+ const undiscoveredLazyResults: Array<ToolResult> = []
795
+ const executableToolCalls = toolCalls.filter((tc) => {
796
+ if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) {
797
+ undiscoveredLazyResults.push({
798
+ toolCallId: tc.id,
799
+ toolName: tc.function.name,
800
+ result: {
801
+ error: this.lazyToolManager.getUndiscoveredToolError(
802
+ tc.function.name,
803
+ ),
804
+ },
805
+ state: 'output-error',
806
+ })
807
+ return false
808
+ }
809
+ return true
810
+ })
811
+
812
+ if (undiscoveredLazyResults.length > 0) {
813
+ const finishEvt = this.finishedEvent!
814
+ for (const chunk of this.buildToolResultChunks(
815
+ undiscoveredLazyResults,
816
+ finishEvt,
817
+ )) {
818
+ yield chunk
819
+ }
820
+ }
821
+
822
+ if (executableToolCalls.length === 0) {
823
+ // All tool calls were undiscovered lazy tools — errors emitted, continue loop
824
+ this.toolCallManager.clear()
825
+ this.setToolPhase('continue')
826
+ return
827
+ }
753
828
  this.middlewareCtx.phase = 'beforeTools'
754
829
 
755
830
  const { approvals, clientToolResults } = this.collectClientState()
756
831
 
757
832
  const generator = executeToolCalls(
758
- toolCalls,
833
+ executableToolCalls,
759
834
  this.tools,
760
835
  approvals,
761
836
  clientToolResults,
@@ -842,6 +917,14 @@ class TextEngine<
842
917
  yield chunk
843
918
  }
844
919
 
920
+ // Refresh tools if lazy tools were discovered in this batch
921
+ if (this.lazyToolManager.hasNewlyDiscoveredTools()) {
922
+ this.tools = this.lazyToolManager.getActiveTools()
923
+ this.toolCallManager = new ToolCallManager(this.tools)
924
+ this.setToolPhase('continue')
925
+ return
926
+ }
927
+
845
928
  this.toolCallManager.clear()
846
929
 
847
930
  this.setToolPhase('continue')
@@ -0,0 +1,254 @@
1
+ import { convertSchemaToJsonSchema } from './schema-converter'
2
+ import type { Tool } from '../../../types'
3
+
4
+ const DISCOVERY_TOOL_NAME = '__lazy__tool__discovery__'
5
+
6
+ /**
7
+ * Manages lazy tool discovery for the chat agent loop.
8
+ *
9
+ * Lazy tools are not sent to the LLM initially. Instead, a synthetic
10
+ * "discovery tool" is provided that lets the LLM discover lazy tools
11
+ * by name, receiving their full descriptions and schemas on demand.
12
+ */
13
+ export class LazyToolManager {
14
+ private readonly eagerTools: ReadonlyArray<Tool>
15
+ private readonly lazyToolMap: Map<string, Tool>
16
+ private readonly discoveredTools: Set<string>
17
+ private hasNewDiscoveries: boolean
18
+ private readonly discoveryTool: Tool | null
19
+
20
+ constructor(
21
+ tools: ReadonlyArray<Tool>,
22
+ messages: ReadonlyArray<{
23
+ role: string
24
+ content?: any
25
+ toolCalls?: Array<{
26
+ id: string
27
+ type: string
28
+ function: { name: string; arguments: string }
29
+ }>
30
+ toolCallId?: string
31
+ }>,
32
+ ) {
33
+ const eager: Array<Tool> = []
34
+ this.lazyToolMap = new Map()
35
+ this.discoveredTools = new Set()
36
+ this.hasNewDiscoveries = false
37
+
38
+ // Separate tools into eager and lazy
39
+ for (const tool of tools) {
40
+ if (tool.lazy) {
41
+ this.lazyToolMap.set(tool.name, tool)
42
+ } else {
43
+ eager.push(tool)
44
+ }
45
+ }
46
+ this.eagerTools = eager
47
+
48
+ // If no lazy tools, no discovery tool needed
49
+ if (this.lazyToolMap.size === 0) {
50
+ this.discoveryTool = null
51
+ return
52
+ }
53
+
54
+ // Scan message history to pre-populate discoveredTools
55
+ this.scanMessageHistory(messages)
56
+
57
+ // Create the synthetic discovery tool
58
+ this.discoveryTool = this.createDiscoveryTool()
59
+ }
60
+
61
+ /**
62
+ * Returns the set of tools that should be sent to the LLM:
63
+ * eager tools + discovered lazy tools + discovery tool (if undiscovered tools remain).
64
+ * Resets the hasNewDiscoveries flag.
65
+ */
66
+ getActiveTools(): Array<Tool> {
67
+ this.hasNewDiscoveries = false
68
+
69
+ const active: Array<Tool> = [...this.eagerTools]
70
+
71
+ // Add discovered lazy tools
72
+ for (const name of this.discoveredTools) {
73
+ const tool = this.lazyToolMap.get(name)
74
+ if (tool) {
75
+ active.push(tool)
76
+ }
77
+ }
78
+
79
+ // Add discovery tool if there are still undiscovered lazy tools
80
+ if (
81
+ this.discoveryTool &&
82
+ this.discoveredTools.size < this.lazyToolMap.size
83
+ ) {
84
+ active.push(this.discoveryTool)
85
+ }
86
+
87
+ return active
88
+ }
89
+
90
+ /**
91
+ * Returns whether new tools have been discovered since the last getActiveTools() call.
92
+ */
93
+ hasNewlyDiscoveredTools(): boolean {
94
+ return this.hasNewDiscoveries
95
+ }
96
+
97
+ /**
98
+ * Returns true if the given name is a lazy tool that has not yet been discovered.
99
+ */
100
+ isUndiscoveredLazyTool(name: string): boolean {
101
+ return this.lazyToolMap.has(name) && !this.discoveredTools.has(name)
102
+ }
103
+
104
+ /**
105
+ * Returns a helpful error message for when an undiscovered lazy tool is called.
106
+ */
107
+ getUndiscoveredToolError(name: string): string {
108
+ return `Error: Tool '${name}' must be discovered first. Call ${DISCOVERY_TOOL_NAME} with toolNames: ['${name}'] to discover it.`
109
+ }
110
+
111
+ /**
112
+ * Scans message history to find previously discovered lazy tools.
113
+ * Looks for assistant messages with discovery tool calls and their
114
+ * corresponding tool result messages.
115
+ */
116
+ private scanMessageHistory(
117
+ messages: ReadonlyArray<{
118
+ role: string
119
+ content?: any
120
+ toolCalls?: Array<{
121
+ id: string
122
+ type: string
123
+ function: { name: string; arguments: string }
124
+ }>
125
+ toolCallId?: string
126
+ }>,
127
+ ): void {
128
+ // Collect tool call IDs for discovery tool invocations
129
+ const discoveryCallIds = new Set<string>()
130
+
131
+ for (const msg of messages) {
132
+ if (msg.role === 'assistant' && msg.toolCalls) {
133
+ for (const tc of msg.toolCalls) {
134
+ if (tc.function.name === DISCOVERY_TOOL_NAME) {
135
+ discoveryCallIds.add(tc.id)
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ if (discoveryCallIds.size === 0) return
142
+
143
+ // Find corresponding tool result messages
144
+ for (const msg of messages) {
145
+ if (
146
+ msg.role === 'tool' &&
147
+ msg.toolCallId &&
148
+ discoveryCallIds.has(msg.toolCallId)
149
+ ) {
150
+ try {
151
+ const content =
152
+ typeof msg.content === 'string'
153
+ ? msg.content
154
+ : JSON.stringify(msg.content)
155
+ const parsed = JSON.parse(content)
156
+ if (parsed && Array.isArray(parsed.tools)) {
157
+ for (const tool of parsed.tools) {
158
+ if (
159
+ tool &&
160
+ typeof tool.name === 'string' &&
161
+ this.lazyToolMap.has(tool.name)
162
+ ) {
163
+ this.discoveredTools.add(tool.name)
164
+ }
165
+ }
166
+ }
167
+ } catch {
168
+ // Malformed JSON — skip gracefully
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Creates the synthetic discovery tool that the LLM can call
176
+ * to discover lazy tools' descriptions and schemas.
177
+ */
178
+ private createDiscoveryTool(): Tool {
179
+ const undiscoveredNames = (): Array<string> => {
180
+ const names: Array<string> = []
181
+ for (const [name] of this.lazyToolMap) {
182
+ if (!this.discoveredTools.has(name)) {
183
+ names.push(name)
184
+ }
185
+ }
186
+ return names
187
+ }
188
+
189
+ const lazyToolMap = this.lazyToolMap
190
+
191
+ // Build the static description with all lazy tool names
192
+ const allLazyNames = Array.from(this.lazyToolMap.keys())
193
+ 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.`
194
+
195
+ // Use the arrow function to capture `this` context
196
+ const manager = this
197
+
198
+ return {
199
+ name: DISCOVERY_TOOL_NAME,
200
+ description,
201
+ inputSchema: {
202
+ type: 'object',
203
+ properties: {
204
+ toolNames: {
205
+ type: 'array',
206
+ items: { type: 'string' },
207
+ description:
208
+ 'List of tool names to discover. Each name must match one of the available tools.',
209
+ },
210
+ },
211
+ required: ['toolNames'],
212
+ },
213
+ execute: (args: { toolNames: Array<string> }) => {
214
+ const tools: Array<{
215
+ name: string
216
+ description: string
217
+ inputSchema?: any
218
+ }> = []
219
+ const errors: Array<string> = []
220
+
221
+ for (const name of args.toolNames) {
222
+ const tool = lazyToolMap.get(name)
223
+ if (tool) {
224
+ manager.discoveredTools.add(name)
225
+ manager.hasNewDiscoveries = true
226
+ const jsonSchema = tool.inputSchema
227
+ ? convertSchemaToJsonSchema(tool.inputSchema)
228
+ : undefined
229
+ tools.push({
230
+ name: tool.name,
231
+ description: tool.description,
232
+ ...(jsonSchema ? { inputSchema: jsonSchema } : {}),
233
+ })
234
+ } else {
235
+ errors.push(
236
+ `Unknown tool: '${name}'. Available tools: [${undiscoveredNames().join(', ')}]`,
237
+ )
238
+ }
239
+ }
240
+
241
+ const result: {
242
+ tools: typeof tools
243
+ errors?: Array<string>
244
+ } = { tools }
245
+
246
+ if (errors.length > 0) {
247
+ result.errors = errors
248
+ }
249
+
250
+ return result
251
+ },
252
+ }
253
+ }
254
+ }
@@ -100,6 +100,9 @@ export class ToolCallManager {
100
100
  name: event.toolName,
101
101
  arguments: '',
102
102
  },
103
+ ...(event.providerMetadata && {
104
+ providerMetadata: event.providerMetadata,
105
+ }),
103
106
  })
104
107
  }
105
108
 
@@ -32,6 +32,7 @@ export interface ClientTool<
32
32
  inputSchema?: TInput
33
33
  outputSchema?: TOutput
34
34
  needsApproval?: boolean
35
+ lazy?: boolean
35
36
  metadata?: Record<string, unknown>
36
37
  execute?: (
37
38
  args: InferSchemaType<TInput>,
@@ -96,6 +97,7 @@ export interface ToolDefinitionConfig<
96
97
  inputSchema?: TInput
97
98
  outputSchema?: TOutput
98
99
  needsApproval?: boolean
100
+ lazy?: boolean
99
101
  metadata?: Record<string, unknown>
100
102
  }
101
103
 
@@ -427,25 +427,25 @@ export async function getVideoJobStatus<
427
427
  url: urlResult.url,
428
428
  }
429
429
  } catch (error) {
430
+ const errorMessage =
431
+ error instanceof Error ? error.message : 'Failed to get video URL'
430
432
  aiEventClient.emit('video:request:completed', {
431
433
  requestId,
432
434
  provider: adapter.name,
433
435
  model: adapter.model,
434
436
  requestType: 'status',
435
437
  jobId,
436
- status: statusResult.status,
438
+ status: 'failed',
437
439
  progress: statusResult.progress,
438
- error:
439
- error instanceof Error ? error.message : 'Failed to get video URL',
440
+ error: errorMessage,
440
441
  duration: Date.now() - startTime,
441
442
  timestamp: Date.now(),
442
443
  })
443
- // If URL fetch fails, still return status
444
+ // Provider reported completed but result fetch failed — treat as failed
444
445
  return {
445
- status: statusResult.status,
446
+ status: 'failed' as const,
446
447
  progress: statusResult.progress,
447
- error:
448
- error instanceof Error ? error.message : 'Failed to get video URL',
448
+ error: errorMessage,
449
449
  }
450
450
  }
451
451
  }
package/src/types.ts CHANGED
@@ -91,6 +91,8 @@ export interface ToolCall {
91
91
  name: string
92
92
  arguments: string // JSON string
93
93
  }
94
+ /** Provider-specific metadata to carry through the tool call lifecycle */
95
+ providerMetadata?: Record<string, unknown>
94
96
  }
95
97
 
96
98
  // ============================================================================
@@ -493,6 +495,9 @@ export interface Tool<
493
495
  /** If true, tool execution requires user approval before running. Works with both server and client tools. */
494
496
  needsApproval?: boolean
495
497
 
498
+ /** If true, this tool is lazy and will only be sent to the LLM after being discovered via the lazy tool discovery mechanism. Only meaningful when used with chat(). */
499
+ lazy?: boolean
500
+
496
501
  /** Additional metadata for adapters or custom extensions */
497
502
  metadata?: Record<string, any>
498
503
  }
@@ -846,6 +851,8 @@ export interface ToolCallStartEvent extends BaseAGUIEvent {
846
851
  parentMessageId?: string
847
852
  /** Index for parallel tool calls */
848
853
  index?: number
854
+ /** Provider-specific metadata to carry into the ToolCall */
855
+ providerMetadata?: Record<string, unknown>
849
856
  }
850
857
 
851
858
  /**