From 7b5c58902cd6a4cd95a7d2da9b4f7c2b56aa9d72 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 29 May 2026 01:55:12 -0300 Subject: [PATCH] Harden command parsing (review fix) interpret() now extracts the FIRST balanced [...] array and tolerates code fences / trailing prose, instead of a greedy [.*] that could swallow trailing bracketed text and fail to parse. Falls back gracefully to a spoken apology. Added regression tests for trailing brackets, fenced objects, and garbage. 44 tests passing; edge cases (angle 0, offset 0, negative moves, unknown stock) verified. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/woodshop/driver.py | 46 ++++++++++++++++++++++++++++++++++-------- tests/test_driver.py | 15 ++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/woodshop/driver.py b/src/woodshop/driver.py index 0bb2a73..56d9956 100644 --- a/src/woodshop/driver.py +++ b/src/woodshop/driver.py @@ -73,17 +73,47 @@ User said: "{utterance}" """ +def _extract_calls(raw: str) -> list[dict] | None: + """Pull a JSON array of calls out of a model response, tolerating code + fences and trailing prose. Tries the whole string, then the FIRST balanced + [...] (not greedy-to-last-bracket, which would swallow trailing text).""" + raw = raw.strip() + if raw.startswith("```"): + raw = re.sub(r"^```[a-zA-Z]*\n?", "", raw) + raw = re.sub(r"\n?```$", "", raw).strip() + + candidates = [raw] + start = raw.find("[") + if start != -1: + depth = 0 + for i in range(start, len(raw)): + if raw[i] == "[": + depth += 1 + elif raw[i] == "]": + depth -= 1 + if depth == 0: + candidates.append(raw[start:i + 1]) + break + + for candidate in candidates: + try: + value = json.loads(candidate) + except json.JSONDecodeError: + continue + if isinstance(value, list): + return value + if isinstance(value, dict): + return [value] + return None + + def interpret(utterance: str, schemas: str) -> list[dict]: prompt = SYSTEM.format(schemas=schemas, scene=scene_summary(), utterance=utterance) raw = _run(REASON_PROVIDER.split(), stdin=prompt) - match = re.search(r"\[.*\]", raw, re.DOTALL) # tolerate stray text/fences - if not match: - return [{"tool": "say", "args": {"text": "Sorry, I didn't catch a command."}}] - try: - calls = json.loads(match.group(0)) - except json.JSONDecodeError: - return [{"tool": "say", "args": {"text": "Sorry, I couldn't parse that."}}] - return calls if isinstance(calls, list) else [calls] + calls = _extract_calls(raw) + if calls is None: + return [{"tool": "say", "args": {"text": "Sorry, I couldn't parse that command."}}] + return calls def dispatch(calls: list[dict], verbose: bool = True) -> list[str]: diff --git a/tests/test_driver.py b/tests/test_driver.py index a967c03..00391cb 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -83,3 +83,18 @@ def test_interpret_handles_garbage(monkeypatch): monkeypatch.setattr(driver, "_run", lambda cmd, stdin="": "I'm not sure what you mean") calls = driver.interpret("blah", schemas="[]") assert calls[0]["tool"] == "say" + + +def test_extract_calls_ignores_trailing_brackets(): + """A greedy [.*] would swallow the trailing '[note]' and fail to parse.""" + raw = '[{"tool": "wood-undo", "args": {}}]\n\nLet me know [if that helps].' + assert driver._extract_calls(raw) == [{"tool": "wood-undo", "args": {}}] + + +def test_extract_calls_strips_fences_and_handles_object(): + assert driver._extract_calls('```json\n{"tool": "wood-clear", "args": {}}\n```') == \ + [{"tool": "wood-clear", "args": {}}] + + +def test_extract_calls_returns_none_on_garbage(): + assert driver._extract_calls("no json here") is None