diff --git a/src/clayde/prompts/address_review.j2 b/src/clayde/prompts/address_review.j2 index f6913b1..59b7978 100644 --- a/src/clayde/prompts/address_review.j2 +++ b/src/clayde/prompts/address_review.j2 @@ -25,6 +25,8 @@ Steps: After making all changes, provide a short summary of what you changed in response to each review comment. -Output ONLY a JSON object with the following structure. No preamble, no markdown wrapping. +IMPORTANT: Your final response must be ONLY a raw JSON object — nothing else. +Do not include any text before or after the JSON. Do not wrap it in markdown code fences. +Your entire response must be parseable by json.loads(). {"summary": ""} diff --git a/src/clayde/prompts/implement.j2 b/src/clayde/prompts/implement.j2 index cfb1522..68e51d6 100644 --- a/src/clayde/prompts/implement.j2 +++ b/src/clayde/prompts/implement.j2 @@ -25,6 +25,8 @@ Steps: Do NOT create a pull request — it will be created automatically after you finish. -Output ONLY a JSON object with the following structure. No preamble, no markdown wrapping. +IMPORTANT: Your final response must be ONLY a raw JSON object — nothing else. +Do not include any text before or after the JSON. Do not wrap it in markdown code fences. +Your entire response must be parseable by json.loads(). {"summary": ""} diff --git a/src/clayde/prompts/preliminary_plan.j2 b/src/clayde/prompts/preliminary_plan.j2 index 2a6e054..c6eed70 100644 --- a/src/clayde/prompts/preliminary_plan.j2 +++ b/src/clayde/prompts/preliminary_plan.j2 @@ -30,6 +30,8 @@ Also determine: `clayde/issue-{{ number }}-` where `` is a 1-to-3 word lowercase hyphenated description (e.g. `clayde/issue-{{ number }}-better-branch-naming`). -Output ONLY a JSON object with the following structure. No preamble, no markdown wrapping. +IMPORTANT: Your final response must be ONLY a raw JSON object — nothing else. +Do not include any text before or after the JSON. Do not wrap it in markdown code fences. +Your entire response must be parseable by json.loads(). {"plan": "", "size": "small|large", "branch_name": "clayde/issue-{{ number }}-"} diff --git a/src/clayde/prompts/thorough_plan.j2 b/src/clayde/prompts/thorough_plan.j2 index b5dec97..001fafd 100644 --- a/src/clayde/prompts/thorough_plan.j2 +++ b/src/clayde/prompts/thorough_plan.j2 @@ -37,6 +37,8 @@ implementation. If anything is ambiguous or you need clarification, include your questions at the end under a "Questions" section. -Output ONLY a JSON object with the following structure. No preamble, no markdown wrapping. +IMPORTANT: Your final response must be ONLY a raw JSON object — nothing else. +Do not include any text before or after the JSON. Do not wrap it in markdown code fences. +Your entire response must be parseable by json.loads(). {"plan": ""} diff --git a/src/clayde/prompts/update_plan.j2 b/src/clayde/prompts/update_plan.j2 index 9baa5b4..9b0de71 100644 --- a/src/clayde/prompts/update_plan.j2 +++ b/src/clayde/prompts/update_plan.j2 @@ -33,6 +33,8 @@ Also produce the complete updated plan (copy unchanged sections verbatim — do not rephrase, reformat, or restructure parts of the plan that are not affected by the new comments. Only modify what is necessary). -Output ONLY a JSON object with the following structure. No preamble, no markdown wrapping. +IMPORTANT: Your final response must be ONLY a raw JSON object — nothing else. +Do not include any text before or after the JSON. Do not wrap it in markdown code fences. +Your entire response must be parseable by json.loads(). {"summary": "", "updated_plan": ""} diff --git a/src/clayde/responses.py b/src/clayde/responses.py index f109838..2ba965b 100644 --- a/src/clayde/responses.py +++ b/src/clayde/responses.py @@ -30,13 +30,44 @@ class AddressReviewResponse(BaseModel): summary: str -def _strip_code_fences(text: str) -> str: - """Strip markdown code fences (```json ... ``` or ``` ... ```) from text.""" +def _extract_json(text: str) -> str: + """Extract a JSON object from LLM output that may contain surrounding text. + + Tries in order: + 1. Code-fenced JSON block (```json ... ``` or ``` ... ```) + 2. First top-level { ... } object in the text + 3. Falls back to stripped original text + """ text = text.strip() - # Remove ```json ... ``` or ``` ... ``` fences - m = re.match(r"^```(?:json)?\s*\n([\s\S]*?)\n```$", text) + # 1. Extract from markdown code fence anywhere in the text + m = re.search(r"```(?:json)?\s*\n([\s\S]*?)\n```", text) if m: return m.group(1).strip() + # 2. Find the first top-level JSON object by matching braces + start = text.find("{") + if start != -1: + depth = 0 + in_string = False + escape = False + for i in range(start, len(text)): + c = text[i] + if escape: + escape = False + continue + if c == "\\": + escape = True + continue + if c == '"' and not escape: + in_string = not in_string + continue + if in_string: + continue + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return text[start : i + 1] return text @@ -49,7 +80,7 @@ def parse_response(text: str, model_class: type[BaseModel]) -> BaseModel: Raises: ValueError: If the text cannot be parsed as JSON or fails validation. """ - cleaned = _strip_code_fences(text) + cleaned = _extract_json(text) try: data = json.loads(cleaned) except json.JSONDecodeError as e: diff --git a/tests/test_responses.py b/tests/test_responses.py index d330f8e..4871918 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -8,35 +8,54 @@ PreliminaryPlanResponse, ThoroughPlanResponse, UpdatePlanResponse, - _strip_code_fences, + _extract_json, parse_response, ) -class TestStripCodeFences: +class TestExtractJson: def test_strips_json_fences(self): text = "```json\n{\"plan\": \"hello\"}\n```" - assert _strip_code_fences(text) == '{"plan": "hello"}' + assert _extract_json(text) == '{"plan": "hello"}' def test_strips_plain_fences(self): text = "```\n{\"plan\": \"hello\"}\n```" - assert _strip_code_fences(text) == '{"plan": "hello"}' + assert _extract_json(text) == '{"plan": "hello"}' def test_no_fences_unchanged(self): text = '{"plan": "hello"}' - assert _strip_code_fences(text) == text + assert _extract_json(text) == text def test_strips_surrounding_whitespace(self): text = ' {"plan": "hello"} ' - assert _strip_code_fences(text) == '{"plan": "hello"}' + assert _extract_json(text) == '{"plan": "hello"}' def test_partial_fence_unchanged(self): # Only opening fence — should not strip text = "```json\n{\"plan\": \"hello\"}" - result = _strip_code_fences(text) - # no closing fence so it stays as-is (stripped) + result = _extract_json(text) + # no closing fence so brace-matching finds the JSON object assert "plan" in result + def test_extracts_json_with_preamble(self): + text = 'Here is my plan.\n\n```json\n{"plan": "hello"}\n```' + assert _extract_json(text) == '{"plan": "hello"}' + + def test_extracts_json_object_without_fences(self): + text = 'Now I have enough context.\n\n{"plan": "hello"}' + assert _extract_json(text) == '{"plan": "hello"}' + + def test_handles_nested_braces_in_json(self): + text = 'Some text\n{"plan": "use {braces} here"}' + result = _extract_json(text) + assert '"plan"' in result + assert "{braces}" in result + + def test_handles_escaped_quotes(self): + text = r'Preamble {"plan": "say \"hello\""}' + result = _extract_json(text) + assert '"plan"' in result + class TestParseResponse: def test_parses_preliminary_plan(self):