myagent-ai 1.47.19 → 1.47.21

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.
@@ -1,730 +0,0 @@
1
- """
2
- Custom Fault-Tolerant XML Output Parser Module.
3
-
4
- Parses the XML ``<output>`` block generated by the LLM in response to the
5
- system prompt. The LLM produces structured XML that drives the agent's
6
- execution loop — including tool calls, memory operations, user interaction
7
- hints, and loop-control flags.
8
-
9
- **This module does NOT use xml.etree.ElementTree.** All parsing is done with
10
- pure Python + regex to achieve maximum fault tolerance.
11
-
12
- Expected XML schema produced by the LLM::
13
-
14
- <output>
15
- <mainsubject>当前对话的6字以内标题</mainsubject>
16
- <usersays_correct>...</usersays_correct>
17
- <reply>展示给用户的文本内容</reply>
18
- <toolstocal>
19
- <tool>
20
-
21
- <toolname>工具名</toolname>
22
- <parms>参数JSON或描述</parms>
23
- <timeout>预估超时时限(秒)</timeout>
24
- </tool>
25
- </toolstocal>
26
- <remember>
27
- <type>global|session</type>
28
- <content>记忆内容</content>
29
- </remember>
30
- <recall>下一轮需要调取的记忆</recall>
31
- <get_knowledge>下一轮需要搜索获得的知识</get_knowledge>
32
- </output>
33
-
34
- Fault-tolerance features:
35
-
36
- * Text before ``<output>`` or after ``</output>`` is silently stripped.
37
- * Unclosed tags are auto-closed at the next sibling tag boundary.
38
- * Self-closing tags (``<tag/>``) resolve to empty strings.
39
- * Case-insensitive tag matching (``<OUTPUT>`` == ``<output>``).
40
- * Tag-name aliases (reserved for future use).
41
- * If extraction yields nothing meaningful, ``needs_correction`` is set to
42
- ``True`` so the caller can ask the LLM to re-format.
43
- """
44
-
45
- from __future__ import annotations
46
-
47
- import html
48
- import re
49
- from dataclasses import dataclass, field
50
- from typing import Any, Dict, List
51
-
52
- from core.logger import get_logger
53
-
54
- logger = get_logger("myagent.output_parser")
55
-
56
- # ---------------------------------------------------------------------------
57
- # Constants
58
- # ---------------------------------------------------------------------------
59
-
60
- _DEFAULT_TIMEOUT: int = 120
61
-
62
- # All top-level tags we recognise inside <output>.
63
- KNOWN_TOP_LEVEL_TAGS = [
64
- "usersays_correct",
65
- "task_plan", # 任务计划(Markdown格式)
66
- "toolstocal",
67
- "remember",
68
- "recall",
69
- "knowledge",
70
- "get_knowledge",
71
-
72
- "reply", # [v1.36] 用户可见文本(顶层标签,不再嵌套在 <response> 内)
73
- # [v1.37] "response" 已移除 — 不再兼容 <response> 包裹,统一使用 <reply>
74
- "mainsubject", # [v1.15.8] 会话标题自动命名
75
- ]
76
-
77
- # Inner tags inside each <tool>.
78
- TOOL_INNER_TAGS = [
79
- "toolname",
80
- "parms",
81
- "timeout",
82
- ]
83
-
84
- # Inner tags inside <remember>.
85
- REMEMBER_INNER_TAGS = ["type", "content"]
86
-
87
- # Tag aliases: canonical name -> list of aliases.
88
- _TAG_ALIASES: Dict[str, List[str]] = {
89
- # [v1.36] askuser/ask_user aliases removed — tag no longer used
90
- }
91
-
92
- # Build reverse lookup: alias -> canonical.
93
- _ALIAS_TO_CANONICAL: Dict[str, str] = {}
94
- for _canonical, _aliases in _TAG_ALIASES.items():
95
- for _alias in _aliases:
96
- _ALIAS_TO_CANONICAL[_alias.lower()] = _canonical
97
-
98
-
99
- # ---------------------------------------------------------------------------
100
- # Data classes
101
- # ---------------------------------------------------------------------------
102
-
103
-
104
- @dataclass
105
- class ParsedOutput:
106
- """Structured representation of the LLM's ``<output>`` block.
107
-
108
- Attributes:
109
- usersays_correct: Corrected / canonicalised version of the user's
110
- voice input.
111
- task_plan: Updated or new task plan (may contain Markdown).
112
- tools_to_call: Ordered list of tool descriptors to execute.
113
- remember: Content that should be persisted to the agent's memory.
114
- remember_type: "global" (cross-session) or "session" (current session only).
115
- recall: Memory keys / descriptions to retrieve for the next loop
116
- iteration.
117
- knowledge: Knowledge content the LLM wants to persist.
118
- get_knowledge: Knowledge search keywords for the next loop iteration.
119
- reply: User-visible text content extracted from <reply> tag (sole display content).
120
- raw_text: The verbatim raw text returned by the LLM.
121
- parse_success: Whether parsing extracted at least one meaningful field.
122
- needs_correction: When ``True``, the caller should send the raw text
123
- back to the LLM for re-formatting.
124
- """
125
-
126
- usersays_correct: str = ""
127
- task_plan: str = "" # 任务计划(Markdown格式)
128
- tools_to_call: List[Dict[str, Any]] = field(default_factory=list)
129
- remember: str = ""
130
- remember_type: str = ""
131
- recall: str = ""
132
- knowledge: str = ""
133
- get_knowledge: str = ""
134
-
135
- reply: str = "" # [v1.37] 用户可见文本(<reply> 标签,唯一回复来源)
136
- mainsubject: str = "" # [v1.15.8] 会话标题自动命名(6字以内)
137
- raw_text: str = ""
138
- parse_success: bool = False
139
- needs_correction: bool = False
140
- output_block_complete: bool = False # </output> 闭合标签是否存在
141
-
142
-
143
- # ---------------------------------------------------------------------------
144
- # Low-level extraction helpers
145
- # ---------------------------------------------------------------------------
146
-
147
-
148
- def _safe_strip(value: str | None) -> str:
149
- if value is None:
150
- return ""
151
- return value.strip()
152
-
153
-
154
- def _parse_bool(value: str | None, default: bool) -> bool:
155
- if value is None:
156
- return default
157
- stripped = value.strip().lower()
158
- if stripped in ("true", "1", "yes"):
159
- return True
160
- if stripped in ("false", "0", "no"):
161
- return False
162
- return default
163
-
164
-
165
- def _parse_int(value: str | None, default: int) -> int:
166
- if value is None:
167
- return default
168
- try:
169
- return int(value.strip())
170
- except (ValueError, TypeError):
171
- return default
172
-
173
-
174
- def _canonical_tag(tag_name: str) -> str:
175
- """Return the canonical tag name for *tag_name* (alias-aware, lowercased)."""
176
- lower = tag_name.strip().lower()
177
- return _ALIAS_TO_CANONICAL.get(lower, lower)
178
-
179
-
180
- def _extract_tag_content(text: str, tag_name: str, stop_tags: List[str] | None = None, *, conservative: bool = False) -> str:
181
- """Extract the text content of ``<tag_name>…</tag_name>`` from *text*.
182
-
183
- Fault-tolerant strategies tried in order:
184
-
185
- 1. **Properly closed**: ``<tag>content</tag>``
186
- 2. **Unclosed at next sibling opening tag**: ``<tag>content<next_tag>…``
187
- 3. **Unclosed at ``</output>``**: ``<tag>content</output>``
188
- 4. **Self-closing**: ``<tag/>``
189
- 5. **Opening tag at end of string**: ``<tag>content$``
190
-
191
- Parameters:
192
- text: The text to search within (typically the body of ``<output>``).
193
- tag_name: The tag name to extract (case-insensitive).
194
- stop_tags: Sibling tag names that signal the end of this tag's
195
- content (used for unclosed-tag detection). Defaults to
196
- ``KNOWN_TOP_LEVEL_TAGS``.
197
- """
198
- if not text or not tag_name:
199
- return ""
200
-
201
- if stop_tags is None:
202
- stop_tags = KNOWN_TOP_LEVEL_TAGS
203
-
204
- tag_esc = re.escape(tag_name)
205
-
206
- # Strategy 1: Properly closed <tag>content</tag>
207
- m = re.search(
208
- rf"<{tag_esc}[^>]*>(.*?)</{tag_esc}\s*>",
209
- text,
210
- re.DOTALL | re.IGNORECASE,
211
- )
212
- if m:
213
- return html.unescape(m.group(1))
214
-
215
- # Conservative mode: only extract properly closed tags, skip all fallbacks
216
- if conservative:
217
- return ""
218
-
219
- # Strategy 2: Unclosed — content runs until the next opening/closing
220
- # sibling tag or </output>.
221
- sibling_names = [t for t in stop_tags if t.lower() != tag_name.lower()]
222
- if sibling_names:
223
- sibling_pat = "|".join(re.escape(t) for t in sibling_names)
224
- # CRITICAL: Wrap sibling_pat in (?:...) so that | doesn't split the
225
- # leading < or </ from the alternation. Without this, e.g.
226
- # "<a|b|c" is parsed as "<a" OR "b" OR "c" — NOT "<a" OR "<b" OR "<c".
227
- boundary = rf"(?:</output\s*>|<(?:{sibling_pat})\b|</(?:{sibling_pat})\s*>)"
228
- else:
229
- boundary = r"</output\s*>"
230
-
231
- m = re.search(
232
- rf"<{tag_esc}[^>]*>(.*?)({boundary})",
233
- text,
234
- re.DOTALL | re.IGNORECASE,
235
- )
236
- if m:
237
- return html.unescape(m.group(1))
238
-
239
- # Strategy 3: Self-closing <tag/> or <tag />
240
- m = re.search(rf"<{tag_esc}[^>]*/\s*>", text, re.IGNORECASE)
241
- if m:
242
- return ""
243
-
244
- # Strategy 4: Opening tag at end of text with no closing
245
- m = re.search(
246
- rf"<{tag_esc}[^>]*>(.*?)$",
247
- text,
248
- re.DOTALL | re.IGNORECASE,
249
- )
250
- if m:
251
- content = m.group(1).strip()
252
- # Only return if there's actual content (not just whitespace)
253
- if content:
254
- return html.unescape(content)
255
-
256
- return ""
257
-
258
-
259
- def _extract_all_tag_blocks(
260
- text: str,
261
- tag_name: str,
262
- parent_close_tag: str | None = None,
263
- *,
264
- conservative: bool = False,
265
- ) -> List[str]:
266
- """Extract all ``<tag_name>…`` blocks from *text*.
267
-
268
- Used for extracting multiple ``<tool>`` blocks from ``<toolstocal>``
269
- content. Handles both properly closed and unclosed blocks.
270
-
271
- Returns a list of content strings, one per block.
272
- """
273
- if not text:
274
- return []
275
-
276
- tag_esc = re.escape(tag_name)
277
- blocks: List[str] = []
278
-
279
- # Strategy 1: Find all properly closed <tag>content</tag> blocks
280
- properly_closed = re.findall(
281
- rf"<{tag_esc}[^>]*>(.*?)</{tag_esc}\s*>",
282
- text,
283
- re.DOTALL | re.IGNORECASE,
284
- )
285
- if properly_closed:
286
- return [html.unescape(b) for b in properly_closed]
287
-
288
- # Conservative mode: only extract properly closed blocks
289
- if conservative:
290
- return []
291
-
292
- # Strategy 2: Split by <tag> openings — each segment is a block
293
- positions = [
294
- m.end() for m in re.finditer(rf"<{tag_esc}[^>]*>", text, re.IGNORECASE)
295
- ]
296
-
297
- for i, content_start in enumerate(positions):
298
- if i + 1 < len(positions):
299
- # Block ends at next <tag> opening
300
- content_end = positions[i + 1]
301
- elif parent_close_tag:
302
- # Last block — ends at parent close tag
303
- close_m = re.search(
304
- re.escape(parent_close_tag),
305
- text[content_start:],
306
- re.IGNORECASE,
307
- )
308
- content_end = content_start + close_m.start() if close_m else len(text)
309
- else:
310
- content_end = len(text)
311
-
312
- blocks.append(html.unescape(text[content_start:content_end]))
313
-
314
- return blocks
315
-
316
-
317
- def _extract_output_body(raw_text: str) -> str | None:
318
- """Extract the content between ``<output>`` and ``</output>``.
319
-
320
- If ``</output>`` is missing (unclosed), returns everything after the
321
- opening ``<output>`` tag.
322
-
323
- Returns ``None`` if no ``<output>`` opening tag is found at all.
324
- """
325
- open_match = re.search(r"<output[^>]*>", raw_text, re.IGNORECASE)
326
- if open_match is None:
327
- return None
328
-
329
- content_start = open_match.end()
330
-
331
- close_match = re.search(
332
- r"</output\s*>",
333
- raw_text[content_start:],
334
- re.IGNORECASE,
335
- )
336
- if close_match:
337
- return raw_text[content_start : content_start + close_match.start()]
338
-
339
- # Unclosed <output> — take everything after it
340
- return raw_text[content_start:]
341
-
342
-
343
- def _strip_outer_noise(text: str) -> str:
344
- """Remove text that is outside any recognised XML tags.
345
-
346
- This handles the case where the LLM outputs plain text before or
347
- after the ``<output>`` block, e.g.::
348
-
349
- "我来使用 Python 脚本下载... <output>...</output>"
350
-
351
- The function returns the ``<output>…</output>`` body, or the original
352
- text if no output block is found.
353
- """
354
- if not text:
355
- return text
356
-
357
- body = _extract_output_body(text)
358
- if body is not None:
359
- return body
360
-
361
- # No <output> tag at all — check if there are any recognised tags
362
- has_tags = False
363
- for tag in KNOWN_TOP_LEVEL_TAGS:
364
- if re.search(rf"<{re.escape(tag)}[\s>]", text, re.IGNORECASE):
365
- has_tags = True
366
- break
367
-
368
- if has_tags:
369
- # Tags exist but no <output> wrapper — return as-is
370
- return text
371
-
372
- # No tags at all — return original (caller will set needs_correction)
373
- return text
374
-
375
-
376
- def is_output_block_complete(raw_text: str) -> bool:
377
- """Check if *raw_text* contains a properly closed ``<output>...</output>`` block.
378
-
379
- Returns:
380
- True if both ``<output>`` and ``</output>`` tags are present.
381
- False if neither tag, or only the opening tag, is found.
382
- """
383
- if not raw_text:
384
- return False
385
- open_m = re.search(r"<output[^>]*>", raw_text, re.IGNORECASE)
386
- if open_m is None:
387
- return False
388
- close_m = re.search(r"</output\s*>", raw_text[open_m.end():], re.IGNORECASE)
389
- return close_m is not None
390
-
391
-
392
- # ---------------------------------------------------------------------------
393
- # Core custom parser — NO xml.etree.ElementTree
394
- # ---------------------------------------------------------------------------
395
-
396
-
397
- def _custom_parse(raw_text: str) -> ParsedOutput:
398
- """Fully custom, regex-based XML parser with maximum fault tolerance.
399
-
400
- This function does NOT use ``xml.etree.ElementTree`` at all. Every
401
- extraction is done via regex patterns that handle malformed XML
402
- gracefully.
403
-
404
- Returns a :class:`ParsedOutput` with ``parse_success=True`` if at least
405
- one meaningful field was extracted, or ``needs_correction=True`` if
406
- nothing could be parsed.
407
- """
408
- parsed = ParsedOutput(raw_text=raw_text)
409
-
410
- if not raw_text or not raw_text.strip():
411
- parsed.needs_correction = True
412
- return parsed
413
-
414
- # ── Step 0: 检查 <output> 块,处理缺少开始/闭合标签的情况 ──
415
- _has_open = bool(re.search(r"<output[^>]*>", raw_text, re.IGNORECASE))
416
- _has_close = bool(re.search(r"</output\s*>", raw_text, re.IGNORECASE))
417
-
418
- if not _has_open and not _has_close:
419
- # 完全没有 <output> 标签 — 检查是否包含已知子标签
420
- _has_known_tags = any(
421
- re.search(rf"<{re.escape(t)}[\s>]", raw_text, re.IGNORECASE)
422
- for t in KNOWN_TOP_LEVEL_TAGS
423
- )
424
- if _has_known_tags:
425
- # 有子标签但缺少 <output> 包装 — 自动补全后正常解析
426
- logger.info(
427
- "LLM 输出缺少 <output> 标签但包含已知子标签,"
428
- "自动补全 <output> 包装后解析"
429
- )
430
- raw_text = "<output>\n" + raw_text.strip() + "\n</output>"
431
- parsed.output_block_complete = True
432
- else:
433
- parsed.output_block_complete = False
434
- elif _has_open and not _has_close:
435
- parsed.output_block_complete = False
436
- elif not _has_open and _has_close:
437
- # 有闭合标签但没开始标签 — 补全开始标签
438
- logger.info("LLM 输出缺少 <output> 开始标签但有 </output>,自动补全")
439
- raw_text = "<output>\n" + raw_text.strip()
440
- parsed.output_block_complete = True
441
- else:
442
- parsed.output_block_complete = True
443
-
444
- conservative = not parsed.output_block_complete
445
-
446
- if conservative:
447
- logger.warning(
448
- "XML <output> 块不完整(缺少 </output> 闭合标签),"
449
- "启用保守解析模式(仅提取完整闭合的标签)\n"
450
- "====== LLM 完整输出开始 ======\n"
451
- f"{raw_text}\n"
452
- "====== LLM 完整输出结束 ======"
453
- )
454
-
455
- # ── Step 1: Strip non-XML noise (text before/after <output>) ──
456
- body = _strip_outer_noise(raw_text)
457
-
458
- # ── Step 2: Extract each known top-level tag ──
459
-
460
- # usersays_correct
461
- raw_val = _extract_tag_content(body, "usersays_correct", conservative=conservative)
462
- parsed.usersays_correct = _safe_strip(raw_val)
463
-
464
- # task_plan [v1.34.5] 任务计划(Markdown格式)
465
- raw_val = _extract_tag_content(body, "task_plan", conservative=conservative)
466
- parsed.task_plan = _safe_strip(raw_val)
467
-
468
- # [v1.37] 不再提取 <response> — 统一使用 <reply>,<response> 标签直接剥离不保留
469
- # reply — 用户可见文本(唯一回复来源)
470
- # [v1.38] 保守模式下 <reply> 仍尝试宽松提取 — LLM 输出截断时 <reply> 常不完整但包含重要内容
471
- raw_val = _extract_tag_content(body, "reply", conservative=conservative)
472
- if not raw_val.strip() and conservative:
473
- # 保守模式未提取到闭合的 <reply>,尝试宽松模式(允许未闭合标签)
474
- raw_val = _extract_tag_content(body, "reply", conservative=False)
475
- if raw_val.strip():
476
- logger.info("保守模式下 <reply> 未闭合但通过宽松提取恢复内容")
477
- parsed.reply = _safe_strip(raw_val)
478
-
479
- # recall
480
- raw_val = _extract_tag_content(body, "recall", conservative=conservative)
481
- parsed.recall = _safe_strip(raw_val)
482
-
483
- # knowledge
484
- raw_val = _extract_tag_content(body, "knowledge", conservative=conservative)
485
- parsed.knowledge = _safe_strip(raw_val)
486
-
487
- # get_knowledge
488
- raw_val = _extract_tag_content(body, "get_knowledge", conservative=conservative)
489
- parsed.get_knowledge = _safe_strip(raw_val)
490
-
491
-
492
-
493
- # mainsubject [v1.15.8] 会话标题自动命名
494
- raw_val = _extract_tag_content(body, "mainsubject", conservative=conservative)
495
- parsed.mainsubject = _safe_strip(raw_val)
496
-
497
- # ── Step 3: Parse <remember> (may contain <type> and <content>) ──
498
- remember_raw = _extract_tag_content(body, "remember", conservative=conservative)
499
- if remember_raw.strip():
500
- # Try structured format: <type>global</type><content>...</content>
501
- type_val = _extract_tag_content(remember_raw, "type", REMEMBER_INNER_TAGS, conservative=conservative)
502
- content_val = _extract_tag_content(remember_raw, "content", REMEMBER_INNER_TAGS, conservative=conservative)
503
-
504
- if content_val.strip():
505
- mem_type = _safe_strip(type_val) or "session"
506
- if mem_type not in ("global", "session"):
507
- mem_type = "session"
508
- parsed.remember = _safe_strip(content_val)
509
- parsed.remember_type = mem_type
510
- else:
511
- # Legacy plain-text format
512
- parsed.remember = _safe_strip(remember_raw)
513
- parsed.remember_type = "session"
514
-
515
- # ── Step 4: Parse <toolstocal> → list of tool dicts ──
516
- toolstocal_raw = _extract_tag_content(body, "toolstocal", conservative=conservative)
517
- if toolstocal_raw.strip():
518
- parsed.tools_to_call = _parse_toolstocal(toolstocal_raw, conservative=conservative)
519
-
520
- # ── Step 4.5: 兜底机制 — 宽松提取工具调用,确保执行不会因解析错误而中断 ──
521
- # 策略优先级:
522
- # 1. _parse_toolstocal 已成功提取 → 不做任何事
523
- # 2. 直接在整个输出中搜索 <tool>...</tool> 块(跳过 toolstocal 包装)
524
- # 3. 搜索散落的 <toolname>...</toolname> + <parms>...</parms> 配对
525
- if not parsed.tools_to_call:
526
- # 兜底 Level 1: 在整个原始文本中直接搜索 <tool> 块
527
- _raw_tool_blocks = _extract_all_tag_blocks(
528
- raw_text, "tool", parent_close_tag=None, conservative=False,
529
- )
530
- for block in _raw_tool_blocks:
531
- tn = _safe_strip(_extract_tag_content(block, "toolname", TOOL_INNER_TAGS))
532
- if tn:
533
- parsed.tools_to_call.append({
534
- "toolname": tn,
535
- "parms": _safe_strip(_extract_tag_content(block, "parms", TOOL_INNER_TAGS)),
536
- "timeout": _parse_int(_extract_tag_content(block, "timeout", TOOL_INNER_TAGS), _DEFAULT_TIMEOUT),
537
- })
538
- logger.info(f"[兜底L1] 从非<toolstocal>区域提取到工具调用: {tn}")
539
-
540
- if not parsed.tools_to_call:
541
- # 兜底 Level 2: 搜索散落的 <toolname>...</toolname>,然后在同一段中找最近的 <parms>
542
- _toolname_positions = []
543
- for m in re.finditer(r"<toolname[^>]*>(.*?)</toolname\s*>", raw_text, re.DOTALL | re.IGNORECASE):
544
- tn = html.unescape(m.group(1)).strip()
545
- if tn:
546
- _toolname_positions.append((m.start(), m.end(), tn))
547
-
548
- if _toolname_positions:
549
- logger.info(f"[兜底L2] 找到 {len(_toolname_positions)} 个散落的 <toolname> 标签")
550
- for _i, (_start, _end, _tn) in enumerate(_toolname_positions):
551
- # 在 toolname 之后的 500 字符内搜索最近的 <parms>
552
- _search_region = raw_text[_end:_end + 500]
553
- _parms_match = re.search(
554
- r"<parms[^>]*>(.*?)</parms\s*>",
555
- _search_region, re.DOTALL | re.IGNORECASE,
556
- )
557
- _parms = html.unescape(_parms_match.group(1)).strip() if _parms_match else ""
558
-
559
- # 也尝试在 toolname 之前的 200 字符内搜索(parms 可能在 toolname 前面)
560
- if not _parms:
561
- _pre_region = raw_text[max(0, _start - 200):_start]
562
- _parms_match = re.search(
563
- r"<parms[^>]*>(.*?)</parms\s*>",
564
- _pre_region, re.DOTALL | re.IGNORECASE,
565
- )
566
- _parms = html.unescape(_parms_match.group(1)).strip() if _parms_match else ""
567
-
568
- parsed.tools_to_call.append({
569
- "toolname": _tn,
570
- "parms": _parms,
571
- "timeout": _DEFAULT_TIMEOUT,
572
- })
573
- logger.info(f"[兜底L2] 散落提取工具: {_tn}, parms={'有' if _parms else '无'}")
574
-
575
- # ── Step 5: Determine parse success ──
576
- has_content = bool(
577
- parsed.reply
578
- or parsed.usersays_correct
579
- or parsed.tools_to_call
580
- or parsed.remember
581
- or parsed.recall
582
- or parsed.knowledge
583
- or parsed.get_knowledge
584
- )
585
-
586
- if has_content:
587
- parsed.parse_success = True
588
- else:
589
- # Nothing was extracted — check if there's any raw text that could
590
- # be a response (the LLM might have skipped XML entirely)
591
- cleaned = raw_text.strip()
592
- # Remove any residual XML tags
593
- cleaned_no_tags = re.sub(r"<[^>]+>", "", cleaned).strip()
594
- if cleaned_no_tags:
595
- # The LLM output something but not in XML format
596
- # Treat the entire output as a response
597
- parsed.reply = cleaned_no_tags
598
- parsed.parse_success = True
599
- logger.info(
600
- f"XML解析未提取到结构化字段,将原始文本(去除标签后)作为reply: "
601
- f"{cleaned_no_tags[:100]}..."
602
- )
603
- else:
604
- # Complete parse failure
605
- parsed.needs_correction = True
606
- logger.warning(
607
- f"XML解析完全失败,需要LLM修正。原始输出前200字符: {raw_text[:200]}"
608
- )
609
-
610
- return parsed
611
-
612
-
613
- def _parse_toolstocal(toolstocal_content: str, *, conservative: bool = False) -> List[Dict[str, Any]]:
614
- """Parse ``<toolstocal>`` body into a list of tool descriptors."""
615
- tools: List[Dict[str, Any]] = []
616
-
617
- tool_blocks = _extract_all_tag_blocks(
618
- toolstocal_content, "tool", parent_close_tag="</toolstocal>",
619
- conservative=conservative,
620
- )
621
-
622
- for block in tool_blocks:
623
- tool: Dict[str, Any] = {
624
- "toolname": _safe_strip(
625
- _extract_tag_content(block, "toolname", TOOL_INNER_TAGS, conservative=conservative)
626
- ),
627
- "parms": _safe_strip(
628
- _extract_tag_content(block, "parms", TOOL_INNER_TAGS, conservative=conservative)
629
- ),
630
- "timeout": _parse_int(
631
- _extract_tag_content(block, "timeout", TOOL_INNER_TAGS, conservative=conservative),
632
- _DEFAULT_TIMEOUT,
633
- ),
634
- }
635
- # Only add if toolname is present
636
- if tool["toolname"]:
637
- tools.append(tool)
638
-
639
- return tools
640
-
641
-
642
- # ---------------------------------------------------------------------------
643
- # Public API
644
- # ---------------------------------------------------------------------------
645
-
646
-
647
- def parse_output(raw_text: str) -> ParsedOutput:
648
- """Parse the LLM's raw response into a :class:`ParsedOutput`.
649
-
650
- This function uses a **fully custom regex-based parser** (no
651
- ``xml.etree.ElementTree``) for maximum fault tolerance.
652
-
653
- If the custom parser cannot extract any meaningful content, it falls
654
- back to treating the raw text as a plain response. Only if even that
655
- fails does it set ``needs_correction=True``, signalling the caller to
656
- ask the LLM to re-format its output.
657
-
658
- Parameters:
659
- raw_text: The complete text returned by the LLM.
660
-
661
- Returns:
662
- A :class:`ParsedOutput` instance.
663
- """
664
- if not raw_text:
665
- return ParsedOutput(raw_text=raw_text, needs_correction=True)
666
-
667
- return _custom_parse(raw_text)
668
-
669
-
670
- def extract_surrounding_text(full_text: str) -> tuple[str, str]:
671
- """Split *full_text* around the ``<output>…</output>`` block.
672
-
673
- Returns:
674
- A ``(text_before_xml, text_after_xml)`` tuple. Both parts are
675
- stripped. If no ``<output>`` block is found the original text
676
- becomes *text_before_xml* and *text_after_xml* is ``""``.
677
- """
678
- open_match = re.search(r"<output[^>]*>", full_text, re.IGNORECASE)
679
- if open_match is None:
680
- return full_text.strip(), ""
681
-
682
- text_before = full_text[: open_match.start()].strip()
683
-
684
- rest = full_text[open_match.end() :]
685
- close_match = re.search(r"</output\s*>", rest, re.IGNORECASE)
686
- if close_match is None:
687
- text_after = rest.strip()
688
- else:
689
- text_after = rest[close_match.end() :].strip()
690
-
691
- return text_before, text_after
692
-
693
-
694
- # ---------------------------------------------------------------------------
695
- # Validation
696
- # ---------------------------------------------------------------------------
697
-
698
-
699
- def validate_output(parsed: ParsedOutput) -> list[str]:
700
- """Validate a :class:`ParsedOutput` and return a list of warnings.
701
-
702
- An empty list means no issues were detected. Warnings are non-fatal
703
- hints that the calling code may log or present to the user.
704
- """
705
- warnings: list[str] = []
706
-
707
- # --- Tool-level checks ---
708
- for idx, tool in enumerate(parsed.tools_to_call):
709
- prefix = f"tool[{idx}]"
710
-
711
- if not tool.get("toolname"):
712
- warnings.append(f"{prefix}: missing 'toolname'")
713
-
714
- timeout = tool.get("timeout", _DEFAULT_TIMEOUT)
715
- if isinstance(timeout, int) and timeout <= 0:
716
- warnings.append(
717
- f"{prefix}: timeout={timeout} is not positive; "
718
- f"defaulting to {_DEFAULT_TIMEOUT}s"
719
- )
720
-
721
- if tool.get("toolname") and not tool.get("parms"):
722
- warnings.append(
723
- f"{prefix} ('{tool['toolname']}'): 'parms' is empty — "
724
- "verify the tool requires no parameters"
725
- )
726
-
727
- # --- Semantic checks ---
728
- # [v1.36] askuser/finish/finish_reason 已废弃,移除相关校验
729
-
730
- return warnings