@tanstack/ai 0.8.0 → 0.9.1

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.
@@ -45,6 +45,24 @@ export interface ChunkStrategy {
45
45
  reset?: () => void
46
46
  }
47
47
 
48
+ /**
49
+ * Per-message streaming state.
50
+ * Tracks the accumulation of text, tool calls, and thinking content
51
+ * for a single message in the stream.
52
+ */
53
+ export interface MessageStreamState {
54
+ id: string
55
+ role: 'user' | 'assistant' | 'system'
56
+ totalTextContent: string
57
+ currentSegmentText: string
58
+ lastEmittedText: string
59
+ thinkingContent: string
60
+ toolCalls: Map<string, InternalToolCallState>
61
+ toolCallOrder: Array<string>
62
+ hasToolCallsSinceTextStart: boolean
63
+ isComplete: boolean
64
+ }
65
+
48
66
  /**
49
67
  * Result from processing a stream
50
68
  */
@@ -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
 
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
  }
@@ -730,6 +735,7 @@ export type AGUIEventType =
730
735
  | 'TOOL_CALL_END'
731
736
  | 'STEP_STARTED'
732
737
  | 'STEP_FINISHED'
738
+ | 'MESSAGES_SNAPSHOT'
733
739
  | 'STATE_SNAPSHOT'
734
740
  | 'STATE_DELTA'
735
741
  | 'CUSTOM'
@@ -806,8 +812,8 @@ export interface TextMessageStartEvent extends BaseAGUIEvent {
806
812
  type: 'TEXT_MESSAGE_START'
807
813
  /** Unique identifier for this message */
808
814
  messageId: string
809
- /** Role is always assistant for generated messages */
810
- role: 'assistant'
815
+ /** Role of the message sender */
816
+ role: 'user' | 'assistant' | 'system' | 'tool'
811
817
  }
812
818
 
813
819
  /**
@@ -841,8 +847,12 @@ export interface ToolCallStartEvent extends BaseAGUIEvent {
841
847
  toolCallId: string
842
848
  /** Name of the tool being called */
843
849
  toolName: string
850
+ /** ID of the parent message that initiated this tool call */
851
+ parentMessageId?: string
844
852
  /** Index for parallel tool calls */
845
853
  index?: number
854
+ /** Provider-specific metadata to carry into the ToolCall */
855
+ providerMetadata?: Record<string, unknown>
846
856
  }
847
857
 
848
858
  /**
@@ -897,6 +907,19 @@ export interface StepFinishedEvent extends BaseAGUIEvent {
897
907
  content?: string
898
908
  }
899
909
 
910
+ /**
911
+ * Emitted to provide a snapshot of all messages in a conversation.
912
+ *
913
+ * Unlike StateSnapshot (which carries arbitrary application state),
914
+ * MessagesSnapshot specifically delivers the conversation transcript.
915
+ * This is a first-class AG-UI event type.
916
+ */
917
+ export interface MessagesSnapshotEvent extends BaseAGUIEvent {
918
+ type: 'MESSAGES_SNAPSHOT'
919
+ /** Complete array of messages in the conversation */
920
+ messages: Array<UIMessage>
921
+ }
922
+
900
923
  /**
901
924
  * Emitted to provide a full state snapshot.
902
925
  */
@@ -941,6 +964,7 @@ export type AGUIEvent =
941
964
  | ToolCallEndEvent
942
965
  | StepStartedEvent
943
966
  | StepFinishedEvent
967
+ | MessagesSnapshotEvent
944
968
  | StateSnapshotEvent
945
969
  | StateDeltaEvent
946
970
  | CustomEvent