@falai/agent 3.2.1 → 3.2.2

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/src/utils/json.ts CHANGED
@@ -2,12 +2,103 @@
2
2
  * JSON parsing utilities
3
3
  */
4
4
 
5
+ /** JSON's own short escapes for the control characters a model actually types. */
6
+ const CONTROL_ESCAPES: Record<string, string> = {
7
+ "\n": "\\n",
8
+ "\r": "\\r",
9
+ "\t": "\\t",
10
+ "\b": "\\b",
11
+ "\f": "\\f",
12
+ };
13
+
14
+ /**
15
+ * Escape the raw control characters a model left unescaped inside a string
16
+ * literal.
17
+ *
18
+ * JSON forbids a literal newline between quotes. A model whose decoder is
19
+ * pinned to the schema cannot break that rule, but one merely ASKED for the
20
+ * envelope in its prompt — which is how a schema rides on any call that also
21
+ * carries tools — breaks it constantly, because it pretty-prints the reply it
22
+ * would have sent:
23
+ *
24
+ * {
25
+ * "message": "Boa escolha!
26
+ * À vista: R$ 3.149"
27
+ * }
28
+ *
29
+ * `JSON.parse` calls that an unterminated string and gives up, and the whole
30
+ * envelope travels on as the user-visible reply. Re-escaping the control
31
+ * characters makes it parse into exactly what the model meant.
32
+ *
33
+ * A string ends at the next unescaped quote, so a stray quote inside the
34
+ * message shifts the boundary and the result fails to parse. That is the
35
+ * intended outcome: a wrong guess must never become a reply.
36
+ */
37
+ function escapeControlCharsInStrings(text: string): string {
38
+ let out = "";
39
+ let inString = false;
40
+ let escaped = false;
41
+
42
+ for (const ch of text) {
43
+ if (!inString) {
44
+ if (ch === '"') inString = true;
45
+ out += ch;
46
+ continue;
47
+ }
48
+ if (escaped) {
49
+ escaped = false;
50
+ out += ch;
51
+ } else if (ch === "\\") {
52
+ escaped = true;
53
+ out += ch;
54
+ } else if (ch === '"') {
55
+ inString = false;
56
+ out += ch;
57
+ } else if (ch < " ") {
58
+ out += CONTROL_ESCAPES[ch] ?? `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`;
59
+ } else {
60
+ out += ch;
61
+ }
62
+ }
63
+
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Parse strictly, then once more with {@link escapeControlCharsInStrings}.
69
+ * Throws when neither reading is valid JSON.
70
+ */
71
+ function parseLenient(text: string): unknown {
72
+ try {
73
+ return JSON.parse(text);
74
+ } catch (strictError) {
75
+ try {
76
+ return JSON.parse(escapeControlCharsInStrings(text));
77
+ } catch {
78
+ // The repair is a second reading of the same text, not a different
79
+ // dialect — so the first violation is the one worth reporting.
80
+ throw strictError;
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Whether text is shaped like a protocol envelope rather than a reply to a
87
+ * person — an opening brace or a code fence. Text that fails to parse AND
88
+ * looks like this is never user-worthy: it is a broken envelope, and showing
89
+ * it is the leak this module exists to prevent.
90
+ */
91
+ export function isJSONShaped(text: string): boolean {
92
+ return /^\s*(```|\{)/.test(text);
93
+ }
94
+
5
95
  /**
6
96
  * Clean and parse JSON response that might be wrapped in markdown code blocks
7
97
  * Handles cases like:
8
98
  * - ```json\n{...}\n```
9
99
  * - ```\n{...}\n```
10
100
  * - Plain JSON: {...}
101
+ * - An object whose string values carry unescaped newlines
11
102
  */
12
103
  export function parseJSONResponse(text: string): unknown {
13
104
  if (!text || typeof text !== 'string') {
@@ -28,7 +119,7 @@ export function parseJSONResponse(text: string): unknown {
28
119
 
29
120
  // Try to parse the cleaned JSON
30
121
  try {
31
- return JSON.parse(cleaned);
122
+ return parseLenient(cleaned);
32
123
  } catch (error) {
33
124
  throw new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : String(error)}\nContent: ${cleaned.substring(0, 200)}...`);
34
125
  }
@@ -66,7 +157,7 @@ export function extractEmbeddedJSONObject(text: string): Record<string, unknown>
66
157
  if (end === -1) continue;
67
158
 
68
159
  try {
69
- const parsed = JSON.parse(text.slice(start, end + 1));
160
+ const parsed = parseLenient(text.slice(start, end + 1));
70
161
  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
71
162
  return parsed as Record<string, unknown>;
72
163
  }