@tanstack/ai 0.0.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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/dist/esm/base-adapter.d.ts +35 -0
- package/dist/esm/base-adapter.js +12 -0
- package/dist/esm/base-adapter.js.map +1 -0
- package/dist/esm/core/chat-common-options.d.ts +52 -0
- package/dist/esm/core/chat.d.ts +30 -0
- package/dist/esm/core/chat.js +533 -0
- package/dist/esm/core/chat.js.map +1 -0
- package/dist/esm/core/embedding.d.ts +8 -0
- package/dist/esm/core/embedding.js +33 -0
- package/dist/esm/core/embedding.js.map +1 -0
- package/dist/esm/core/summarize.d.ts +9 -0
- package/dist/esm/core/summarize.js +36 -0
- package/dist/esm/core/summarize.js.map +1 -0
- package/dist/esm/event-client.d.ts +311 -0
- package/dist/esm/event-client.js +62 -0
- package/dist/esm/event-client.js.map +1 -0
- package/dist/esm/index.d.ts +16 -0
- package/dist/esm/index.js +50 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/message-converters.d.ts +52 -0
- package/dist/esm/message-converters.js +162 -0
- package/dist/esm/message-converters.js.map +1 -0
- package/dist/esm/stream/index.d.ts +11 -0
- package/dist/esm/stream/json-parser.d.ts +38 -0
- package/dist/esm/stream/json-parser.js +28 -0
- package/dist/esm/stream/json-parser.js.map +1 -0
- package/dist/esm/stream/message-updaters.d.ts +44 -0
- package/dist/esm/stream/message-updaters.js +141 -0
- package/dist/esm/stream/message-updaters.js.map +1 -0
- package/dist/esm/stream/processor.d.ts +242 -0
- package/dist/esm/stream/processor.js +693 -0
- package/dist/esm/stream/processor.js.map +1 -0
- package/dist/esm/stream/strategies.d.ts +43 -0
- package/dist/esm/stream/strategies.js +54 -0
- package/dist/esm/stream/strategies.js.map +1 -0
- package/dist/esm/stream/types.d.ts +71 -0
- package/dist/esm/tools/tool-calls.d.ts +112 -0
- package/dist/esm/tools/tool-calls.js +302 -0
- package/dist/esm/tools/tool-calls.js.map +1 -0
- package/dist/esm/tools/tool-definition.d.ts +125 -0
- package/dist/esm/tools/tool-definition.js +25 -0
- package/dist/esm/tools/tool-definition.js.map +1 -0
- package/dist/esm/tools/zod-converter.d.ts +30 -0
- package/dist/esm/tools/zod-converter.js +36 -0
- package/dist/esm/tools/zod-converter.js.map +1 -0
- package/dist/esm/types.d.ts +619 -0
- package/dist/esm/utilities/agent-loop-strategies.d.ts +59 -0
- package/dist/esm/utilities/agent-loop-strategies.js +23 -0
- package/dist/esm/utilities/agent-loop-strategies.js.map +1 -0
- package/dist/esm/utilities/chat-options.d.ts +6 -0
- package/dist/esm/utilities/chat-options.js +7 -0
- package/dist/esm/utilities/chat-options.js.map +1 -0
- package/dist/esm/utilities/messages.d.ts +30 -0
- package/dist/esm/utilities/messages.js +7 -0
- package/dist/esm/utilities/messages.js.map +1 -0
- package/dist/esm/utilities/stream-to-response.d.ts +48 -0
- package/dist/esm/utilities/stream-to-response.js +62 -0
- package/dist/esm/utilities/stream-to-response.js.map +1 -0
- package/package.json +65 -0
- package/src/base-adapter.ts +85 -0
- package/src/core/chat-common-options.ts +55 -0
- package/src/core/chat.ts +771 -0
- package/src/core/embedding.ts +54 -0
- package/src/core/summarize.ts +56 -0
- package/src/event-client.ts +389 -0
- package/src/index.ts +68 -0
- package/src/message-converters.ts +285 -0
- package/src/stream/index.ts +41 -0
- package/src/stream/json-parser.ts +58 -0
- package/src/stream/message-updaters.ts +275 -0
- package/src/stream/processor.ts +1092 -0
- package/src/stream/strategies.ts +78 -0
- package/src/stream/types.ts +94 -0
- package/src/tools/tool-calls.ts +471 -0
- package/src/tools/tool-definition.ts +206 -0
- package/src/tools/zod-converter.ts +85 -0
- package/src/types.ts +872 -0
- package/src/utilities/agent-loop-strategies.ts +85 -0
- package/src/utilities/chat-options.ts +35 -0
- package/src/utilities/messages.ts +63 -0
- package/src/utilities/stream-to-response.ts +116 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in Chunk Strategies
|
|
3
|
+
*
|
|
4
|
+
* Strategies for controlling when text updates are emitted to the UI
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChunkStrategy } from './types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Immediate Strategy - emit on every chunk (default behavior)
|
|
11
|
+
*/
|
|
12
|
+
export class ImmediateStrategy implements ChunkStrategy {
|
|
13
|
+
shouldEmit(_chunk: string, _accumulated: string): boolean {
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Punctuation Strategy - emit when chunk contains punctuation
|
|
20
|
+
* Useful for natural text flow in UI
|
|
21
|
+
*/
|
|
22
|
+
export class PunctuationStrategy implements ChunkStrategy {
|
|
23
|
+
private punctuation = /[.,!?;:\n]/
|
|
24
|
+
|
|
25
|
+
shouldEmit(chunk: string, _accumulated: string): boolean {
|
|
26
|
+
return this.punctuation.test(chunk)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Batch Strategy - emit every N chunks
|
|
32
|
+
* Useful for reducing UI update frequency
|
|
33
|
+
*/
|
|
34
|
+
export class BatchStrategy implements ChunkStrategy {
|
|
35
|
+
private chunkCount = 0
|
|
36
|
+
|
|
37
|
+
constructor(private batchSize: number = 5) {}
|
|
38
|
+
|
|
39
|
+
shouldEmit(_chunk: string, _accumulated: string): boolean {
|
|
40
|
+
this.chunkCount++
|
|
41
|
+
if (this.chunkCount >= this.batchSize) {
|
|
42
|
+
this.chunkCount = 0
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
reset(): void {
|
|
49
|
+
this.chunkCount = 0
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Word Boundary Strategy - emit at word boundaries
|
|
55
|
+
* Prevents cutting words in half
|
|
56
|
+
*/
|
|
57
|
+
export class WordBoundaryStrategy implements ChunkStrategy {
|
|
58
|
+
shouldEmit(chunk: string, _accumulated: string): boolean {
|
|
59
|
+
// Emit if chunk ends with whitespace
|
|
60
|
+
return /\s$/.test(chunk)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Composite Strategy - combine multiple strategies (OR logic)
|
|
66
|
+
* Emits if ANY strategy says to emit
|
|
67
|
+
*/
|
|
68
|
+
export class CompositeStrategy implements ChunkStrategy {
|
|
69
|
+
constructor(private strategies: Array<ChunkStrategy>) {}
|
|
70
|
+
|
|
71
|
+
shouldEmit(chunk: string, accumulated: string): boolean {
|
|
72
|
+
return this.strategies.some((s) => s.shouldEmit(chunk, accumulated))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
reset(): void {
|
|
76
|
+
this.strategies.forEach((s) => s.reset?.())
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream Processor Types
|
|
3
|
+
*
|
|
4
|
+
* Unified types for stream processing used by both server and client.
|
|
5
|
+
* The canonical chunk format is StreamChunk from @tanstack/ai types.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { StreamChunk, ToolCall } from '../types'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tool call states - track the lifecycle of a tool call
|
|
12
|
+
*/
|
|
13
|
+
export type ToolCallState =
|
|
14
|
+
| 'awaiting-input' // Received start but no arguments yet
|
|
15
|
+
| 'input-streaming' // Partial arguments received
|
|
16
|
+
| 'input-complete' // All arguments received
|
|
17
|
+
| 'approval-requested' // Waiting for user approval
|
|
18
|
+
| 'approval-responded' // User has approved/denied
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Tool result states - track the lifecycle of a tool result
|
|
22
|
+
*/
|
|
23
|
+
export type ToolResultState =
|
|
24
|
+
| 'streaming' // Placeholder for future streamed output
|
|
25
|
+
| 'complete' // Result is complete
|
|
26
|
+
| 'error' // Error occurred
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Internal state for a tool call being tracked
|
|
30
|
+
*/
|
|
31
|
+
export interface InternalToolCallState {
|
|
32
|
+
id: string
|
|
33
|
+
name: string
|
|
34
|
+
arguments: string
|
|
35
|
+
state: ToolCallState
|
|
36
|
+
parsedArguments?: any
|
|
37
|
+
index: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Strategy for determining when to emit text updates
|
|
42
|
+
*/
|
|
43
|
+
export interface ChunkStrategy {
|
|
44
|
+
/**
|
|
45
|
+
* Called for each text chunk received
|
|
46
|
+
* @param chunk - The new chunk of text (delta)
|
|
47
|
+
* @param accumulated - All text accumulated so far
|
|
48
|
+
* @returns true if an update should be emitted now
|
|
49
|
+
*/
|
|
50
|
+
shouldEmit: (chunk: string, accumulated: string) => boolean
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Optional: Reset strategy state (called when streaming starts)
|
|
54
|
+
*/
|
|
55
|
+
reset?: () => void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Result from processing a stream
|
|
60
|
+
*/
|
|
61
|
+
export interface ProcessorResult {
|
|
62
|
+
content: string
|
|
63
|
+
thinking?: string
|
|
64
|
+
toolCalls?: Array<ToolCall>
|
|
65
|
+
finishReason?: string | null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Current state of the processor
|
|
70
|
+
*/
|
|
71
|
+
export interface ProcessorState {
|
|
72
|
+
content: string
|
|
73
|
+
thinking: string
|
|
74
|
+
toolCalls: Map<string, InternalToolCallState>
|
|
75
|
+
toolCallOrder: Array<string>
|
|
76
|
+
finishReason: string | null
|
|
77
|
+
done: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Recording format for replay testing
|
|
82
|
+
*/
|
|
83
|
+
export interface ChunkRecording {
|
|
84
|
+
version: '1.0'
|
|
85
|
+
timestamp: number
|
|
86
|
+
model?: string
|
|
87
|
+
provider?: string
|
|
88
|
+
chunks: Array<{
|
|
89
|
+
chunk: StreamChunk
|
|
90
|
+
timestamp: number
|
|
91
|
+
index: number
|
|
92
|
+
}>
|
|
93
|
+
result?: ProcessorResult
|
|
94
|
+
}
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DoneStreamChunk,
|
|
3
|
+
ModelMessage,
|
|
4
|
+
Tool,
|
|
5
|
+
ToolCall,
|
|
6
|
+
ToolResultStreamChunk,
|
|
7
|
+
} from '../types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Manages tool call accumulation and execution for the chat() method's automatic tool execution loop.
|
|
11
|
+
*
|
|
12
|
+
* Responsibilities:
|
|
13
|
+
* - Accumulates streaming tool call chunks (ID, name, arguments)
|
|
14
|
+
* - Validates tool calls (filters out incomplete ones)
|
|
15
|
+
* - Executes tool `execute` functions with parsed arguments
|
|
16
|
+
* - Emits `tool_result` chunks for client visibility
|
|
17
|
+
* - Returns tool result messages for conversation history
|
|
18
|
+
*
|
|
19
|
+
* This class is used internally by the AI.chat() method to handle the automatic
|
|
20
|
+
* tool execution loop. It can also be used independently for custom tool execution logic.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* const manager = new ToolCallManager(tools);
|
|
25
|
+
*
|
|
26
|
+
* // During streaming, accumulate tool calls
|
|
27
|
+
* for await (const chunk of stream) {
|
|
28
|
+
* if (chunk.type === "tool_call") {
|
|
29
|
+
* manager.addToolCallChunk(chunk);
|
|
30
|
+
* }
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* // After stream completes, execute tools
|
|
34
|
+
* if (manager.hasToolCalls()) {
|
|
35
|
+
* const toolResults = yield* manager.executeTools(doneChunk);
|
|
36
|
+
* messages = [...messages, ...toolResults];
|
|
37
|
+
* manager.clear();
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export class ToolCallManager {
|
|
42
|
+
private toolCallsMap = new Map<number, ToolCall>()
|
|
43
|
+
private tools: ReadonlyArray<Tool>
|
|
44
|
+
|
|
45
|
+
constructor(tools: ReadonlyArray<Tool>) {
|
|
46
|
+
this.tools = tools
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Add a tool call chunk to the accumulator
|
|
51
|
+
* Handles streaming tool calls by accumulating arguments
|
|
52
|
+
*/
|
|
53
|
+
addToolCallChunk(chunk: {
|
|
54
|
+
toolCall: {
|
|
55
|
+
id: string
|
|
56
|
+
type: 'function'
|
|
57
|
+
function: {
|
|
58
|
+
name: string
|
|
59
|
+
arguments: string
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
index: number
|
|
63
|
+
}): void {
|
|
64
|
+
const index = chunk.index
|
|
65
|
+
const existing = this.toolCallsMap.get(index)
|
|
66
|
+
|
|
67
|
+
if (!existing) {
|
|
68
|
+
// Only create entry if we have a tool call ID and name
|
|
69
|
+
if (chunk.toolCall.id && chunk.toolCall.function.name) {
|
|
70
|
+
this.toolCallsMap.set(index, {
|
|
71
|
+
id: chunk.toolCall.id,
|
|
72
|
+
type: 'function',
|
|
73
|
+
function: {
|
|
74
|
+
name: chunk.toolCall.function.name,
|
|
75
|
+
arguments: chunk.toolCall.function.arguments || '',
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
// Update name if it wasn't set before
|
|
81
|
+
if (chunk.toolCall.function.name && !existing.function.name) {
|
|
82
|
+
existing.function.name = chunk.toolCall.function.name
|
|
83
|
+
}
|
|
84
|
+
// Accumulate arguments for streaming tool calls
|
|
85
|
+
if (chunk.toolCall.function.arguments) {
|
|
86
|
+
existing.function.arguments += chunk.toolCall.function.arguments
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if there are any complete tool calls to execute
|
|
93
|
+
*/
|
|
94
|
+
hasToolCalls(): boolean {
|
|
95
|
+
return this.getToolCalls().length > 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get all complete tool calls (filtered for valid ID and name)
|
|
100
|
+
*/
|
|
101
|
+
getToolCalls(): Array<ToolCall> {
|
|
102
|
+
return Array.from(this.toolCallsMap.values()).filter(
|
|
103
|
+
(tc) => tc.id && tc.function.name && tc.function.name.trim().length > 0,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Execute all tool calls and return tool result messages
|
|
109
|
+
* Also yields tool_result chunks for streaming
|
|
110
|
+
*/
|
|
111
|
+
async *executeTools(
|
|
112
|
+
doneChunk: DoneStreamChunk,
|
|
113
|
+
): AsyncGenerator<ToolResultStreamChunk, Array<ModelMessage>, void> {
|
|
114
|
+
const toolCallsArray = this.getToolCalls()
|
|
115
|
+
const toolResults: Array<ModelMessage> = []
|
|
116
|
+
|
|
117
|
+
for (const toolCall of toolCallsArray) {
|
|
118
|
+
const tool = this.tools.find((t) => t.name === toolCall.function.name)
|
|
119
|
+
|
|
120
|
+
let toolResultContent: string
|
|
121
|
+
if (tool?.execute) {
|
|
122
|
+
try {
|
|
123
|
+
// Parse arguments
|
|
124
|
+
let args: any
|
|
125
|
+
try {
|
|
126
|
+
args = JSON.parse(toolCall.function.arguments)
|
|
127
|
+
} catch (parseError) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Failed to parse tool arguments as JSON: ${toolCall.function.arguments}`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Validate input against inputSchema
|
|
134
|
+
if (tool.inputSchema) {
|
|
135
|
+
try {
|
|
136
|
+
args = tool.inputSchema.parse(args)
|
|
137
|
+
} catch (validationError: any) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Input validation failed for tool ${tool.name}: ${validationError.message}`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Execute the tool
|
|
145
|
+
let result = await tool.execute(args)
|
|
146
|
+
|
|
147
|
+
// Validate output against outputSchema if provided
|
|
148
|
+
if (tool.outputSchema && result !== undefined && result !== null) {
|
|
149
|
+
try {
|
|
150
|
+
result = tool.outputSchema.parse(result)
|
|
151
|
+
} catch (validationError: any) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Output validation failed for tool ${tool.name}: ${validationError.message}`,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
toolResultContent =
|
|
159
|
+
typeof result === 'string' ? result : JSON.stringify(result)
|
|
160
|
+
} catch (error: any) {
|
|
161
|
+
// If tool execution fails, add error message
|
|
162
|
+
toolResultContent = `Error executing tool: ${error.message}`
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
// Tool doesn't have execute function, add placeholder
|
|
166
|
+
toolResultContent = `Tool ${toolCall.function.name} does not have an execute function`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Emit tool_result chunk so callers can track tool execution
|
|
170
|
+
yield {
|
|
171
|
+
type: 'tool_result',
|
|
172
|
+
id: doneChunk.id,
|
|
173
|
+
model: doneChunk.model,
|
|
174
|
+
timestamp: Date.now(),
|
|
175
|
+
toolCallId: toolCall.id,
|
|
176
|
+
content: toolResultContent,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Add tool result message
|
|
180
|
+
toolResults.push({
|
|
181
|
+
role: 'tool',
|
|
182
|
+
content: toolResultContent,
|
|
183
|
+
toolCallId: toolCall.id,
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return toolResults
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Clear the tool calls map for the next iteration
|
|
192
|
+
*/
|
|
193
|
+
clear(): void {
|
|
194
|
+
this.toolCallsMap.clear()
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface ToolResult {
|
|
199
|
+
toolCallId: string
|
|
200
|
+
toolName: string
|
|
201
|
+
result: any
|
|
202
|
+
state?: 'output-available' | 'output-error'
|
|
203
|
+
/** Duration of tool execution in milliseconds (only for server-executed tools) */
|
|
204
|
+
duration?: number
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface ApprovalRequest {
|
|
208
|
+
toolCallId: string
|
|
209
|
+
toolName: string
|
|
210
|
+
input: any
|
|
211
|
+
approvalId: string
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface ClientToolRequest {
|
|
215
|
+
toolCallId: string
|
|
216
|
+
toolName: string
|
|
217
|
+
input: any
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
interface ExecuteToolCallsResult {
|
|
221
|
+
/** Tool results ready to send to LLM */
|
|
222
|
+
results: Array<ToolResult>
|
|
223
|
+
/** Tools that need user approval before execution */
|
|
224
|
+
needsApproval: Array<ApprovalRequest>
|
|
225
|
+
/** Tools that need client-side execution */
|
|
226
|
+
needsClientExecution: Array<ClientToolRequest>
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Execute tool calls based on their configuration
|
|
231
|
+
*
|
|
232
|
+
* Handles three cases:
|
|
233
|
+
* 1. Client tools (no execute) - request client to execute
|
|
234
|
+
* 2. Server tools with approval - check approval before executing
|
|
235
|
+
* 3. Normal server tools - execute immediately
|
|
236
|
+
*
|
|
237
|
+
* @param toolCalls - Tool calls from the LLM
|
|
238
|
+
* @param tools - Available tools with their configurations
|
|
239
|
+
* @param approvals - Map of approval decisions (approval.id -> approved boolean)
|
|
240
|
+
* @param clientResults - Map of client-side execution results (toolCallId -> result)
|
|
241
|
+
*/
|
|
242
|
+
export async function executeToolCalls(
|
|
243
|
+
toolCalls: Array<ToolCall>,
|
|
244
|
+
tools: ReadonlyArray<Tool>,
|
|
245
|
+
approvals: Map<string, boolean> = new Map(),
|
|
246
|
+
clientResults: Map<string, any> = new Map(),
|
|
247
|
+
): Promise<ExecuteToolCallsResult> {
|
|
248
|
+
const results: Array<ToolResult> = []
|
|
249
|
+
const needsApproval: Array<ApprovalRequest> = []
|
|
250
|
+
const needsClientExecution: Array<ClientToolRequest> = []
|
|
251
|
+
|
|
252
|
+
// Create tool lookup map
|
|
253
|
+
const toolMap = new Map<string, Tool>()
|
|
254
|
+
for (const tool of tools) {
|
|
255
|
+
toolMap.set(tool.name, tool)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const toolCall of toolCalls) {
|
|
259
|
+
const tool = toolMap.get(toolCall.function.name)
|
|
260
|
+
const toolName = toolCall.function.name
|
|
261
|
+
|
|
262
|
+
if (!tool) {
|
|
263
|
+
// Unknown tool - return error
|
|
264
|
+
results.push({
|
|
265
|
+
toolCallId: toolCall.id,
|
|
266
|
+
toolName,
|
|
267
|
+
result: { error: `Unknown tool: ${toolName}` },
|
|
268
|
+
state: 'output-error',
|
|
269
|
+
})
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Parse arguments, throwing error if invalid JSON
|
|
274
|
+
let input: any = {}
|
|
275
|
+
const argsStr = toolCall.function.arguments.trim() || '{}'
|
|
276
|
+
if (argsStr) {
|
|
277
|
+
try {
|
|
278
|
+
input = JSON.parse(argsStr)
|
|
279
|
+
} catch (parseError) {
|
|
280
|
+
// If parsing fails, throw error to fail fast
|
|
281
|
+
throw new Error(`Failed to parse tool arguments as JSON: ${argsStr}`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Validate input against inputSchema
|
|
286
|
+
if (tool.inputSchema) {
|
|
287
|
+
try {
|
|
288
|
+
input = tool.inputSchema.parse(input)
|
|
289
|
+
} catch (validationError: any) {
|
|
290
|
+
results.push({
|
|
291
|
+
toolCallId: toolCall.id,
|
|
292
|
+
toolName,
|
|
293
|
+
result: {
|
|
294
|
+
error: `Input validation failed for tool ${tool.name}: ${validationError.message}`,
|
|
295
|
+
},
|
|
296
|
+
state: 'output-error',
|
|
297
|
+
})
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// CASE 1: Client-side tool (no execute function)
|
|
303
|
+
if (!tool.execute) {
|
|
304
|
+
// Check if tool needs approval
|
|
305
|
+
if (tool.needsApproval) {
|
|
306
|
+
const approvalId = `approval_${toolCall.id}`
|
|
307
|
+
|
|
308
|
+
// Check if approval decision exists
|
|
309
|
+
if (approvals.has(approvalId)) {
|
|
310
|
+
const approved = approvals.get(approvalId)
|
|
311
|
+
|
|
312
|
+
if (approved) {
|
|
313
|
+
// Approved - check if client has executed
|
|
314
|
+
if (clientResults.has(toolCall.id)) {
|
|
315
|
+
results.push({
|
|
316
|
+
toolCallId: toolCall.id,
|
|
317
|
+
toolName,
|
|
318
|
+
result: clientResults.get(toolCall.id),
|
|
319
|
+
})
|
|
320
|
+
} else {
|
|
321
|
+
// Approved but not executed yet - request client execution
|
|
322
|
+
needsClientExecution.push({
|
|
323
|
+
toolCallId: toolCall.id,
|
|
324
|
+
toolName,
|
|
325
|
+
input,
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
// User declined
|
|
330
|
+
results.push({
|
|
331
|
+
toolCallId: toolCall.id,
|
|
332
|
+
toolName,
|
|
333
|
+
result: { error: 'User declined tool execution' },
|
|
334
|
+
state: 'output-error',
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
// Need approval first
|
|
339
|
+
needsApproval.push({
|
|
340
|
+
toolCallId: toolCall.id,
|
|
341
|
+
toolName: toolCall.function.name,
|
|
342
|
+
input,
|
|
343
|
+
approvalId,
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
// No approval needed - check if client has executed
|
|
348
|
+
if (clientResults.has(toolCall.id)) {
|
|
349
|
+
results.push({
|
|
350
|
+
toolCallId: toolCall.id,
|
|
351
|
+
toolName,
|
|
352
|
+
result: clientResults.get(toolCall.id),
|
|
353
|
+
})
|
|
354
|
+
} else {
|
|
355
|
+
// Request client execution
|
|
356
|
+
needsClientExecution.push({
|
|
357
|
+
toolCallId: toolCall.id,
|
|
358
|
+
toolName,
|
|
359
|
+
input,
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// CASE 2: Server tool with approval required
|
|
367
|
+
if (tool.needsApproval) {
|
|
368
|
+
const approvalId = `approval_${toolCall.id}`
|
|
369
|
+
|
|
370
|
+
// Check if approval decision exists
|
|
371
|
+
if (approvals.has(approvalId)) {
|
|
372
|
+
const approved = approvals.get(approvalId)
|
|
373
|
+
|
|
374
|
+
if (approved) {
|
|
375
|
+
// Execute after approval
|
|
376
|
+
const startTime = Date.now()
|
|
377
|
+
try {
|
|
378
|
+
let result = await tool.execute(input)
|
|
379
|
+
const duration = Date.now() - startTime
|
|
380
|
+
|
|
381
|
+
// Validate output against outputSchema if provided
|
|
382
|
+
if (tool.outputSchema && result !== undefined && result !== null) {
|
|
383
|
+
const parsed = tool.outputSchema.safeParse(result)
|
|
384
|
+
if (parsed.success) {
|
|
385
|
+
result = parsed.data
|
|
386
|
+
} else {
|
|
387
|
+
throw new Error(
|
|
388
|
+
`Output validation failed for tool ${tool.name}: ${parsed.error.message}`,
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
results.push({
|
|
394
|
+
toolCallId: toolCall.id,
|
|
395
|
+
toolName,
|
|
396
|
+
result:
|
|
397
|
+
typeof result === 'string'
|
|
398
|
+
? JSON.parse(result)
|
|
399
|
+
: result || null,
|
|
400
|
+
duration,
|
|
401
|
+
})
|
|
402
|
+
} catch (error: any) {
|
|
403
|
+
const duration = Date.now() - startTime
|
|
404
|
+
results.push({
|
|
405
|
+
toolCallId: toolCall.id,
|
|
406
|
+
toolName,
|
|
407
|
+
result: { error: error.message },
|
|
408
|
+
state: 'output-error',
|
|
409
|
+
duration,
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
// User declined
|
|
414
|
+
results.push({
|
|
415
|
+
toolCallId: toolCall.id,
|
|
416
|
+
toolName,
|
|
417
|
+
result: { error: 'User declined tool execution' },
|
|
418
|
+
state: 'output-error',
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
} else {
|
|
422
|
+
// Need approval
|
|
423
|
+
needsApproval.push({
|
|
424
|
+
toolCallId: toolCall.id,
|
|
425
|
+
toolName,
|
|
426
|
+
input,
|
|
427
|
+
approvalId,
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
continue
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// CASE 3: Normal server tool - execute immediately
|
|
434
|
+
const startTime = Date.now()
|
|
435
|
+
try {
|
|
436
|
+
let result = await tool.execute(input)
|
|
437
|
+
const duration = Date.now() - startTime
|
|
438
|
+
|
|
439
|
+
// Validate output against outputSchema if provided
|
|
440
|
+
if (tool.outputSchema && result !== undefined && result !== null) {
|
|
441
|
+
const parsed = tool.outputSchema.safeParse(result)
|
|
442
|
+
if (parsed.success) {
|
|
443
|
+
result = parsed.data
|
|
444
|
+
} else {
|
|
445
|
+
throw new Error(
|
|
446
|
+
`Output validation failed for tool ${tool.name}: ${parsed.error.message}`,
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
results.push({
|
|
452
|
+
toolCallId: toolCall.id,
|
|
453
|
+
toolName,
|
|
454
|
+
result:
|
|
455
|
+
typeof result === 'string' ? JSON.parse(result) : result || null,
|
|
456
|
+
duration,
|
|
457
|
+
})
|
|
458
|
+
} catch (error: any) {
|
|
459
|
+
const duration = Date.now() - startTime
|
|
460
|
+
results.push({
|
|
461
|
+
toolCallId: toolCall.id,
|
|
462
|
+
toolName,
|
|
463
|
+
result: { error: error.message },
|
|
464
|
+
state: 'output-error',
|
|
465
|
+
duration,
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return { results, needsApproval, needsClientExecution }
|
|
471
|
+
}
|