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.
This commit is contained in:
santhreal 2026-07-18 18:35:43 -07:00
parent 2c5cc409b0
commit 5e2ef821b5
4 changed files with 28 additions and 9 deletions

1
.gitignore vendored
View file

@ -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

View file

@ -1508,13 +1508,13 @@ class ExecutionAnalyzer:
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)}")

View file

@ -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

View file

@ -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}