-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathxml-tool-parser.js
More file actions
363 lines (317 loc) · 9.79 KB
/
Copy pathxml-tool-parser.js
File metadata and controls
363 lines (317 loc) · 9.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
/**
* XML Tool Parser - Extracts tool calls from model responses
* Based on Kilo-Code's character-by-character parsing approach
*
* This implementation properly preserves text content while extracting tool calls,
* maintaining the correct order and position of text vs tools.
*/
// Default known tool names from Claude Code MCP tools
const DEFAULT_KNOWN_TOOLS = new Set([
// File operations
'Read', 'Write', 'Edit', 'Create', 'MultiEdit',
// Execution
'Execute',
// Search
'Grep', 'Glob', 'LS',
// Task management
'Task', 'TodoWrite',
// Web
'FetchUrl', 'WebSearch',
// Context7
'context7___resolve-library-id',
'context7___get-library-docs',
// DeepWiki
'deepwiki___read_wiki_structure',
'deepwiki___read_wiki_contents',
'deepwiki___ask_question',
// Ref
'Ref___ref_search_documentation',
'Ref___ref_read_url',
// CopilotKit
'copilotkit___search-docs',
'copilotkit___search-code',
// Legacy snake_case (for backward compatibility)
'read_file', 'write_file', 'edit_file', 'create_file',
'execute_command', 'search_files', 'list_files',
'ask_followup_question'
])
// Registry for dynamically registered tools
let toolRegistry = new Set(DEFAULT_KNOWN_TOOLS)
/**
* Register additional tool names dynamically
* @param {string[]|Set<string>} tools - Tool names to add
*/
export function registerTools(tools) {
if (Array.isArray(tools)) {
tools.forEach(tool => toolRegistry.add(tool))
} else if (tools instanceof Set) {
tools.forEach(tool => toolRegistry.add(tool))
} else if (typeof tools === 'string') {
toolRegistry.add(tools)
}
}
/**
* Get current tool registry
* @returns {Set<string>} Copy of current tool registry
*/
export function getKnownTools() {
return new Set(toolRegistry)
}
/**
* Reset tool registry to defaults
*/
export function resetToolRegistry() {
toolRegistry = new Set(DEFAULT_KNOWN_TOOLS)
}
/**
* Main parsing function - uses character-by-character parsing to extract
* text and tool blocks while preserving their order and position
* @param {string} content - The content to parse
* @param {Set<string>|string[]} [knownTools] - Optional set/array of known tool names. If not provided, uses registry.
*/
export function parseAssistantMessage(content, knownTools = null) {
// Safety check for invalid content
if (!content || typeof content !== 'string') {
console.warn('[XML Parser] Invalid content received:', content)
return [{
type: 'text',
text: ''
}]
}
try {
// Use provided knownTools or fall back to registry
const toolsToCheck = knownTools
? (knownTools instanceof Set ? knownTools : new Set(knownTools))
: toolRegistry
const contentBlocks = []
let currentTextStart = 0
let i = 0
while (i < content.length) {
// Check if we're at the start of a known tool tag
const toolMatch = findToolTag(content, i, toolsToCheck)
if (toolMatch) {
// Extract any text BEFORE this tool
if (i > currentTextStart) {
const text = content.substring(currentTextStart, i).trim()
if (text) {
contentBlocks.push({
type: 'text',
text: text
})
}
}
// Parse the tool block
const toolResult = parseToolBlock(content, toolMatch)
if (toolResult) {
contentBlocks.push({
type: 'tool_use',
id: toolResult.id,
name: toolResult.name,
input: toolResult.input
})
// Move past the tool block
i = toolResult.endIndex
currentTextStart = toolResult.endIndex
} else {
// If tool parsing failed, treat it as text and continue
i++
}
} else {
// Not a tool tag, continue scanning
i++
}
}
// Extract any remaining text after the last tool
if (currentTextStart < content.length) {
const text = content.substring(currentTextStart).trim()
if (text) {
contentBlocks.push({
type: 'text',
text: text
})
}
}
// If no content blocks were found, return the original content as text
if (contentBlocks.length === 0) {
return [{
type: 'text',
text: content.trim()
}]
}
return contentBlocks
} catch (parseError) {
console.warn('[XML Parser] Error parsing assistant message:', parseError)
// Return raw content as text on error
return [{
type: 'text',
text: content
}]
}
}
/**
* Find a known tool tag starting at the given index
* Returns the tool name and tag end position, or null if not found
* @param {string} content - Content to search
* @param {number} startIndex - Index to start searching from
* @param {Set<string>} knownTools - Set of known tool names
*/
function findToolTag(content, startIndex, knownTools) {
if (content[startIndex] !== '<') {
return null
}
// Check if this is a closing tag (skip those)
if (content[startIndex + 1] === '/') {
return null
}
// Extract tag name
let i = startIndex + 1
let tagName = ''
while (i < content.length && content[i] !== '>' && content[i] !== ' ') {
tagName += content[i]
i++
}
// Skip to closing '>'
while (i < content.length && content[i] !== '>') {
i++
}
if (i >= content.length) {
return null // Incomplete tag
}
// Check if this is a known tool
if (knownTools.has(tagName)) {
return {
toolName: tagName,
tagEndIndex: i + 1
}
}
// Warn about unknown but tool-like tags (for metrics)
if (tagName && /^[A-Z]/.test(tagName) && !tagName.includes(' ')) {
console.warn(`[XML Parser] Encountered unknown tool-like tag: <${tagName}>. Consider registering it.`)
}
return null
}
/**
* Parse a complete tool block starting from a known tool tag
* Returns the tool object with name, input, and end position
*/
function parseToolBlock(content, toolMatch) {
const { toolName, tagEndIndex } = toolMatch
const closingTag = `</${toolName}>`
// Find the matching closing tag
const closingTagIndex = content.indexOf(closingTag, tagEndIndex)
if (closingTagIndex === -1) {
console.warn(`[XML Parser] No closing tag found for <${toolName}>`)
return null
}
// Extract content between opening and closing tags
const toolContent = content.substring(tagEndIndex, closingTagIndex)
// Parse parameters from the tool content
const input = parseToolParameters(toolContent)
return {
id: generateToolId(),
name: toolName,
input: input,
endIndex: closingTagIndex + closingTag.length
}
}
/**
* Parse parameters from tool content
* Example: "<path>/file.txt</path><content>hello</content>"
* -> { path: '/file.txt', content: 'hello' }
*/
function parseToolParameters(toolContent) {
const params = {}
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/g
let match
while ((match = paramRegex.exec(toolContent)) !== null) {
const paramName = match[1]
let paramValue = match[2]
// Special handling for 'content' parameter - preserve formatting
if (paramName === 'content' || paramName === 'code_edit' || paramName === 'new_str' || paramName === 'old_str') {
// Only trim leading/trailing newlines, not all whitespace
paramValue = paramValue.replace(/^\n+/, '').replace(/\n+$/, '')
} else {
paramValue = paramValue.trim()
}
// Handle nested parameters (like <args><path>...</path></args>)
if (paramValue.includes('<')) {
const nestedParams = {}
const nestedRegex = /<(\w+)>([\s\S]*?)<\/\1>/g
let nestedMatch
while ((nestedMatch = nestedRegex.exec(paramValue)) !== null) {
nestedParams[nestedMatch[1]] = nestedMatch[2].trim()
}
// If we found nested params, use them; otherwise use the raw value
if (Object.keys(nestedParams).length > 0) {
params[paramName] = nestedParams
} else {
params[paramName] = paramValue
}
} else {
params[paramName] = paramValue
}
}
return params
}
// Monotonic counter for tool call IDs (ensures stable, collision-free IDs)
let toolIdCounter = 0
/**
* Generate a unique tool call ID using a monotonic counter
* Format: toolu_<timestamp>_<counter> ensures uniqueness and stability
*/
function generateToolId() {
toolIdCounter++
const timestamp = Date.now()
// Use counter for uniqueness within the same millisecond
return `toolu_${timestamp}_${toolIdCounter.toString(36)}`
}
/**
* Check if content has any XML tool calls
* (Useful for quick detection without full parsing)
* @param {string} content - Content to check
* @param {Set<string>|string[]} [knownTools] - Optional set/array of known tool names
*/
export function hasXMLToolCalls(content, knownTools = null) {
if (!content || typeof content !== 'string') {
return false
}
const toolsToCheck = knownTools
? (knownTools instanceof Set ? knownTools : new Set(knownTools))
: toolRegistry
// Check if any known tool tags are present
for (const toolName of toolsToCheck) {
if (content.includes(`<${toolName}>`)) {
return true
}
}
return false
}
/**
* Legacy function for backward compatibility
* Extracts just the tool calls (without text)
*/
export function parseXMLToolCalls(content) {
const blocks = parseAssistantMessage(content)
return blocks
.filter(block => block.type === 'tool_use')
.map(block => ({
id: block.id,
type: 'function',
function: {
name: block.name,
arguments: JSON.stringify(block.input)
}
}))
}
/**
* Legacy function for backward compatibility
* Extracts just the text content (without tools)
*/
export function extractTextContent(content) {
const blocks = parseAssistantMessage(content)
return blocks
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
.trim()
}