wtagent 0.1.0-alpha.0

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,333 @@
1
+ import { XMLParser, XMLValidator } from "fast-xml-parser";
2
+ import { ProtocolError } from "../shared/errors.js";
3
+
4
+ const parser = new XMLParser({
5
+ ignoreAttributes: false,
6
+ attributeNamePrefix: "",
7
+ textNodeName: "#text",
8
+ cdataPropName: "#cdata",
9
+ parseTagValue: false,
10
+ parseAttributeValue: false,
11
+ trimValues: false,
12
+ });
13
+
14
+ function stripSingleCodeFence(text) {
15
+ const trimmed = String(text ?? "").trim();
16
+ const match = trimmed.match(/^```(?:xml)?\s*([\s\S]*?)\s*```$/i);
17
+ return match ? match[1].trim() : trimmed;
18
+ }
19
+
20
+ // Matches the ampersand of a well-formed XML entity: named (&), decimal
21
+ // (&), or hex (&). Anything else is a bare ampersand the model forgot
22
+ // to escape or wrap in CDATA.
23
+ const VALID_ENTITY = /&(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);/y;
24
+
25
+ // Escapes bare ampersands to & while leaving valid entities and everything
26
+ // inside CDATA sections untouched. Unescaped `&` (e.g. "Tom & Jerry", query
27
+ // strings like "a=1&b=2", "R&D") is the most common reason ChatGPT Web's XML
28
+ // fails strict parsing, and repairing it is always safe: a bare `&` is never
29
+ // legal XML, so this cannot change the meaning of otherwise-valid markup.
30
+ function escapeBareAmpersands(text) {
31
+ let out = "";
32
+ let index = 0;
33
+ const cdataOpen = "<![CDATA[";
34
+ const cdataClose = "]]>";
35
+
36
+ while (index < text.length) {
37
+ const char = text[index];
38
+ if (char === "<" && text.startsWith(cdataOpen, index)) {
39
+ const close = text.indexOf(cdataClose, index + cdataOpen.length);
40
+ const end = close < 0 ? text.length : close + cdataClose.length;
41
+ out += text.slice(index, end);
42
+ index = end;
43
+ continue;
44
+ }
45
+ if (char === "&") {
46
+ VALID_ENTITY.lastIndex = index;
47
+ if (VALID_ENTITY.test(text)) {
48
+ out += text.slice(index, VALID_ENTITY.lastIndex);
49
+ index = VALID_ENTITY.lastIndex;
50
+ } else {
51
+ out += "&amp;";
52
+ index += 1;
53
+ }
54
+ continue;
55
+ }
56
+ out += char;
57
+ index += 1;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function extractEnvelope(text) {
63
+ const cleaned = stripSingleCodeFence(text);
64
+ const start = cleaned.indexOf("<agent_response");
65
+ const endTag = "</agent_response>";
66
+ const end = cleaned.lastIndexOf(endTag);
67
+
68
+ if (start < 0 || end < 0 || end < start) {
69
+ throw new ProtocolError(
70
+ "Response must contain one complete <agent_response> envelope.",
71
+ { details: { raw: cleaned } },
72
+ );
73
+ }
74
+
75
+ // ChatGPT Web may prepend a preamble (e.g. "Sure, here is the response:")
76
+ // or append trailing text / render rich cards around the XML. We only care
77
+ // about the envelope itself, so surrounding text is stripped instead of
78
+ // being treated as a protocol violation.
79
+ return cleaned.slice(start, end + endTag.length);
80
+ }
81
+
82
+ function normalizeXmlValue(value) {
83
+ if (value == null) {
84
+ return "";
85
+ }
86
+ if (Array.isArray(value)) {
87
+ return value.map(normalizeXmlValue);
88
+ }
89
+ if (typeof value !== "object") {
90
+ return String(value);
91
+ }
92
+
93
+ const keys = Object.keys(value);
94
+ const textKeys = keys.filter((key) => key === "#text" || key === "#cdata");
95
+ const contentKeys = keys.filter(
96
+ (key) => key !== "#text" && key !== "#cdata",
97
+ );
98
+ if (textKeys.length === keys.length) {
99
+ return textKeys.map((key) => String(value[key] ?? "")).join("");
100
+ }
101
+
102
+ if (
103
+ contentKeys.length === 1
104
+ && ["item", "string", "arg"].includes(contentKeys[0])
105
+ ) {
106
+ const listValue = value[contentKeys[0]];
107
+ const items = Array.isArray(listValue) ? listValue : [listValue];
108
+ return items.map(normalizeXmlValue);
109
+ }
110
+
111
+ const normalized = {};
112
+ for (const [key, child] of Object.entries(value)) {
113
+ if (key === "#text" || key === "#cdata") {
114
+ continue;
115
+ }
116
+ normalized[key] = normalizeXmlValue(child);
117
+ }
118
+ return normalized;
119
+ }
120
+
121
+ function scalarText(value) {
122
+ const normalized = normalizeXmlValue(value);
123
+ if (typeof normalized === "string") {
124
+ return normalized.trim();
125
+ }
126
+ return "";
127
+ }
128
+
129
+ function parseDone(value) {
130
+ const text = scalarText(value).toLowerCase();
131
+ if (text !== "true" && text !== "false") {
132
+ throw new ProtocolError("<done> must be true or false.");
133
+ }
134
+ return text === "true";
135
+ }
136
+
137
+ // Reads a single element's inner text by regex, tolerating attributes on the
138
+ // tag and CDATA inside it. Used only by the recovery path below.
139
+ function looseTagText(xml, tag) {
140
+ const match = new RegExp(
141
+ `<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`,
142
+ "i",
143
+ ).exec(xml);
144
+ if (!match) {
145
+ return null;
146
+ }
147
+ let inner = match[1];
148
+ const cdata = /^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/.exec(inner);
149
+ if (cdata) {
150
+ return cdata[1].trim();
151
+ }
152
+ // Strip any stray tags the model may have left inside the message, then
153
+ // decode the handful of entities that matter for display.
154
+ return inner
155
+ .replace(/<[^>]*>/g, "")
156
+ .replaceAll("&lt;", "<")
157
+ .replaceAll("&gt;", ">")
158
+ .replaceAll("&quot;", '"')
159
+ .replaceAll("&apos;", "'")
160
+ .replaceAll("&amp;", "&")
161
+ .trim();
162
+ }
163
+
164
+ // Last-resort recovery when strict parsing fails. It ONLY salvages a
165
+ // conversational, message-only answer — a response that carries <done> and
166
+ // <message> but no <tool_call>. Tool calls are never recovered this way:
167
+ // guessing the arguments of a side-effecting operation from broken XML is
168
+ // unsafe, so those still fall through to a format retry.
169
+ function recoverMessageOnlyResponse(envelope) {
170
+ if (/<tool_call[\s>]/i.test(envelope)) {
171
+ return null;
172
+ }
173
+ const doneText = looseTagText(envelope, "done");
174
+ const message = looseTagText(envelope, "message");
175
+ if (doneText == null || message == null) {
176
+ return null;
177
+ }
178
+ const done = doneText.trim().toLowerCase();
179
+ if (done !== "true" && done !== "false") {
180
+ return null;
181
+ }
182
+ if (done === "true" && !message.trim()) {
183
+ return null;
184
+ }
185
+ return {
186
+ done: done === "true",
187
+ message,
188
+ toolCall: null,
189
+ raw: envelope,
190
+ recovered: true,
191
+ };
192
+ }
193
+
194
+ export function parseAgentResponse(rawText) {
195
+ const rawEnvelope = extractEnvelope(rawText);
196
+
197
+ const structuralXml = rawEnvelope.replace(
198
+ /<!\[CDATA\[[\s\S]*?\]\]>/g,
199
+ "<![CDATA[]]>",
200
+ );
201
+ if (/<!DOCTYPE|<!ENTITY/i.test(structuralXml)) {
202
+ throw new ProtocolError("DTD and XML entities are not allowed.");
203
+ }
204
+
205
+ // Repair the single most common corruption first: bare ampersands the model
206
+ // wrote outside CDATA. This is done before validation so an otherwise
207
+ // well-formed envelope with a stray "&" parses instead of triggering a retry.
208
+ const envelope = escapeBareAmpersands(rawEnvelope);
209
+
210
+ const validation = XMLValidator.validate(envelope);
211
+ if (validation !== true) {
212
+ const recovered = recoverMessageOnlyResponse(envelope);
213
+ if (recovered) {
214
+ return recovered;
215
+ }
216
+ throw new ProtocolError(
217
+ `Invalid XML: ${validation.err?.msg ?? "unknown XML error"}`,
218
+ { details: validation },
219
+ );
220
+ }
221
+
222
+ let parsed;
223
+ try {
224
+ parsed = parser.parse(envelope);
225
+ } catch (error) {
226
+ const recovered = recoverMessageOnlyResponse(envelope);
227
+ if (recovered) {
228
+ return recovered;
229
+ }
230
+ throw new ProtocolError(`Invalid XML: ${error.message}`, { cause: error });
231
+ }
232
+
233
+ const response = parsed.agent_response;
234
+ if (!response || typeof response !== "object" || Array.isArray(response)) {
235
+ throw new ProtocolError("Missing <agent_response> root.");
236
+ }
237
+
238
+ const done = parseDone(response.done);
239
+ const message = scalarText(response.message);
240
+ const rawToolCall = response.tool_call;
241
+
242
+ if (Array.isArray(rawToolCall)) {
243
+ throw new ProtocolError("V1 allows at most one <tool_call> per turn.");
244
+ }
245
+
246
+ let toolCall = null;
247
+ if (rawToolCall != null && rawToolCall !== "") {
248
+ if (typeof rawToolCall !== "object") {
249
+ throw new ProtocolError("<tool_call> must contain a name and args.");
250
+ }
251
+
252
+ const name = String(rawToolCall.name ?? "").trim();
253
+ if (!name) {
254
+ throw new ProtocolError("<tool_call> is missing the name attribute.");
255
+ }
256
+
257
+ const normalizedArgs = normalizeXmlValue(
258
+ rawToolCall.args ?? rawToolCall.arguments ?? {},
259
+ );
260
+ toolCall = {
261
+ id: String(rawToolCall.id ?? "").trim() || null,
262
+ name,
263
+ args: typeof normalizedArgs === "string" && !normalizedArgs.trim()
264
+ ? {}
265
+ : normalizedArgs,
266
+ };
267
+ }
268
+
269
+ if (done && toolCall) {
270
+ throw new ProtocolError(
271
+ "A completed response cannot also request a tool. Use done=false.",
272
+ );
273
+ }
274
+
275
+ return {
276
+ done,
277
+ message,
278
+ toolCall,
279
+ raw: envelope,
280
+ };
281
+ }
282
+
283
+ export function escapeXmlText(value) {
284
+ return String(value ?? "")
285
+ .replaceAll("&", "&amp;")
286
+ .replaceAll("<", "&lt;")
287
+ .replaceAll(">", "&gt;");
288
+ }
289
+
290
+ export function escapeXmlAttribute(value) {
291
+ return escapeXmlText(value)
292
+ .replaceAll('"', "&quot;")
293
+ .replaceAll("'", "&apos;");
294
+ }
295
+
296
+ export function cdata(value) {
297
+ const safe = String(value ?? "").replaceAll("]]>", "]]]]><![CDATA[>");
298
+ return `<![CDATA[${safe}]]>`;
299
+ }
300
+
301
+ function optionalResultField(tag, value) {
302
+ if (value == null || value === "") {
303
+ return "";
304
+ }
305
+ return `\n <${tag}>${cdata(value)}</${tag}>`;
306
+ }
307
+
308
+ export function serializeToolResult(result) {
309
+ const status = result.ok ? "ok" : "error";
310
+ const data = result.data == null
311
+ ? ""
312
+ : typeof result.data === "string"
313
+ ? result.data
314
+ : JSON.stringify(result.data);
315
+
316
+ return [
317
+ `<tool_result name="${escapeXmlAttribute(result.name)}"`,
318
+ ` status="${status}">`,
319
+ `\n <message>${cdata(result.message ?? "")}</message>`,
320
+ optionalResultField("stdout", result.stdout),
321
+ optionalResultField("stderr", result.stderr),
322
+ optionalResultField("data", data),
323
+ "\n</tool_result>",
324
+ ].join("");
325
+ }
326
+
327
+ export function serializeProtocolError(error) {
328
+ return [
329
+ "<protocol_error>",
330
+ cdata(error.message),
331
+ "</protocol_error>",
332
+ ].join("");
333
+ }