From 20ee31b721a266971a7848992dd1d7afe71fd10d Mon Sep 17 00:00:00 2001 From: Justin Mclean Date: Thu, 3 Sep 2026 03:43:36 +0000 Subject: [PATCH 1/2] Make a rejected plan diagnosable Two changes, both about the same failure: a decompose attempt is reported as "response is not valid JSON" and there is no way to see what the model actually said. _extract_json now decodes the first complete object with JSONDecoder.raw_decode and ignores whatever follows it. json.loads is strict about trailing content, so a model that closes its object and then adds a sentence of commentary -- or leaves a ``` fence unclosed, which defeats the regex path -- was rejected even though a complete, usable plan was sitting right there. The rejection journal entry gains agent_stdout_path. A 500-character stderr head is enough to see *that* a plan was rejected and useless for seeing *why*, because the malformed part of a plan is almost always near its end; the full response now goes to runs//artifacts/ so a rejection is diagnosable after the fact rather than only reproducible. Best effort: a journal without a path, an empty response, or an unwritable directory leaves the entry as it was. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01S7Y1ASxt4ox8riauVcYCsF --- src/jumar/decompose.py | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/jumar/decompose.py b/src/jumar/decompose.py index ce55cab..d8c119b 100644 --- a/src/jumar/decompose.py +++ b/src/jumar/decompose.py @@ -180,17 +180,40 @@ def _extract_json(text: str) -> dict[str, Any] | None: return result except json.JSONDecodeError: pass + # Last resort: decode the first object and ignore whatever follows it. + # `json.loads` on the whole remainder is strict about trailing content, so + # a model that closes its object and then adds a sentence of commentary -- + # or an unclosed ``` fence, which defeats the regex above -- was reported + # as "response is not valid JSON" even though a complete object was there. + decoder = json.JSONDecoder() start = text.find("{") - if start >= 0: + while start >= 0: try: - result = json.loads(text[start:]) - if isinstance(result, dict): - return result + result, _ = decoder.raw_decode(text[start:]) except json.JSONDecodeError: - pass + start = text.find("{", start + 1) + continue + if isinstance(result, dict): + return result + start = text.find("{", start + 1) return None + +def _dump_rejected(journal: Any, item_id: str, attempt: int, stdout: str) -> str | None: + """Write a rejected plan response next to the journal. Best effort.""" + path = getattr(journal, "_path", None) + if path is None or not stdout: + return None + try: + out_dir = Path(path).parent / "artifacts" + out_dir.mkdir(parents=True, exist_ok=True) + out = out_dir / f"plan-rejected-{item_id}-{attempt}.txt" + out.write_text(stdout, encoding="utf-8") + except OSError: + return None + return str(out) + # --------------------------------------------------------------------------- # Check + Subtask construction # --------------------------------------------------------------------------- @@ -568,6 +591,13 @@ def decompose( journal_payload["rejection_detail"] = detail if result.stderr: journal_payload["agent_stderr_head"] = result.stderr[:500] + # A 500-character head is enough to see *that* a plan was rejected and + # useless for seeing *why*: the malformed part of a plan is almost + # always near its end. Write the whole response beside the journal so a + # rejection is diagnosable after the fact instead of only reproducible. + dump = _dump_rejected(journal, item.item_id, attempt_no + 1, result.stdout) + if dump is not None: + journal_payload["agent_stdout_path"] = dump journal.append( PLAN_REJECTED, item_id=item.item_id, From e6099aac5091a0f64036acd0dd34a42b04488790 Mon Sep 17 00:00:00 2001 From: Justin Mclean Date: Thu, 3 Sep 2026 14:01:39 +1000 Subject: [PATCH 2/2] ruff fix --- src/jumar/decompose.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jumar/decompose.py b/src/jumar/decompose.py index d8c119b..a57e3b5 100644 --- a/src/jumar/decompose.py +++ b/src/jumar/decompose.py @@ -199,7 +199,6 @@ def _extract_json(text: str) -> dict[str, Any] | None: return None - def _dump_rejected(journal: Any, item_id: str, attempt: int, stdout: str) -> str | None: """Write a rejected plan response next to the journal. Best effort.""" path = getattr(journal, "_path", None) @@ -214,6 +213,7 @@ def _dump_rejected(journal: Any, item_id: str, attempt: int, stdout: str) -> str return None return str(out) + # --------------------------------------------------------------------------- # Check + Subtask construction # ---------------------------------------------------------------------------