mo commited on
Commit
d0f0aa1
·
1 Parent(s): 9b04a00

fix(parser): lenient answer salvage — no raw JSON leaks when the brain breaks JSON with unescaped quotes (e.g. German „..." quotes); 3 new tests

Browse files
apps/desktop/src/agent.js CHANGED
@@ -75,6 +75,38 @@ export function parseToolCall(text) {
75
  return null;
76
  }
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  /**
79
  * Reasoning-protocol parser: the brain answers in one of three shapes:
80
  * {reasoning, tool, args} → {kind:'tools', calls:[...]}
@@ -82,6 +114,7 @@ export function parseToolCall(text) {
82
  * plain text → {kind:'text', text}
83
  * valid JSON, wrong shape → null (malformed → corrective nudge)
84
  * prose wrapping any of those → detected via balanced-brace extraction
 
85
  */
86
  export function parseBrainReply(text) {
87
  if (!text) return null;
@@ -144,6 +177,15 @@ export function parseBrainReply(text) {
144
  ai += 6;
145
  }
146
  }
 
 
 
 
 
 
 
 
 
147
  return { kind: 'text', text };
148
  }
149
 
 
75
  return null;
76
  }
77
 
78
+ /**
79
+ * Lenient salvage: LLMs often emit a JSON answer with an unescaped quote
80
+ * inside the string (e.g. German quotes), breaking strict parsing. Extract
81
+ * the string value of `key` by scanning with escape awareness. Returns the
82
+ * decoded string, or null when the field cannot be found.
83
+ */
84
+ export function lenientStringField(text, key) {
85
+ const re = new RegExp('"' + key + '"\\s*:\\s*"');
86
+ const m = re.exec(String(text || ''));
87
+ if (!m) return null;
88
+ let i = m.index + m[0].length;
89
+ let out = '';
90
+ while (i < text.length) {
91
+ const c = text[i];
92
+ if (c === '\\') {
93
+ const n = text[i + 1];
94
+ if (n === 'n') out += '\n';
95
+ else if (n === 't') out += '\t';
96
+ else if (n === 'r') out += '\r';
97
+ else if (n === '\\') out += '\\';
98
+ else if (n === '"') out += '"';
99
+ else out += (n ?? '');
100
+ i += 2;
101
+ continue;
102
+ }
103
+ if (c === '"') return out;
104
+ out += c;
105
+ i++;
106
+ }
107
+ return out !== '' ? out : null;
108
+ }
109
+
110
  /**
111
  * Reasoning-protocol parser: the brain answers in one of three shapes:
112
  * {reasoning, tool, args} → {kind:'tools', calls:[...]}
 
114
  * plain text → {kind:'text', text}
115
  * valid JSON, wrong shape → null (malformed → corrective nudge)
116
  * prose wrapping any of those → detected via balanced-brace extraction
117
+ * broken JSON with an answer → salvaged leniently (no raw JSON leaks)
118
  */
119
  export function parseBrainReply(text) {
120
  if (!text) return null;
 
177
  ai += 6;
178
  }
179
  }
180
+ // Lenient salvage: broken JSON (unescaped quotes etc.) but a readable
181
+ // answer field — deliver the answer instead of leaking raw JSON.
182
+ if (text.trim().startsWith('{')) {
183
+ const answer = lenientStringField(text, 'answer');
184
+ if (answer !== null && answer.trim() !== '') {
185
+ const reasoning = lenientStringField(text, 'reasoning');
186
+ return { kind: 'answer', answer, reasoning: reasoning ?? '' };
187
+ }
188
+ }
189
  return { kind: 'text', text };
190
  }
191
 
apps/desktop/test/agent.test.mjs CHANGED
@@ -132,9 +132,9 @@ describe('tools/registry', () => {
132
  // ── Brain reply parser (reasoning protocol) ──────────────────────
133
 
134
  describe('brain reply parser', () => {
135
- let parseBrainReply, parseToolCall;
136
  before(async () => {
137
- ({ parseBrainReply, parseToolCall } = await import('../src/agent.js'));
138
  });
139
 
140
  it('parses a plain tool call (legacy)', () => {
@@ -187,6 +187,24 @@ describe('brain reply parser', () => {
187
  assert.equal(parseBrainReply(''), null);
188
  assert.equal(parseBrainReply(null), null);
189
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  });
191
 
192
  // ── Config tests ──────────────────────────────────────────────────
 
132
  // ── Brain reply parser (reasoning protocol) ──────────────────────
133
 
134
  describe('brain reply parser', () => {
135
+ let parseBrainReply, parseToolCall, lenientStringField;
136
  before(async () => {
137
+ ({ parseBrainReply, parseToolCall, lenientStringField } = await import('../src/agent.js'));
138
  });
139
 
140
  it('parses a plain tool call (legacy)', () => {
 
187
  assert.equal(parseBrainReply(''), null);
188
  assert.equal(parseBrainReply(null), null);
189
  });
190
+
191
+ it('salvages a broken answer JSON with unescaped quotes (no raw JSON leak)', () => {
192
+ const raw = '{"reasoning":"Der Befehl wurde ausgeführt","answer":"Er sagte: „Ich bin ein KI-Assistent."\n\nHinweis: sag Bescheid."}';
193
+ const r = parseBrainReply(raw);
194
+ assert.equal(r.kind, 'answer');
195
+ assert.ok(r.answer.startsWith('Er sagte:'));
196
+ assert.ok(!r.answer.includes('{') && !r.answer.includes('reasoning'));
197
+ });
198
+
199
+ it('does not salvage plain text that merely starts with a brace', () => {
200
+ const r = parseBrainReply('{"weird": "broken string without answer');
201
+ assert.equal(r.kind, 'text');
202
+ });
203
+
204
+ it('lenientStringField handles escapes', () => {
205
+ const out = lenientStringField('{"answer":"line1\\nline2 \\\\ back \\\"q\\\""}', 'answer');
206
+ assert.equal(out, 'line1\nline2 \\ back "q"');
207
+ });
208
  });
209
 
210
  // ── Config tests ──────────────────────────────────────────────────