From 5e2ef821b56c78087d9c4b6d1a94f5e1cc34d8df Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:35:43 -0700 Subject: [PATCH] fix(skill_engine): parse LLM JSON despite trailing brace prose Greedy \{.*\} matching spanned into later prose braces, so json.loads failed and analysis/capture dropped valid objects. Use raw_decode from the first brace, matching agents.base. --- .gitignore | 1 + openspace/skill_engine/analyzer.py | 10 +++++----- .../skill_engine/evolution/capture_semantic.py | 10 ++++++---- .../test_extract_json_trailing_prose.py | 16 ++++++++++++++++ 4 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 tests/skill_engine/test_extract_json_trailing_prose.py diff --git a/.gitignore b/.gitignore index 27e8dfb0..73b84e09 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ tests/skill_engine/* !tests/skill_engine/test_evolver_length_recovery.py !tests/skill_engine/test_evolution_retry_idempotency.py !tests/skill_engine/test_analyzer_length_recovery.py +!tests/skill_engine/test_extract_json_trailing_prose.py !tests/skill_engine/decision/ tests/skill_engine/decision/* !tests/skill_engine/decision/test_analysis_adapter.py diff --git a/openspace/skill_engine/analyzer.py b/openspace/skill_engine/analyzer.py index 2d794b44..ada3c680 100644 --- a/openspace/skill_engine/analyzer.py +++ b/openspace/skill_engine/analyzer.py @@ -1508,13 +1508,13 @@ def _extract_json(text: str) -> Optional[Dict[str, Any]]: if code_match: text = code_match.group(1).strip() else: - # Try bare JSON object - json_match = re.search(r"\{.*\}", text, re.DOTALL) - if json_match: - text = json_match.group() + # Prefer the first object start; raw_decode ignores trailing prose. + brace = text.find("{") + if brace >= 0: + text = text[brace:] try: - data = json.loads(text) + data, _ = json.JSONDecoder().raw_decode(text) if isinstance(data, dict): return data logger.warning(f"LLM returned non-dict JSON: {type(data)}") diff --git a/openspace/skill_engine/evolution/capture_semantic.py b/openspace/skill_engine/evolution/capture_semantic.py index a1ceb62a..aff0107f 100644 --- a/openspace/skill_engine/evolution/capture_semantic.py +++ b/openspace/skill_engine/evolution/capture_semantic.py @@ -399,11 +399,13 @@ def _extract_json(text: str) -> dict[str, Any] | None: if match: text = match.group(1) else: - match = re.search(r"\{.*\}", text, re.DOTALL) - if match: - text = match.group(0) + brace = text.find("{") + if brace >= 0: + text = text[brace:] + else: + return None try: - value = json.loads(text) + value, _ = json.JSONDecoder().raw_decode(text) except Exception: return None return dict(value) if isinstance(value, Mapping) else None diff --git a/tests/skill_engine/test_extract_json_trailing_prose.py b/tests/skill_engine/test_extract_json_trailing_prose.py new file mode 100644 index 00000000..f4ca5069 --- /dev/null +++ b/tests/skill_engine/test_extract_json_trailing_prose.py @@ -0,0 +1,16 @@ +"""Regression: trailing prose with braces must not drop valid analysis JSON.""" + +from __future__ import annotations + +from openspace.skill_engine.analyzer import ExecutionAnalyzer +from openspace.skill_engine.evolution.capture_semantic import _extract_json as capture_extract_json + + +def test_analyzer_extract_json_tolerates_trailing_prose_braces(): + text = '{"task_completed": true}\n\nReplace {foo} with {bar}.' + assert ExecutionAnalyzer._extract_json(text) == {"task_completed": True} + + +def test_capture_semantic_extract_json_tolerates_trailing_prose_braces(): + text = '{"ok": true}\n\nnote {x}' + assert capture_extract_json(text) == {"ok": True}