149 lines
6.0 KiB
Python
149 lines
6.0 KiB
Python
"""Lenient JSON for the relayed command channel — and honest about it.
|
||
|
||
`filectl` and `dialoguectl` both take a single line of compact JSON, hand-typed
|
||
by a language model into a tool marker. Models get that *nearly* right and then
|
||
get it wrong in a small, boringly repeatable set of ways:
|
||
|
||
{"op":"list","path":"/home/x","recursive":false"} ← stray quote after a literal
|
||
{"op": "read", "path": "/tmp/a.txt",} ← trailing comma
|
||
{'op': 'read', 'path': '/tmp/a.txt'} ← single quotes
|
||
{“op”: “read”, “path”: “/tmp/a.txt”} ← smart quotes
|
||
{"op": "list", "recursive": False} ← Python literals
|
||
```json {"op": "list"} ``` ← fenced
|
||
|
||
Observed live (2026-07-30): a stray quote after `false` cost an entire desk
|
||
turn — the call was rejected, the model re-sent the *identical* line, was
|
||
rejected again, and then gave up and told the user "I'll check now" without
|
||
ever calling anything. The user got a promise instead of an answer because of
|
||
one character.
|
||
|
||
Strict parsing is the wrong trade here. Nothing about a misplaced quote is
|
||
ambiguous, the payload is machine-written and machine-read, and the cost of
|
||
refusing is a wasted round trip that the model has already demonstrated it
|
||
won't recover from. So: try strict first, then apply narrow repairs, and
|
||
accept a repair **only if the result parses**.
|
||
|
||
Two rules keep this from becoming "guess what they meant":
|
||
|
||
1. **Repairs are conservative and named.** Each one fixes a known malformation,
|
||
is applied in isolation, and is reported by name.
|
||
2. **Repairs are never silent.** The caller appends the repair note to the tool
|
||
result, so the model is told it sent broken JSON *while it still has the
|
||
turn* — the fix works today and teaches within the conversation. Hiding it
|
||
would trade a visible failure for an invisible one.
|
||
|
||
When nothing parses, the error points at the exact character with a caret,
|
||
because "Expecting ',' delimiter: char 74" is not something a model can act on
|
||
and a pointed-at fragment is.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from typing import Any, Callable
|
||
|
||
# Ordered, cheapest and safest first. Each entry is (name, transform); after
|
||
# each one the payload is re-parsed, so the first repair that works wins and
|
||
# nothing more aggressive gets applied than the input actually needed.
|
||
_REPAIRS: tuple[tuple[str, Callable[[str], str]], ...] = (
|
||
(
|
||
"stripped a markdown code fence",
|
||
lambda text: re.sub(r"^\s*```(?:json)?\s*|\s*```\s*$", "", text),
|
||
),
|
||
(
|
||
"replaced smart quotes with straight ones",
|
||
lambda text: text.translate(str.maketrans({"“": '"', "”": '"',
|
||
"‘": "'", "’": "'"})),
|
||
),
|
||
(
|
||
"removed a stray quote after a bare value",
|
||
# {"recursive":false"} -> {"recursive":false}
|
||
lambda text: re.sub(
|
||
r"(:\s*(?:true|false|null|-?\d+(?:\.\d+)?))\s*\"(\s*[,}\]])", r"\1\2", text),
|
||
),
|
||
(
|
||
"removed a trailing comma",
|
||
lambda text: re.sub(r",(\s*[}\]])", r"\1", text),
|
||
),
|
||
(
|
||
"converted Python literals (True/False/None) to JSON",
|
||
lambda text: re.sub(r"(:\s*)(True|False|None)\b",
|
||
lambda m: m.group(1) + {"True": "true", "False": "false",
|
||
"None": "null"}[m.group(2)], text),
|
||
),
|
||
(
|
||
"converted single-quoted strings to double-quoted",
|
||
lambda text: re.sub(r"'([^'\"]*)'", r'"\1"', text),
|
||
),
|
||
)
|
||
|
||
|
||
class RelayJsonError(ValueError):
|
||
"""Unparseable even after repairs — carries a pointed-at fragment."""
|
||
|
||
|
||
def loads(payload: str) -> tuple[Any, list[str]]:
|
||
"""Parse *payload*, repairing common model mistakes.
|
||
|
||
Returns (data, repairs) where *repairs* names what had to be fixed — empty
|
||
when the input was already valid. Raises RelayJsonError with a caret at the
|
||
offending character when nothing works."""
|
||
text = str(payload or "").strip()
|
||
if not text:
|
||
raise RelayJsonError("empty payload")
|
||
try:
|
||
return json.loads(text), []
|
||
except json.JSONDecodeError as exc:
|
||
# Bound to a plain name: Python deletes the `as` target at the end of
|
||
# the except block, so referring to it further down would raise
|
||
# UnboundLocalError instead of reporting the parse failure.
|
||
first_error = exc
|
||
|
||
applied: list[str] = []
|
||
candidate = text
|
||
for name, repair in _REPAIRS:
|
||
repaired = repair(candidate)
|
||
if repaired == candidate:
|
||
continue
|
||
candidate = repaired
|
||
applied.append(name)
|
||
try:
|
||
return json.loads(candidate), applied
|
||
except json.JSONDecodeError:
|
||
continue # keep going: a payload can be broken in more than one way
|
||
|
||
raise RelayJsonError(point_at(text, first_error))
|
||
|
||
|
||
def point_at(text: str, error: json.JSONDecodeError, width: int = 28) -> str:
|
||
"""Show the failure where it happened.
|
||
|
||
A model can act on "you wrote `false\"}` here"; it cannot act on
|
||
"Expecting ',' delimiter: line 1 column 75"."""
|
||
position = max(0, min(len(text), getattr(error, "pos", 0)))
|
||
start = max(0, position - width)
|
||
end = min(len(text), position + width)
|
||
fragment = text[start:end]
|
||
caret = " " * (position - start) + "^"
|
||
lead = "…" if start > 0 else ""
|
||
tail = "…" if end < len(text) else ""
|
||
return (
|
||
f"{error.msg} at character {position}:\n"
|
||
f" {lead}{fragment}{tail}\n"
|
||
f" {' ' * len(lead)}{caret}"
|
||
)
|
||
|
||
|
||
def repair_note(repairs: list[str]) -> str:
|
||
"""The line appended to a tool result when repairs were needed.
|
||
|
||
Phrased as feedback rather than an apology: the model is the author of the
|
||
broken JSON and is the one who can stop sending it."""
|
||
if not repairs:
|
||
return ""
|
||
return (
|
||
" (note: your JSON was malformed — I " + "; ".join(repairs)
|
||
+ " and ran it anyway. Send valid single-line JSON next time.)"
|
||
)
|