Compare commits

...
Author SHA1 Message Date
qaz8545355andqaz8545355 d2f03ca528 fix: handle <think> reasoning tags in parseExtractorOutput (#2559)
Reasoning models (MiniMax-M3, DeepSeek-R1, etc.) return <think>...</think>
tags in the content field before the actual JSON output. This caused
parseExtractorOutput to fail in two ways:

1. The fence regex /^\`\`\`(json)?...$/ requires the fence at text start;
   <think> preceding it prevents matching, so the raw text (with trailing
   fences) hits JSON.parse and throws.

2. When think tags contain [ or { characters, indexOf finds them inside
   the reasoning block instead of the actual JSON array.

Changes:
- Strip <think>...</think> tags before any parsing (covers all reasoning models)
- Add JSON.parse fallback: truncate at last ] or } to handle trailing
  noise (leftover markdown fences after stripping)

Tests: 28/28 pass (3 new cases for think tags + trailing noise).

Co-authored-by: qaz8545355 <junjun@openclaw.local>
2026-07-23 15:31:50 -07:00
2 changed files with 37 additions and 1 deletions
+17 -1
View File
@@ -299,6 +299,8 @@ export async function defaultExtractor(
export function parseExtractorOutput(raw: string): ProposedTake[] {
if (!raw || raw.trim().length === 0) return [];
let text = raw.trim();
// Strip <think>...</think> reasoning tags (MiniMax-M3, DeepSeek-R1, etc.).
text = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
// Strip markdown code fence wrapper.
const fenced = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
if (fenced) text = (fenced[1] ?? '').trim();
@@ -311,7 +313,21 @@ export function parseExtractorOutput(raw: string): ProposedTake[] {
try {
parsed = JSON.parse(text.slice(start));
} catch {
return [];
// Fallback: truncate at last ] or } to handle trailing noise (e.g. leftover
// markdown fences after <think> stripping). Try array-closing first.
const sliced = text.slice(start);
const lastArr = sliced.lastIndexOf(']');
const lastObj = sliced.lastIndexOf('}');
const end = Math.max(lastArr, lastObj);
if (end > 0) {
try {
parsed = JSON.parse(sliced.slice(0, end + 1));
} catch {
return [];
}
} else {
return [];
}
}
const arr = Array.isArray(parsed) ? parsed : [parsed];
const out: ProposedTake[] = [];
+20
View File
@@ -169,6 +169,26 @@ describe('parseExtractorOutput', () => {
const out = parseExtractorOutput(raw);
expect(out[0]!.domain).toBe('macro');
});
test('strips <think> reasoning tags before parsing (MiniMax-M3, DeepSeek-R1)', () => {
const raw = '<think>Analyzing the prose... I see several claims.</think>\n\n```json\n[{"claim_text":"X","kind":"take","holder":"brain","weight":0.5}]\n```';
const out = parseExtractorOutput(raw);
expect(out).toHaveLength(1);
expect(out[0]!.claim_text).toBe('X');
});
test('strips multiple <think> blocks', () => {
const raw = '<think>First thought.</think>\n<tool_call>...</tool_call>\n<think>Second thought.</think>\n\n[{"claim_text":"Y","kind":"bet","holder":"brain","weight":0.7}]';
const out = parseExtractorOutput(raw);
expect(out).toHaveLength(1);
});
test('handles trailing noise after JSON (leftover fences)', () => {
const raw = '<think>done</think>\n```json\n[{"claim_text":"Z","kind":"take","holder":"brain","weight":0.6}]\n```\n';
const out = parseExtractorOutput(raw);
expect(out).toHaveLength(1);
expect(out[0]!.claim_text).toBe('Z');
});
});
// ─── contentHash ────────────────────────────────────────────────────