Add relay_json module, update dialogue and file_ops, update local settings
This commit is contained in:
+13
-5
@@ -42,6 +42,8 @@ import json
|
||||
import re
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from . import relay_json
|
||||
|
||||
_PREFIXES = ("dialoguectl", "dialogue", "scene")
|
||||
|
||||
# ElevenLabs Text to Dialogue limits (docs, 2026-07): at most 10 distinct
|
||||
@@ -82,11 +84,14 @@ def parse(command: str) -> Optional[dict]:
|
||||
'dialoguectl needs a JSON argument, e.g. dialoguectl {"lines": '
|
||||
'[{"voice": "self", "text": "[cheerfully] hello"}]}'
|
||||
)
|
||||
# Same lenient parse as filectl (see relay_json): machine-written JSON
|
||||
# fails in a handful of repeatable ways, and a stray quote shouldn't cost
|
||||
# a turn — but the repair is reported back rather than hidden.
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except json.JSONDecodeError as exc:
|
||||
data, repairs = relay_json.loads(payload)
|
||||
except relay_json.RelayJsonError as exc:
|
||||
raise DialogueError(
|
||||
f"couldn't parse the JSON ({exc}). It must be one line of compact "
|
||||
f"couldn't parse the JSON — {exc}\nIt must be one line of compact "
|
||||
"JSON — put line breaks inside text as \\n, never as real newlines."
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
@@ -109,6 +114,8 @@ def parse(command: str) -> Optional[dict]:
|
||||
lines.append({"voice": voice, "text": text})
|
||||
|
||||
action = {"action": "dialogue", "lines": lines}
|
||||
if repairs:
|
||||
action["_repairs"] = repairs
|
||||
model = str(data.get("model") or data.get("model_id") or "").strip()
|
||||
if model:
|
||||
action["model"] = model
|
||||
@@ -211,9 +218,10 @@ def describe(action: dict, *, played: bool = True) -> str:
|
||||
"""The tool-result string handed back to the server."""
|
||||
lines = action.get("lines") or []
|
||||
voices = sorted({str(line.get("voice") or "self") for line in lines})
|
||||
note = relay_json.repair_note(action.get("_repairs") or [])
|
||||
if not played:
|
||||
return f"[dialogue] not played ({len(lines)} lines)"
|
||||
return f"[dialogue] not played ({len(lines)} lines){note}"
|
||||
return (
|
||||
f"[dialogue] played {len(lines)} line{'s' if len(lines) != 1 else ''} "
|
||||
f"in {len(voices)} voice{'s' if len(voices) != 1 else ''}: {', '.join(voices)}"
|
||||
f"in {len(voices)} voice{'s' if len(voices) != 1 else ''}: {', '.join(voices)}{note}"
|
||||
)
|
||||
|
||||
+26
-16
@@ -52,6 +52,8 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from . import relay_json
|
||||
|
||||
_PREFIXES = ("filectl", "file")
|
||||
|
||||
# Keeps a runaway read/write/list from blowing up the tool_result relay (and,
|
||||
@@ -95,17 +97,23 @@ def parse(command: str) -> Optional[dict]:
|
||||
if not rest or rest.lower() in ("help", "-h", "--help"):
|
||||
return {"action": "help"}
|
||||
|
||||
# Lenient on purpose — see relay_json. A stray quote in machine-written
|
||||
# JSON should not cost a turn, but the repair is reported back so the model
|
||||
# is told it sent something broken while it can still learn from it.
|
||||
try:
|
||||
payload = json.loads(rest)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise FileOpError(f"couldn't parse filectl JSON ({exc}); usage:\n{HELP}") from exc
|
||||
payload, repairs = relay_json.loads(rest)
|
||||
except relay_json.RelayJsonError as exc:
|
||||
raise FileOpError(f"couldn't parse filectl JSON — {exc}\nusage:\n{HELP}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise FileOpError(f"filectl payload must be a JSON object; usage:\n{HELP}")
|
||||
|
||||
op = str(payload.get("op") or "help").lower()
|
||||
# Carried on the action so execute() can tell the model what it got wrong;
|
||||
# a silent repair would fix today's call and guarantee tomorrow's.
|
||||
tag = {"_repairs": repairs} if repairs else {}
|
||||
|
||||
if op == "help":
|
||||
return {"action": "help"}
|
||||
return {"action": "help", **tag}
|
||||
|
||||
if op == "list":
|
||||
path = _required_str(payload, "path")
|
||||
@@ -114,7 +122,7 @@ def parse(command: str) -> Optional[dict]:
|
||||
raise FileOpError('"pattern" must be a non-empty string')
|
||||
return {
|
||||
"action": "list", "path": path,
|
||||
"pattern": pattern, "recursive": bool(payload.get("recursive")),
|
||||
"pattern": pattern, "recursive": bool(payload.get("recursive")), **tag,
|
||||
}
|
||||
|
||||
if op == "read":
|
||||
@@ -122,14 +130,14 @@ def parse(command: str) -> Optional[dict]:
|
||||
return {
|
||||
"action": "read", "path": path,
|
||||
"start": _line_number(payload.get("start"), "start"),
|
||||
"end": _line_number(payload.get("end"), "end"),
|
||||
"end": _line_number(payload.get("end"), "end"), **tag,
|
||||
}
|
||||
|
||||
if op == "write":
|
||||
path = _required_str(payload, "path")
|
||||
if payload.get("content") is None:
|
||||
raise FileOpError('write needs "content"')
|
||||
return {"action": "write", "path": path, "content": str(payload["content"])}
|
||||
return {"action": "write", "path": path, "content": str(payload["content"]), **tag}
|
||||
|
||||
if op == "edit":
|
||||
path = _required_str(payload, "path")
|
||||
@@ -138,7 +146,7 @@ def parse(command: str) -> Optional[dict]:
|
||||
old, new = str(payload["old"]), str(payload["new"])
|
||||
if old == new:
|
||||
raise FileOpError("old and new text are identical — nothing to edit")
|
||||
return {"action": "edit", "path": path, "old": old, "new": new}
|
||||
return {"action": "edit", "path": path, "old": old, "new": new, **tag}
|
||||
|
||||
raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}")
|
||||
|
||||
@@ -175,14 +183,16 @@ def execute(action: dict) -> str:
|
||||
if kind == "help":
|
||||
return HELP
|
||||
if kind == "list":
|
||||
return _do_list(action)
|
||||
if kind == "read":
|
||||
return _do_read(action)
|
||||
if kind == "write":
|
||||
return _do_write(action)
|
||||
if kind == "edit":
|
||||
return _do_edit(action)
|
||||
return "[filectl] ok"
|
||||
output = _do_list(action)
|
||||
elif kind == "read":
|
||||
output = _do_read(action)
|
||||
elif kind == "write":
|
||||
output = _do_write(action)
|
||||
elif kind == "edit":
|
||||
output = _do_edit(action)
|
||||
else:
|
||||
output = "[filectl] ok"
|
||||
return output + relay_json.repair_note(action.get("_repairs") or [])
|
||||
|
||||
|
||||
def _resolve(path_str: str) -> Path:
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""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.)"
|
||||
)
|
||||
Reference in New Issue
Block a user