workbuddy2api 2.0.0__py3-none-any.whl
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.
- codebuddy_proxy/__main__.py +1572 -0
- codebuddy_proxy/anthropic_adapter.py +439 -0
- codebuddy_proxy/codebuddy_client_demo.py +312 -0
- codebuddy_proxy/desensitize.py +532 -0
- codebuddy_proxy/dsml_parser.py +888 -0
- codebuddy_proxy/projection_metadata.py +410 -0
- codebuddy_proxy/responses_adapter.py +487 -0
- codebuddy_proxy/responses_projection.py +746 -0
- workbuddy2api-2.0.0.dist-info/METADATA +634 -0
- workbuddy2api-2.0.0.dist-info/RECORD +14 -0
- workbuddy2api-2.0.0.dist-info/WHEEL +5 -0
- workbuddy2api-2.0.0.dist-info/entry_points.txt +2 -0
- workbuddy2api-2.0.0.dist-info/licenses/LICENSE +21 -0
- workbuddy2api-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
"""
|
|
2
|
+
完整的工具调用 XML 解析器 - 从 ds2api 移植(完全修复版)
|
|
3
|
+
|
|
4
|
+
支持:
|
|
5
|
+
- 精确标签扫描(状态机)
|
|
6
|
+
- 跳过忽略区域(Markdown fence、CDATA、注释、code span)
|
|
7
|
+
- 自动修复缺失包装器
|
|
8
|
+
- 递归参数解析
|
|
9
|
+
- 流式缓冲
|
|
10
|
+
- 任意标签名支持
|
|
11
|
+
|
|
12
|
+
修复:
|
|
13
|
+
- ✅ 语法错误(行续字符)
|
|
14
|
+
- ✅ 函数体缺失(consume_dsml_prefix)
|
|
15
|
+
- ✅ 参数解析失败(match_tool_markup_name)
|
|
16
|
+
"""
|
|
17
|
+
import re
|
|
18
|
+
import json
|
|
19
|
+
import uuid
|
|
20
|
+
import html
|
|
21
|
+
from typing import Dict, List, Any, Optional, Tuple
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ============================================================================
|
|
26
|
+
# 数据结构
|
|
27
|
+
# ============================================================================
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ToolMarkupTag:
|
|
31
|
+
"""工具标记标签"""
|
|
32
|
+
start: int
|
|
33
|
+
end: int
|
|
34
|
+
name_start: int
|
|
35
|
+
name_end: int
|
|
36
|
+
name: str
|
|
37
|
+
closing: bool
|
|
38
|
+
self_closing: bool
|
|
39
|
+
dsml_like: bool
|
|
40
|
+
canonical: bool
|
|
41
|
+
attributes: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class XMLElementBlock:
|
|
46
|
+
"""XML 元素块"""
|
|
47
|
+
start: int
|
|
48
|
+
end: int
|
|
49
|
+
attrs: str
|
|
50
|
+
body: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ParsedToolCall:
|
|
55
|
+
"""解析后的工具调用"""
|
|
56
|
+
id: str
|
|
57
|
+
type: str
|
|
58
|
+
function: Dict[str, Any]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ============================================================================
|
|
62
|
+
# 常量定义
|
|
63
|
+
# ============================================================================
|
|
64
|
+
|
|
65
|
+
DSML_MARKER = "||DSML||"
|
|
66
|
+
DSML_VARIANTS = ["||DSML||", "||DSML||", "|DSML|"]
|
|
67
|
+
|
|
68
|
+
TOOL_MARKUP_NAMES = [
|
|
69
|
+
("tool_calls", "tool_calls", False),
|
|
70
|
+
("tool-calls", "tool_calls", True),
|
|
71
|
+
("toolcalls", "tool_calls", True),
|
|
72
|
+
("invoke", "invoke", False),
|
|
73
|
+
("parameter", "parameter", False),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
FENCE_MARKERS = ["```", "~~~"]
|
|
77
|
+
CDATA_START = "<![CDATA["
|
|
78
|
+
CDATA_END = "]]>"
|
|
79
|
+
COMMENT_START = "<!--"
|
|
80
|
+
COMMENT_END = "-->"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ============================================================================
|
|
84
|
+
# 忽略区域检测
|
|
85
|
+
# ============================================================================
|
|
86
|
+
|
|
87
|
+
def skip_xml_ignored_section(text: str, i: int) -> Tuple[int, bool, bool]:
|
|
88
|
+
"""
|
|
89
|
+
跳过 XML 忽略区域(CDATA、注释、处理指令)
|
|
90
|
+
返回: (next_position, advanced, blocked)
|
|
91
|
+
"""
|
|
92
|
+
if i >= len(text):
|
|
93
|
+
return i, False, False
|
|
94
|
+
|
|
95
|
+
if text[i:i+9] == CDATA_START:
|
|
96
|
+
end = text.find(CDATA_END, i + 9)
|
|
97
|
+
if end == -1:
|
|
98
|
+
return len(text), False, True
|
|
99
|
+
return end + 3, True, False
|
|
100
|
+
|
|
101
|
+
if text[i:i+4] == COMMENT_START:
|
|
102
|
+
end = text.find(COMMENT_END, i + 4)
|
|
103
|
+
if end == -1:
|
|
104
|
+
return len(text), False, True
|
|
105
|
+
return end + 3, True, False
|
|
106
|
+
|
|
107
|
+
if i + 1 < len(text) and text[i:i+2] == "<?":
|
|
108
|
+
end = text.find("?>", i + 2)
|
|
109
|
+
if end == -1:
|
|
110
|
+
return len(text), False, True
|
|
111
|
+
return end + 2, True, False
|
|
112
|
+
|
|
113
|
+
return i, False, False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def markdown_code_span_end(text: str, start: int) -> Tuple[int, bool]:
|
|
117
|
+
"""检查是否是内联代码开始,如果是则找到结束位置"""
|
|
118
|
+
if start >= len(text) or text[start] != '`':
|
|
119
|
+
return start, False
|
|
120
|
+
|
|
121
|
+
tick_count = 0
|
|
122
|
+
i = start
|
|
123
|
+
while i < len(text) and text[i] == '`':
|
|
124
|
+
tick_count += 1
|
|
125
|
+
i += 1
|
|
126
|
+
|
|
127
|
+
if tick_count >= 3:
|
|
128
|
+
return start, False
|
|
129
|
+
|
|
130
|
+
end = i
|
|
131
|
+
while end < len(text):
|
|
132
|
+
if text[end] == '`':
|
|
133
|
+
end_tick_count = 0
|
|
134
|
+
j = end
|
|
135
|
+
while j < len(text) and text[j] == '`':
|
|
136
|
+
end_tick_count += 1
|
|
137
|
+
j += 1
|
|
138
|
+
|
|
139
|
+
if end_tick_count == tick_count:
|
|
140
|
+
return j, True
|
|
141
|
+
|
|
142
|
+
end = j
|
|
143
|
+
else:
|
|
144
|
+
end += 1
|
|
145
|
+
|
|
146
|
+
return len(text), False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def is_inside_markdown_fence(text: str, pos: int) -> bool:
|
|
150
|
+
"""检查位置是否在 Markdown fence 块内"""
|
|
151
|
+
fence_depth = 0
|
|
152
|
+
current_fence = None
|
|
153
|
+
i = 0
|
|
154
|
+
|
|
155
|
+
while i < pos:
|
|
156
|
+
if i == 0 or text[i-1] == '\n':
|
|
157
|
+
for marker in FENCE_MARKERS:
|
|
158
|
+
if text[i:i+len(marker)] == marker:
|
|
159
|
+
if current_fence is None:
|
|
160
|
+
current_fence = marker
|
|
161
|
+
fence_depth += 1
|
|
162
|
+
line_end = text.find('\n', i)
|
|
163
|
+
if line_end == -1:
|
|
164
|
+
i = len(text)
|
|
165
|
+
else:
|
|
166
|
+
i = line_end + 1
|
|
167
|
+
break
|
|
168
|
+
elif text[i:i+len(current_fence)] == current_fence:
|
|
169
|
+
fence_depth -= 1
|
|
170
|
+
if fence_depth == 0:
|
|
171
|
+
current_fence = None
|
|
172
|
+
i = text.find('\n', i)
|
|
173
|
+
if i == -1:
|
|
174
|
+
i = len(text)
|
|
175
|
+
else:
|
|
176
|
+
i += 1
|
|
177
|
+
break
|
|
178
|
+
else:
|
|
179
|
+
i += 1
|
|
180
|
+
else:
|
|
181
|
+
i += 1
|
|
182
|
+
|
|
183
|
+
return fence_depth > 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ============================================================================
|
|
187
|
+
# 标签扫描
|
|
188
|
+
# ============================================================================
|
|
189
|
+
|
|
190
|
+
def normalize_fullwidth_ascii(text: str, start: int) -> Tuple[str, int]:
|
|
191
|
+
"""标准化全角 ASCII 字符"""
|
|
192
|
+
if start >= len(text):
|
|
193
|
+
return "", 0
|
|
194
|
+
|
|
195
|
+
ch = text[start]
|
|
196
|
+
code = ord(ch)
|
|
197
|
+
|
|
198
|
+
if 0xFF01 <= code <= 0xFF5E:
|
|
199
|
+
normalized = chr(code - 0xFEE0)
|
|
200
|
+
return normalized, 1
|
|
201
|
+
|
|
202
|
+
return ch, 1
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def has_dsml_prefix_at(text: str, start: int) -> bool:
|
|
206
|
+
"""检查位置是否是 DSML 前缀"""
|
|
207
|
+
for variant in DSML_VARIANTS:
|
|
208
|
+
if text[start:start+len(variant)] == variant:
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
if start + 8 < len(text):
|
|
212
|
+
normalized = ""
|
|
213
|
+
pos = start
|
|
214
|
+
for _ in range(8):
|
|
215
|
+
ch, length = normalize_fullwidth_ascii(text, pos)
|
|
216
|
+
normalized += ch
|
|
217
|
+
pos += length
|
|
218
|
+
|
|
219
|
+
if normalized in DSML_VARIANTS:
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def consume_dsml_prefix(text: str, idx: int) -> Tuple[int, bool]:
|
|
226
|
+
"""
|
|
227
|
+
消费 DSML 前缀
|
|
228
|
+
返回: (next_position, found)
|
|
229
|
+
"""
|
|
230
|
+
for variant in DSML_VARIANTS:
|
|
231
|
+
if text[idx:idx+len(variant)] == variant:
|
|
232
|
+
return idx + len(variant), True
|
|
233
|
+
|
|
234
|
+
if idx + 8 < len(text):
|
|
235
|
+
normalized = ""
|
|
236
|
+
pos = idx
|
|
237
|
+
for _ in range(8):
|
|
238
|
+
ch, length = normalize_fullwidth_ascii(text, pos)
|
|
239
|
+
normalized += ch
|
|
240
|
+
pos += length
|
|
241
|
+
|
|
242
|
+
if normalized in DSML_VARIANTS:
|
|
243
|
+
return pos, True
|
|
244
|
+
|
|
245
|
+
return idx, False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def match_tool_markup_name(text: str, start: int) -> Tuple[str, int, bool]:
|
|
249
|
+
"""
|
|
250
|
+
匹配工具标记名称(✅ 修复:支持任意标签名)
|
|
251
|
+
|
|
252
|
+
返回: (canonical_name, end_position, is_dsml_like)
|
|
253
|
+
"""
|
|
254
|
+
dsml_like = False
|
|
255
|
+
idx = start
|
|
256
|
+
|
|
257
|
+
if has_dsml_prefix_at(text, idx):
|
|
258
|
+
idx, _ = consume_dsml_prefix(text, idx)
|
|
259
|
+
dsml_like = True
|
|
260
|
+
|
|
261
|
+
name_start = idx
|
|
262
|
+
name_end = idx
|
|
263
|
+
|
|
264
|
+
while name_end < len(text):
|
|
265
|
+
ch = text[name_end]
|
|
266
|
+
if ch.isalnum() or ch in ('_', '-'):
|
|
267
|
+
name_end += 1
|
|
268
|
+
else:
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
if name_end == name_start:
|
|
272
|
+
return "", start, False
|
|
273
|
+
|
|
274
|
+
raw_name = text[name_start:name_end].lower()
|
|
275
|
+
|
|
276
|
+
# 查找标准化名称(特殊标签)
|
|
277
|
+
for raw, canonical, dsml_only in TOOL_MARKUP_NAMES:
|
|
278
|
+
if raw_name == raw:
|
|
279
|
+
if dsml_only and not dsml_like:
|
|
280
|
+
continue
|
|
281
|
+
return canonical, name_end, dsml_like
|
|
282
|
+
|
|
283
|
+
# ✅ 关键修复:任意其他标签名也接受(用于参数标签如 <cmd>, <path> 等)
|
|
284
|
+
return raw_name, name_end, dsml_like
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def scan_tool_markup_tag_at(text: str, start: int) -> Tuple[Optional[ToolMarkupTag], bool]:
|
|
288
|
+
"""在指定位置扫描工具标记标签"""
|
|
289
|
+
if start >= len(text) or text[start] != '<':
|
|
290
|
+
return None, False
|
|
291
|
+
|
|
292
|
+
idx = start + 1
|
|
293
|
+
|
|
294
|
+
closing = False
|
|
295
|
+
if idx < len(text) and text[idx] == '/':
|
|
296
|
+
closing = True
|
|
297
|
+
idx += 1
|
|
298
|
+
|
|
299
|
+
while idx < len(text) and text[idx] in (' ', '\t', '\r', '\n'):
|
|
300
|
+
idx += 1
|
|
301
|
+
|
|
302
|
+
if idx >= len(text):
|
|
303
|
+
return None, False
|
|
304
|
+
|
|
305
|
+
name_start = idx
|
|
306
|
+
canonical_name, name_end, dsml_like = match_tool_markup_name(text, idx)
|
|
307
|
+
|
|
308
|
+
if not canonical_name:
|
|
309
|
+
return None, False
|
|
310
|
+
|
|
311
|
+
idx = name_end
|
|
312
|
+
attr_start = idx
|
|
313
|
+
self_closing = False
|
|
314
|
+
|
|
315
|
+
while idx < len(text):
|
|
316
|
+
ch = text[idx]
|
|
317
|
+
|
|
318
|
+
if ch == '>':
|
|
319
|
+
attrs = text[attr_start:idx].strip()
|
|
320
|
+
return ToolMarkupTag(
|
|
321
|
+
start=start,
|
|
322
|
+
end=idx,
|
|
323
|
+
name_start=name_start,
|
|
324
|
+
name_end=name_end,
|
|
325
|
+
name=canonical_name,
|
|
326
|
+
closing=closing,
|
|
327
|
+
self_closing=self_closing,
|
|
328
|
+
dsml_like=dsml_like,
|
|
329
|
+
canonical=not dsml_like,
|
|
330
|
+
attributes=attrs
|
|
331
|
+
), True
|
|
332
|
+
|
|
333
|
+
if ch == '/' and idx + 1 < len(text) and text[idx + 1] == '>':
|
|
334
|
+
self_closing = True
|
|
335
|
+
attrs = text[attr_start:idx].strip()
|
|
336
|
+
return ToolMarkupTag(
|
|
337
|
+
start=start,
|
|
338
|
+
end=idx + 1,
|
|
339
|
+
name_start=name_start,
|
|
340
|
+
name_end=name_end,
|
|
341
|
+
name=canonical_name,
|
|
342
|
+
closing=closing,
|
|
343
|
+
self_closing=self_closing,
|
|
344
|
+
dsml_like=dsml_like,
|
|
345
|
+
canonical=not dsml_like,
|
|
346
|
+
attributes=attrs
|
|
347
|
+
), True
|
|
348
|
+
|
|
349
|
+
idx += 1
|
|
350
|
+
|
|
351
|
+
return None, False
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def find_tool_markup_tag_outside_ignored(text: str, start: int) -> Tuple[Optional[ToolMarkupTag], bool]:
|
|
355
|
+
"""从指定位置开始查找下一个工具标记标签(跳过忽略区域)"""
|
|
356
|
+
i = max(start, 0)
|
|
357
|
+
|
|
358
|
+
while i < len(text):
|
|
359
|
+
next_pos, advanced, blocked = skip_xml_ignored_section(text, i)
|
|
360
|
+
if blocked:
|
|
361
|
+
return None, False
|
|
362
|
+
if advanced:
|
|
363
|
+
i = next_pos
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
if text[i] == '`':
|
|
367
|
+
end, found = markdown_code_span_end(text, i)
|
|
368
|
+
if found:
|
|
369
|
+
i = end
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
if is_inside_markdown_fence(text, i):
|
|
373
|
+
line_end = text.find('\n', i)
|
|
374
|
+
if line_end == -1:
|
|
375
|
+
return None, False
|
|
376
|
+
i = line_end + 1
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
tag, found = scan_tool_markup_tag_at(text, i)
|
|
380
|
+
if found:
|
|
381
|
+
return tag, True
|
|
382
|
+
|
|
383
|
+
i += 1
|
|
384
|
+
|
|
385
|
+
return None, False
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def find_matching_tool_markup_close(text: str, open_tag: ToolMarkupTag) -> Tuple[Optional[ToolMarkupTag], bool]:
|
|
389
|
+
"""查找匹配的闭标签"""
|
|
390
|
+
depth = 1
|
|
391
|
+
i = open_tag.end + 1
|
|
392
|
+
|
|
393
|
+
while i < len(text):
|
|
394
|
+
tag, found = find_tool_markup_tag_outside_ignored(text, i)
|
|
395
|
+
if not found:
|
|
396
|
+
break
|
|
397
|
+
|
|
398
|
+
if tag.name == open_tag.name:
|
|
399
|
+
if tag.closing:
|
|
400
|
+
depth -= 1
|
|
401
|
+
if depth == 0:
|
|
402
|
+
return tag, True
|
|
403
|
+
else:
|
|
404
|
+
depth += 1
|
|
405
|
+
|
|
406
|
+
i = tag.end + 1
|
|
407
|
+
|
|
408
|
+
return None, False
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ============================================================================
|
|
412
|
+
# 属性解析
|
|
413
|
+
# ============================================================================
|
|
414
|
+
|
|
415
|
+
def parse_xml_attributes(attrs_text: str) -> Dict[str, str]:
|
|
416
|
+
"""解析 XML 属性"""
|
|
417
|
+
attrs = {}
|
|
418
|
+
# 修复正则表达式以支持 - 和 :
|
|
419
|
+
pattern = r'([a-z0-9_:-]+)\s*=\s*["\']([^"\']*)["\']'
|
|
420
|
+
|
|
421
|
+
for match in re.finditer(pattern, attrs_text, re.IGNORECASE):
|
|
422
|
+
key = match.group(1)
|
|
423
|
+
value = match.group(2)
|
|
424
|
+
attrs[key] = html.unescape(value)
|
|
425
|
+
|
|
426
|
+
return attrs
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ============================================================================
|
|
430
|
+
# 参数解析(✅ 修复)
|
|
431
|
+
# ============================================================================
|
|
432
|
+
|
|
433
|
+
def parse_invoke_parameters(invoke_body: str) -> Dict[str, Any]:
|
|
434
|
+
"""
|
|
435
|
+
递归解析 <invoke> 内的参数
|
|
436
|
+
|
|
437
|
+
支持:
|
|
438
|
+
- CDATA 块:<![CDATA[value]]> (优先级最高,不进行 HTML 解码)
|
|
439
|
+
- DSML 参数:<||DSML||parameter name="cmd">value</||DSML||parameter>
|
|
440
|
+
- 简单标签:<cmd>value</cmd>
|
|
441
|
+
- 嵌套结构
|
|
442
|
+
"""
|
|
443
|
+
params = {}
|
|
444
|
+
i = 0
|
|
445
|
+
|
|
446
|
+
while i < len(invoke_body):
|
|
447
|
+
# 跳过空白
|
|
448
|
+
while i < len(invoke_body) and invoke_body[i] in (' ', '\t', '\r', '\n'):
|
|
449
|
+
i += 1
|
|
450
|
+
|
|
451
|
+
if i >= len(invoke_body):
|
|
452
|
+
break
|
|
453
|
+
|
|
454
|
+
# 查找下一个开标签
|
|
455
|
+
tag_start = invoke_body.find('<', i)
|
|
456
|
+
if tag_start == -1:
|
|
457
|
+
break
|
|
458
|
+
|
|
459
|
+
# 扫描标签
|
|
460
|
+
tag, found = scan_tool_markup_tag_at(invoke_body, tag_start)
|
|
461
|
+
if not found or tag.closing:
|
|
462
|
+
i = tag_start + 1
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
# 查找匹配的闭标签
|
|
466
|
+
close_tag, found_close = find_matching_tool_markup_close(invoke_body, tag)
|
|
467
|
+
if not found_close:
|
|
468
|
+
i = tag.end + 1
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
# 提取参数名和值
|
|
472
|
+
param_name = None
|
|
473
|
+
|
|
474
|
+
# 方法 1: <||DSML||parameter name="cmd">...</||DSML||parameter> (优先级高)
|
|
475
|
+
if tag.name == "parameter":
|
|
476
|
+
attrs = parse_xml_attributes(tag.attributes)
|
|
477
|
+
param_name = attrs.get("name")
|
|
478
|
+
|
|
479
|
+
if param_name:
|
|
480
|
+
value_start = tag.end + 1
|
|
481
|
+
value_end = close_tag.start
|
|
482
|
+
value_text = invoke_body[value_start:value_end].strip()
|
|
483
|
+
|
|
484
|
+
# 优先检测 CDATA 块
|
|
485
|
+
if value_text.startswith("<![CDATA[") and value_text.endswith("]]>"):
|
|
486
|
+
# CDATA 块:提取原始内容,不进行 HTML 解码
|
|
487
|
+
params[param_name] = value_text[9:-3]
|
|
488
|
+
else:
|
|
489
|
+
params[param_name] = html.unescape(value_text)
|
|
490
|
+
else:
|
|
491
|
+
# 方法 2: <cmd>...</cmd> (仅当参数名尚未存在时)
|
|
492
|
+
param_name = tag.name
|
|
493
|
+
|
|
494
|
+
if param_name and param_name not in params:
|
|
495
|
+
value_start = tag.end + 1
|
|
496
|
+
value_end = close_tag.start
|
|
497
|
+
value_text = invoke_body[value_start:value_end].strip()
|
|
498
|
+
|
|
499
|
+
# 优先检测 CDATA 块
|
|
500
|
+
if value_text.startswith("<![CDATA[") and value_text.endswith("]]>"):
|
|
501
|
+
# CDATA 块:提取原始内容,不进行 HTML 解码
|
|
502
|
+
params[param_name] = value_text[9:-3]
|
|
503
|
+
i = close_tag.end + 1
|
|
504
|
+
continue
|
|
505
|
+
|
|
506
|
+
# 检查值是否包含 XML 子元素
|
|
507
|
+
has_complete_tags = False
|
|
508
|
+
if '<' in value_text and '>' in value_text:
|
|
509
|
+
# 简单检测:查找是否有成对的标签
|
|
510
|
+
test_tag, test_found = scan_tool_markup_tag_at(value_text, value_text.find('<'))
|
|
511
|
+
if test_found and not test_tag.closing:
|
|
512
|
+
test_close, test_close_found = find_matching_tool_markup_close(value_text, test_tag)
|
|
513
|
+
has_complete_tags = test_close_found
|
|
514
|
+
|
|
515
|
+
if has_complete_tags:
|
|
516
|
+
nested_params = parse_invoke_parameters(value_text)
|
|
517
|
+
if nested_params:
|
|
518
|
+
params[param_name] = nested_params
|
|
519
|
+
else:
|
|
520
|
+
params[param_name] = html.unescape(value_text)
|
|
521
|
+
else:
|
|
522
|
+
# 纯文本或不完整的 XML,作为字符串处理
|
|
523
|
+
params[param_name] = html.unescape(value_text)
|
|
524
|
+
|
|
525
|
+
i = close_tag.end + 1
|
|
526
|
+
|
|
527
|
+
return params
|
|
528
|
+
|
|
529
|
+
def parse_single_xml_tool_call(block: XMLElementBlock) -> Tuple[Optional[ParsedToolCall], bool]:
|
|
530
|
+
"""解析单个 XML 工具调用块"""
|
|
531
|
+
attrs = parse_xml_attributes(block.attrs)
|
|
532
|
+
tool_name = attrs.get("name")
|
|
533
|
+
|
|
534
|
+
if not tool_name:
|
|
535
|
+
return None, False
|
|
536
|
+
|
|
537
|
+
params = parse_invoke_parameters(block.body)
|
|
538
|
+
|
|
539
|
+
tool_call = ParsedToolCall(
|
|
540
|
+
id=f"call_{uuid.uuid4().hex[:24]}",
|
|
541
|
+
type="function",
|
|
542
|
+
function={
|
|
543
|
+
"name": tool_name,
|
|
544
|
+
"arguments": json.dumps(params, ensure_ascii=False)
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
return tool_call, True
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# ============================================================================
|
|
552
|
+
# 工具调用解析
|
|
553
|
+
def find_invoke_blocks(text: str) -> List[XMLElementBlock]:
|
|
554
|
+
"""查找所有 <invoke> 块(跳过忽略区域)"""
|
|
555
|
+
blocks = []
|
|
556
|
+
i = 0
|
|
557
|
+
|
|
558
|
+
while i < len(text):
|
|
559
|
+
# 使用新的忽略区域检测
|
|
560
|
+
tag, found = find_tool_markup_tag_outside_ignored(text, i)
|
|
561
|
+
|
|
562
|
+
if not found:
|
|
563
|
+
break
|
|
564
|
+
|
|
565
|
+
if tag.name == "invoke" and not tag.closing:
|
|
566
|
+
close_tag, found_close = find_matching_tool_markup_close(text, tag)
|
|
567
|
+
|
|
568
|
+
if found_close:
|
|
569
|
+
blocks.append(XMLElementBlock(
|
|
570
|
+
start=tag.start,
|
|
571
|
+
end=close_tag.end + 1,
|
|
572
|
+
tag_name=tag.name,
|
|
573
|
+
attrs=tag.attributes,
|
|
574
|
+
body=text[tag.end + 1:close_tag.start]
|
|
575
|
+
))
|
|
576
|
+
i = close_tag.end + 1
|
|
577
|
+
continue
|
|
578
|
+
|
|
579
|
+
return OpenAIToolCall(
|
|
580
|
+
id=f"call_{tool_name}_{id(params)}",
|
|
581
|
+
type="function",
|
|
582
|
+
function={
|
|
583
|
+
"name": tool_name,
|
|
584
|
+
"arguments": json.dumps(params, ensure_ascii=False)
|
|
585
|
+
}
|
|
586
|
+
)
|
|
587
|
+
return tool_call, True
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def find_invoke_blocks(text: str) -> List[XMLElementBlock]:
|
|
591
|
+
"""查找所有 <invoke> 块"""
|
|
592
|
+
blocks = []
|
|
593
|
+
i = 0
|
|
594
|
+
|
|
595
|
+
while i < len(text):
|
|
596
|
+
tag, found = find_tool_markup_tag_outside_ignored(text, i)
|
|
597
|
+
if not found:
|
|
598
|
+
break
|
|
599
|
+
|
|
600
|
+
if tag.name == "invoke" and not tag.closing:
|
|
601
|
+
close_tag, found_close = find_matching_tool_markup_close(text, tag)
|
|
602
|
+
if found_close:
|
|
603
|
+
blocks.append(XMLElementBlock(
|
|
604
|
+
start=tag.start,
|
|
605
|
+
end=close_tag.end + 1,
|
|
606
|
+
attrs=tag.attributes,
|
|
607
|
+
body=text[tag.end + 1:close_tag.start]
|
|
608
|
+
))
|
|
609
|
+
i = close_tag.end + 1
|
|
610
|
+
else:
|
|
611
|
+
i = tag.end + 1
|
|
612
|
+
else:
|
|
613
|
+
i = tag.end + 1
|
|
614
|
+
|
|
615
|
+
return blocks
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def parse_xml_tool_calls(text: str) -> List[ParsedToolCall]:
|
|
619
|
+
"""解析 XML 格式的工具调用"""
|
|
620
|
+
tool_calls = []
|
|
621
|
+
invoke_blocks = find_invoke_blocks(text)
|
|
622
|
+
|
|
623
|
+
for block in invoke_blocks:
|
|
624
|
+
call, ok = parse_single_xml_tool_call(block)
|
|
625
|
+
if ok:
|
|
626
|
+
tool_calls.append(call)
|
|
627
|
+
|
|
628
|
+
return tool_calls
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# ============================================================================
|
|
632
|
+
# 自动修复
|
|
633
|
+
# ============================================================================
|
|
634
|
+
|
|
635
|
+
def repair_missing_tool_calls_wrapper(text: str) -> str:
|
|
636
|
+
"""自动修复缺失的 <tool_calls> 包装器"""
|
|
637
|
+
# 检查是否已经有 <tool_calls> 包装器
|
|
638
|
+
i = 0
|
|
639
|
+
while i < len(text):
|
|
640
|
+
tag, found = find_tool_markup_tag_outside_ignored(text, i)
|
|
641
|
+
if not found:
|
|
642
|
+
break
|
|
643
|
+
|
|
644
|
+
if tag.name == "tool_calls" and not tag.closing:
|
|
645
|
+
return text
|
|
646
|
+
|
|
647
|
+
i = tag.end + 1
|
|
648
|
+
|
|
649
|
+
# 查找第一个 <invoke> 标签
|
|
650
|
+
i = 0
|
|
651
|
+
first_invoke = None
|
|
652
|
+
while i < len(text):
|
|
653
|
+
tag, found = find_tool_markup_tag_outside_ignored(text, i)
|
|
654
|
+
if not found:
|
|
655
|
+
break
|
|
656
|
+
|
|
657
|
+
if tag.name == "invoke" and not tag.closing:
|
|
658
|
+
first_invoke = tag
|
|
659
|
+
break
|
|
660
|
+
|
|
661
|
+
i = tag.end + 1
|
|
662
|
+
|
|
663
|
+
if not first_invoke:
|
|
664
|
+
return text
|
|
665
|
+
|
|
666
|
+
# 查找最后一个 </invoke> 闭标签
|
|
667
|
+
last_close_pos = -1
|
|
668
|
+
i = len(text) - 1
|
|
669
|
+
while i >= 0:
|
|
670
|
+
if text[i:i+8] == "</invoke":
|
|
671
|
+
end = text.find('>', i)
|
|
672
|
+
if end != -1:
|
|
673
|
+
last_close_pos = end
|
|
674
|
+
break
|
|
675
|
+
i -= 1
|
|
676
|
+
|
|
677
|
+
if last_close_pos == -1:
|
|
678
|
+
return text
|
|
679
|
+
|
|
680
|
+
# 添加包装器
|
|
681
|
+
prefix = text[:first_invoke.start]
|
|
682
|
+
body = text[first_invoke.start:last_close_pos + 1]
|
|
683
|
+
suffix = text[last_close_pos + 1:]
|
|
684
|
+
|
|
685
|
+
return f"{prefix}<tool_calls>{body}</tool_calls>{suffix}"
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# ============================================================================
|
|
689
|
+
# 统一解析接口
|
|
690
|
+
# ============================================================================
|
|
691
|
+
|
|
692
|
+
def parse_tool_calls(text: str, auto_repair: bool = True) -> List[Dict[str, Any]]:
|
|
693
|
+
"""解析工具调用(统一入口)"""
|
|
694
|
+
if auto_repair:
|
|
695
|
+
text = repair_missing_tool_calls_wrapper(text)
|
|
696
|
+
|
|
697
|
+
parsed_calls = parse_xml_tool_calls(text)
|
|
698
|
+
|
|
699
|
+
result = []
|
|
700
|
+
for call in parsed_calls:
|
|
701
|
+
result.append({
|
|
702
|
+
"id": call.id,
|
|
703
|
+
"type": call.type,
|
|
704
|
+
"function": call.function
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
return result
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def remove_tool_call_markup(text: str) -> str:
|
|
711
|
+
"""从文本中移除所有工具调用标记"""
|
|
712
|
+
result = []
|
|
713
|
+
i = 0
|
|
714
|
+
|
|
715
|
+
while i < len(text):
|
|
716
|
+
tag, found = find_tool_markup_tag_outside_ignored(text, i)
|
|
717
|
+
|
|
718
|
+
if not found:
|
|
719
|
+
result.append(text[i:])
|
|
720
|
+
break
|
|
721
|
+
|
|
722
|
+
if tag.name == "tool_calls" and not tag.closing:
|
|
723
|
+
result.append(text[i:tag.start])
|
|
724
|
+
|
|
725
|
+
close_tag, found_close = find_matching_tool_markup_close(text, tag)
|
|
726
|
+
|
|
727
|
+
if found_close:
|
|
728
|
+
i = close_tag.end + 1
|
|
729
|
+
else:
|
|
730
|
+
i = tag.end + 1
|
|
731
|
+
else:
|
|
732
|
+
result.append(text[i:tag.start])
|
|
733
|
+
i = tag.end + 1
|
|
734
|
+
|
|
735
|
+
return ''.join(result).strip()
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
# ============================================================================
|
|
739
|
+
# 流式缓冲器
|
|
740
|
+
# ============================================================================
|
|
741
|
+
|
|
742
|
+
class ToolCallStreamBuffer:
|
|
743
|
+
"""工具调用流式缓冲器(生产级)"""
|
|
744
|
+
|
|
745
|
+
def __init__(self):
|
|
746
|
+
self.buffer = ""
|
|
747
|
+
self.detected_calls: List[Dict[str, Any]] = []
|
|
748
|
+
self.calls_emitted = False
|
|
749
|
+
|
|
750
|
+
def add_chunk(self, content: str) -> Tuple[str, Optional[List[Dict[str, Any]]]]:
|
|
751
|
+
"""
|
|
752
|
+
添加一个 chunk 的 content
|
|
753
|
+
|
|
754
|
+
返回: (应该输出的 content, tool_calls 或 None)
|
|
755
|
+
"""
|
|
756
|
+
self.buffer += content
|
|
757
|
+
|
|
758
|
+
# 尝试查找完整的 <tool_calls>...</tool_calls>
|
|
759
|
+
i = 0
|
|
760
|
+
while i < len(self.buffer):
|
|
761
|
+
tag, found = find_tool_markup_tag_outside_ignored(self.buffer, i)
|
|
762
|
+
if not found:
|
|
763
|
+
break
|
|
764
|
+
|
|
765
|
+
if tag.name == "tool_calls" and not tag.closing:
|
|
766
|
+
close_tag, found_close = find_matching_tool_markup_close(self.buffer, tag)
|
|
767
|
+
|
|
768
|
+
if found_close:
|
|
769
|
+
block = self.buffer[tag.start:close_tag.end + 1]
|
|
770
|
+
calls = parse_tool_calls(block, auto_repair=False)
|
|
771
|
+
|
|
772
|
+
if calls:
|
|
773
|
+
self.detected_calls = calls
|
|
774
|
+
self.calls_emitted = True
|
|
775
|
+
|
|
776
|
+
prefix = self.buffer[:tag.start]
|
|
777
|
+
suffix = self.buffer[close_tag.end + 1:]
|
|
778
|
+
|
|
779
|
+
self.buffer = ""
|
|
780
|
+
|
|
781
|
+
return prefix.strip(), calls
|
|
782
|
+
else:
|
|
783
|
+
# 还没有完整的闭标签,继续等待
|
|
784
|
+
return "", None
|
|
785
|
+
|
|
786
|
+
i = tag.end + 1
|
|
787
|
+
|
|
788
|
+
# 尝试查找独立的 <invoke>...</invoke>(自动修复模式)
|
|
789
|
+
i = 0
|
|
790
|
+
while i < len(self.buffer):
|
|
791
|
+
tag, found = find_tool_markup_tag_outside_ignored(self.buffer, i)
|
|
792
|
+
if not found:
|
|
793
|
+
break
|
|
794
|
+
|
|
795
|
+
if tag.name == "invoke" and not tag.closing:
|
|
796
|
+
close_tag, found_close = find_matching_tool_markup_close(self.buffer, tag)
|
|
797
|
+
|
|
798
|
+
if found_close:
|
|
799
|
+
block = self.buffer[tag.start:close_tag.end + 1]
|
|
800
|
+
calls = parse_tool_calls(block, auto_repair=True)
|
|
801
|
+
|
|
802
|
+
if calls:
|
|
803
|
+
self.detected_calls = calls
|
|
804
|
+
self.calls_emitted = True
|
|
805
|
+
|
|
806
|
+
prefix = self.buffer[:tag.start]
|
|
807
|
+
suffix = self.buffer[close_tag.end + 1:]
|
|
808
|
+
|
|
809
|
+
self.buffer = ""
|
|
810
|
+
|
|
811
|
+
return prefix.strip(), calls
|
|
812
|
+
else:
|
|
813
|
+
# 还没有完整的闭标签,继续等待
|
|
814
|
+
return "", None
|
|
815
|
+
|
|
816
|
+
i = tag.end + 1
|
|
817
|
+
|
|
818
|
+
# 如果 buffer 中没有任何 < 字符,可以安全输出
|
|
819
|
+
if '<' in self.buffer:
|
|
820
|
+
return "", None
|
|
821
|
+
|
|
822
|
+
output = self.buffer
|
|
823
|
+
self.buffer = ""
|
|
824
|
+
return output, None
|
|
825
|
+
|
|
826
|
+
def should_emit_tool_calls(self) -> bool:
|
|
827
|
+
"""是否应该发送 tool_calls"""
|
|
828
|
+
return self.calls_emitted
|
|
829
|
+
|
|
830
|
+
def get_detected_calls(self) -> List[Dict[str, Any]]:
|
|
831
|
+
"""获取已检测的工具调用"""
|
|
832
|
+
return self.detected_calls
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# ============================================================================
|
|
836
|
+
# 向后兼容别名
|
|
837
|
+
# ============================================================================
|
|
838
|
+
|
|
839
|
+
UnifiedToolCallBuffer = ToolCallStreamBuffer
|
|
840
|
+
DSMLStreamBuffer = ToolCallStreamBuffer
|
|
841
|
+
|
|
842
|
+
# 向后兼容函数名
|
|
843
|
+
parse_all_tool_calls = parse_tool_calls
|
|
844
|
+
remove_all_tool_call_markers = remove_tool_call_markup
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
# ============================================================================
|
|
848
|
+
# 测试代码(如果直接运行)
|
|
849
|
+
# ============================================================================
|
|
850
|
+
|
|
851
|
+
if __name__ == "__main__":
|
|
852
|
+
print("=" * 60)
|
|
853
|
+
print("XML 解析器快速测试")
|
|
854
|
+
print("=" * 60)
|
|
855
|
+
|
|
856
|
+
tests = [
|
|
857
|
+
("标准格式", '<tool_calls><invoke name="bash"><parameter name="cmd">pwd</parameter></invoke></tool_calls>'),
|
|
858
|
+
("DSML 格式", '<||DSML||tool_calls><||DSML||invoke name="bash"><||DSML||parameter name="cmd">ls -la</||DSML||parameter></||DSML||invoke></||DSML||tool_calls>'),
|
|
859
|
+
("简化格式", '<tool_calls><invoke name="bash"><cmd>ls -la</cmd></invoke></tool_calls>'),
|
|
860
|
+
("混合格式", '<tool_call><invoke name="exec_command"><cmd>pwd && ls -la</cmd></invoke></tool_call>'),
|
|
861
|
+
]
|
|
862
|
+
|
|
863
|
+
passed = 0
|
|
864
|
+
failed = 0
|
|
865
|
+
|
|
866
|
+
for name, test_input in tests:
|
|
867
|
+
try:
|
|
868
|
+
result = parse_tool_calls(test_input)
|
|
869
|
+
if result:
|
|
870
|
+
try:
|
|
871
|
+
# ✅ P1-2: JSON 解析异常处理
|
|
872
|
+
args = json.loads(result[0]['function']['arguments'])
|
|
873
|
+
except (json.JSONDecodeError, KeyError, TypeError, IndexError):
|
|
874
|
+
args = {}
|
|
875
|
+
if args:
|
|
876
|
+
print(f"✅ {name}: {result[0]['function']['name']} - {list(args.keys())}")
|
|
877
|
+
passed += 1
|
|
878
|
+
else:
|
|
879
|
+
print(f"❌ {name}: 参数为空")
|
|
880
|
+
failed += 1
|
|
881
|
+
else:
|
|
882
|
+
print(f"❌ {name}: 解析失败")
|
|
883
|
+
failed += 1
|
|
884
|
+
except Exception as e:
|
|
885
|
+
print(f"❌ {name}: 异常 - {e}")
|
|
886
|
+
failed += 1
|
|
887
|
+
|
|
888
|
+
print(f"\n总计: {passed} 通过 / {failed} 失败")
|