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 +42 -0
- apps/desktop/test/agent.test.mjs +20 -2
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 ──────────────────────────────────────────────────
|