aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 16:50:37 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 16:50:37 +0200
commit3576ca1c347bc9f02c5e65fc9b90095a4d309365 (patch)
tree652afb992fd82229554846140fa6279bd9c7bbe6
parentefe6bb69bb22d17501f513a7fc070fbf580ff024 (diff)
downloaddesktop-assistant-master.tar.gz
desktop-assistant-master.zip
write: always end with an outcome; empty replies and the meterHEADmaster
From review. A write ended by anything other than its own stop (an ask, a new conversation, another client's stop) sent only state idle, so the canvas waited for written or an error forever; interrupt() now sends error stopped with turn. A refused write carries turn too. An empty reply is refused instead of saved, a document made of several code blocks is no longer stripped as one fence, the write sizes its budget on the whole context since no history goes with it, and its usage no longer moves the chat's context meter. The file is written as UTF-8 whatever the locale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--assistant.py40
1 files changed, 28 insertions, 12 deletions
diff --git a/assistant.py b/assistant.py
index 4107092..9004243 100644
--- a/assistant.py
+++ b/assistant.py
@@ -522,6 +522,9 @@ def interrupt():
turn += 1
if writing["on"]: # a write has no pending turn to close
writing["on"] = False
+ # Whatever ended it (stop, an ask, a new conversation): the canvas
+ # waits for an outcome, so it gets one.
+ emit({"type": "error", "message": "stopped", "turn": True})
emit({"type": "state", "state": "idle"})
conn = inflight["conn"]
if conn is not None and conn.sock is not None:
@@ -566,7 +569,8 @@ def handle(raw):
and "/" not in cmd["name"])
else None)
if bad:
- emit({"type": "error", "message": bad})
+ # A refused write ends the canvas's wait for it, so it carries turn.
+ emit({"type": "error", "message": bad, **({"turn": True} if kind == "write" else {})})
return
try:
with lock:
@@ -693,7 +697,7 @@ def llm_model():
return model_inputs()[0]
-def ask(messages, calls, tools=None, mine=None, think=None):
+def ask(messages, calls, tools=None, mine=None, think=None, meter=True):
# Yields ("think", text) and ("token", text) as they stream; tool-call
# fragments go into calls. mine is the reply's turn: a stop shuts the
# connection from the other thread (interrupt), which ends the reading.
@@ -715,7 +719,7 @@ def ask(messages, calls, tools=None, mine=None, think=None):
return # stopped before the request went out
inflight["conn"] = conn
try:
- yield from stream(conn, u.path, body, calls)
+ yield from stream(conn, u.path, body, calls, meter)
except OSError:
if mine is not None and turn != mine:
return # the stop hung up under the read
@@ -727,7 +731,7 @@ def ask(messages, calls, tools=None, mine=None, think=None):
conn.close()
-def stream(conn, path, body, calls):
+def stream(conn, path, body, calls, meter=True):
conn.request("POST", path, json.dumps(body).encode(), {"Content-Type": "application/json"})
r = conn.getresponse()
if r.status >= 400:
@@ -748,7 +752,7 @@ def stream(conn, path, body, calls):
if "error" in chunk: # a failure mid-stream arrives as a chunk
err = chunk["error"]
raise RuntimeError(err.get("message", str(err)) if isinstance(err, dict) else str(err))
- if chunk.get("usage"):
+ if chunk.get("usage") and meter:
# How full the model's context is: history, tool results and
# tool definitions all count.
ctx["used"] = chunk["usage"]["prompt_tokens"]
@@ -801,7 +805,8 @@ def unfence(text):
# Models often wrap a whole document in one code fence; the file wants
# what is inside it.
lines = text.strip().split("\n")
- if len(lines) >= 2 and lines[0].startswith("```") and lines[-1].strip() == "```":
+ if (len(lines) >= 2 and lines[0].startswith("```") and lines[-1].strip() == "```"
+ and not any(l.startswith("```") for l in lines[1:-1])): # one fence, not two blocks
return "\n".join(lines[1:-1]) + "\n"
return text
@@ -811,8 +816,8 @@ def write(mine, files, prompt, d, name):
# no history in, nothing kept in the conversation, no thinking, no tools.
tmp = None
try:
- free = (ctx["max"] - ctx["used"]) // 2 * 3 if ctx["max"] else 24000
- blocks, refs, _ = attach(files, max(free, 2000))
+ # No history goes with a write, so half the whole context is its share.
+ blocks, refs, _ = attach(files, ctx["max"] // 2 * 3 if ctx["max"] else 24000)
model, inputs = model_inputs()
images = inputs is None or "image" in inputs
if refs and not images:
@@ -825,7 +830,8 @@ def write(mine, files, prompt, d, name):
body = "\n\n".join(blocks + [prompt])
msgs = [{"role": "user", "content": [{"type": "text", "text": body}] + refs if refs else body}]
text, shown = "", 0.0
- for kind, piece in ask(wire(msgs, images), {}, None, mine, think=False):
+ # meter=False: the chat's context meter is not about this request.
+ for kind, piece in ask(wire(msgs, images), {}, None, mine, think=False, meter=False):
if turn != mine:
return
if kind == "token":
@@ -835,9 +841,11 @@ def write(mine, files, prompt, d, name):
emit({"type": "progress", "chars": len(text)})
if turn != mine: # stopped: the hang-up ended the stream
return
+ if not text.strip():
+ raise Refused("the model gave an empty reply; nothing written")
os.makedirs(d, exist_ok=True)
tmp = os.path.join(d, f".write-{os.getpid()}-{mine}.tmp")
- with open(tmp, "w") as f:
+ with open(tmp, "w", encoding="utf-8") as f:
f.write(unfence(text))
with lock:
if turn != mine:
@@ -1439,7 +1447,9 @@ def test():
send(type="write", files=[pdf], prompt="As Markdown.", dir=fd, name="held.md")
until("state")
send(type="stop")
- assert until("state")[-1]["state"] == "idle"
+ seen = until("state")
+ assert seen[-1]["state"] == "idle"
+ assert {"type": "error", "message": "stopped", "turn": True} in seen # an outcome
time.sleep(0.3)
assert not os.path.exists(os.path.join(fd, "held.md"))
assert not [n for n in os.listdir(fd) if n.startswith(".write-")]
@@ -1447,7 +1457,13 @@ def test():
for bad in ({"dir": "relative", "name": "x.md"}, {"dir": fd, "name": "a/b.md"},
{"dir": fd, "name": ""}):
send(type="write", files=[pdf], prompt="p", **bad)
- assert until("error")[-1]["message"] == "bad write", bad
+ e = until("error")[-1]
+ assert e["message"] == "bad write" and e["turn"] is True, bad
+ assert unfence("```\na\n```\nmid\n```\nb\n```") == "```\na\n```\nmid\n```\nb\n```"
+ script["replies"] = [[{"content": " "}]]
+ send(type="write", files=[pdf], prompt="p", dir=fd, name="empty.md")
+ assert "empty reply" in until("error")[-1]["message"]
+ assert not os.path.exists(os.path.join(fd, "empty.md"))
# llama-server down: an error, not a dead thread.
CFG["llm"] = "http://127.0.0.1:9/v1"
send(type="ask", text="anyone?")