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.
@@ -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} 失败")