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) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-29 01:55:12 -03:00
parent 892a376669
commit 7b5c58902c
2 changed files with 53 additions and 8 deletions

View File

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

View File

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