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.
- package/agents/main_agent.py +37 -259
- package/aiskills/browser_stealth.py +201 -25
- package/aiskills/chromedev_mcp.py +20 -0
- package/package.json +1 -1
- package/web/api_server.py +3 -95
- package/web/ui/chat/chat_main.js +4 -7
- package/web/ui/chat/flow_engine.js +8 -34
- package/worklog.md +27 -0
- package/core/output_parser.py +0 -730
package/core/output_parser.py
DELETED
|
@@ -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
|