keyword-researcher: brittle JSON parsing dropped output
Status: ✅ Fixed
Severity: Medium
Component: packages/agents/src/workers/keyword-researcher.worker.ts
Symptom
On the first job run (and occasionally later), the keyword researcher saved no keyword groups to the DB and left deliverables in pending_review with no data. Logs showed no error — the worker appeared to succeed.
Root Cause
The postProcess function stripped markdown fences using anchored regexes:
const cleaned = output.replace(/^```(?:json)?\n?/i, "").replace(/\n?```$/i, "").trim();These only matched fences at the absolute start/end of the string. Claude frequently writes a brief preamble before the JSON block (e.g. “Here are the keyword groups for your campaign:\n\n```json\n{…}”):
- Prose before JSON → regex misses the fence →
JSON.parsefails on the prose → falls back silently, saving nothing - Fenced JSON mid-text → same failure
- Trailing prose after closing brace → parse succeeds but misses trailing text (rare but possible)
The catch block saved the deliverable in pending_review without groups, so no exception was raised and the failure was invisible.
Fix
keyword-researcher.worker.ts:
- Replaced the brittle regex with
extractJson<AgentOutput>(output)imported fromactivity.worker.ts extractJsonuses brace-depth scanning to find the first complete{...}object regardless of surrounding text or fences- Added
promptSuffix: OUTPUT_FORMAT_INSTRUCTIONviacreateContentWorkeroptions — appended to every prompt, it tells Claude to start with{and end with}with no prose or fences
content.worker.ts:
- Added
promptSuffix?: stringtoContentWorkerOptions— appended after all context sections. Available to any content worker that needs strict output-format enforcement.
Variables
The groups array shape that must be returned:
{
"groups": [
{
"name": "string",
"purpose": "string",
"keywords": [
{ "keyword": "string", "searchVolume": "string?", "difficulty": "string?", "intent": "string?", "category": "string?" }
]
}
]
}