diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 13:31:42 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 13:31:42 +0200 |
| commit | 460cd4456790092ea42a8848bb0ea517a1174fca (patch) | |
| tree | 41d91debdce4ff52d1334f56091dfe812b009f85 | |
| parent | 0185271050ff66d7ce14aa83a990035fc62e73e4 (diff) | |
| download | desktop-assistant-460cd4456790092ea42a8848bb0ea517a1174fca.tar.gz desktop-assistant-460cd4456790092ea42a8848bb0ea517a1174fca.zip | |
Final review: startup probe, error messages, refusal exit, docs
A startup probe that failed with anything but OSError (llama-server not in
router mode, an empty model list) crashed the daemon at boot, against its
own comment; it is caught like the per-request failures now. llama-server's
reasons reach the client: an HTTP error carries its body and an error chunk
mid-stream its message, where the client used to see 'choices'. A daemon
that refuses to start, or fails before serving, exits 1 instead of 0.
assistant.sh no longer mistakes a --test run for the daemon.
AGENTS.md said every section from Hardware to Kokoro was voice-only, which
included findings the daemon still applies; it now names the voice ones.
It also claimed a stopped reply emits nothing after done, while a late ctx
can. The spec lists every error message, the example config no longer
shows a non-default temperature as the default, and the updates tool stops
telling the model not to read lists aloud. The spec is marked implemented.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | AGENTS.md | 15 | ||||
| -rw-r--r-- | assistant.py | 28 | ||||
| -rwxr-xr-x | assistant.sh | 2 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-10-16-text-daemon-design.md | 8 | ||||
| -rw-r--r-- | examples/config.toml | 2 | ||||
| -rwxr-xr-x | examples/tools/updates.py | 4 | ||||
| -rw-r--r-- | examples/tools/updates.toml | 2 |
7 files changed, 44 insertions, 17 deletions
@@ -10,9 +10,14 @@ canvas (`desktop/assistant/` in the quickshell repo, SUPER+Home). Spec: `docs/superpowers/specs/2026-10-16-text-daemon-design.md`. The voice version is the tag `voice-v1`: push-to-talk on the mic's mute -button, whisper large-v3-turbo, Kokoro. The sections from "Hardware" -through "Kokoro-82M" describe that version and its measurements. They are -kept because a return to voice starts from them. +button, whisper large-v3-turbo, Kokoro. These sections describe only that +version, and are kept because a return to voice starts from them: +"Hardware", "whisper.cpp", and under Findings "Models", "STT backends", +"Live mic with whisper-stream", both "Push-to-talk" sections, +"large-v3-turbo", "The loop", "Overlay", "Barge-in", "Wake word trial", +"TTS" and "Kokoro-82M". The rest still describes the daemon: the tool, +context-size and tool-call-history findings are rules `assistant.py` +applies today. Tools (2026-09-26): read-only shell-command TOML files in `~/.config/desktop-assistant/tools/`; `config.toml` there overrides the @@ -35,7 +40,9 @@ socket (ask) ──> llama-server (LLM, tools) ──> socket (think, token, too - **Concurrency:** one reader thread per client, one reply thread per question. One lock guards the conversation, the turn counter and the database. `say()` checks the turn under it before each emit, and stop - bumps the turn under it, so a stopped reply cannot emit after its `done`. + bumps the turn under it, so a stopped reply cannot send a token or tool + event after its `done`. A late `ctx` can still arrive: `ask()` emits it + outside the lock, and its numbers are true regardless. - **One daemon:** `serve()` probe-connects first and refuses to start over a socket that answers; `main()` removes the socket only if it served it. - **Commands** are type-checked before they run (ids must be `int`: SQLite would match `"1"` to row 1 while the Python comparison with the current id would not) and run under a try, so a failing command answers `error` instead of killing the client's reader. - **Client sockets** have a 2 s timeout, not non-blocking mode: a large diff --git a/assistant.py b/assistant.py index 937700a..db527a5 100644 --- a/assistant.py +++ b/assistant.py @@ -488,11 +488,20 @@ def ask(messages, calls, tools=None): body["temperature"] = CFG["temperature"] req = urllib.request.Request(CFG["llm"] + "/chat/completions", json.dumps(body).encode(), {"Content-Type": "application/json"}) - with urllib.request.urlopen(req, timeout=300) as r: + try: + r = urllib.request.urlopen(req, timeout=300) + except urllib.error.HTTPError as e: + # The body says why (a full context, a bad request); the status alone does not. + raise RuntimeError(f"llama-server {e.code}: " + f"{e.read().decode(errors='replace')[:300]}") from None + with r: for line in r: if not line.startswith(b"data: ") or line.strip() == b"data: [DONE]": continue chunk = json.loads(line[6:]) + 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"): # How full the model's context is: history, tool results and # tool definitions all count. @@ -626,7 +635,7 @@ def main(): DB = db_open(DB_PATH) try: context_size(llm_model()) # so the first hello has it - except OSError: + except Exception: pass # llama-server down: asked again per request serve() served = True @@ -635,6 +644,8 @@ def main(): signal.pause() except (KeyboardInterrupt, SystemExit) as e: log(f"exit: {type(e).__name__} {e}".rstrip()) + if not served: + raise SystemExit(1) # refused or failed before serving: not a clean stop except BaseException: log("crashed:\n" + traceback.format_exc().rstrip()) raise @@ -744,12 +755,13 @@ def test(): self.wfile.write(json.dumps(body).encode()) def do_POST(self): - self.rfile.read(int(self.headers["Content-Length"])) + script["body"] = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) self.send_response(200) self.end_headers() try: for delta in script["replies"].pop(0): - self.wfile.write(b"data: " + json.dumps({"choices": [{"delta": delta}]}) + self.wfile.write(delta.encode() if isinstance(delta, str) else + b"data: " + json.dumps({"choices": [{"delta": delta}]}) .encode() + b"\n\n") self.wfile.flush() time.sleep(script["delay"]) @@ -801,12 +813,20 @@ def test(): ("state", "thinking"), ("token", "It is "), ("token", "noon."), ("done", None)], got assert until("state")[-1]["state"] == "idle" + assert script["body"]["chat_template_kwargs"]["enable_thinking"] is True conv = cur["id"] assert load_conv(DB, conv)[1] == [{"user": "what time is it", "reply": "It is noon.", "tools": ["now"]}] # The tool result went to the model but history keeps a placeholder. assert load_conv(DB, conv)[0][0][2] == {"role": "tool", "tool_call_id": "c1", "content": "(shown to the user at the time)"} + # An error chunk mid-stream reaches the client with its message. + script["replies"] = [['data: {"error": {"message": "context full"}}\n\n']] + CFG["thinking"] = False + send(type="ask", text="too much") + assert until("error")[-1]["message"] == "context full" + assert script["body"]["chat_template_kwargs"]["enable_thinking"] is False + CFG["thinking"] = True # Stop mid-stream: no token after done, nothing saved. script["replies"] = [[{"content": f"w{i} "} for i in range(50)]] script["delay"] = 0.02 diff --git a/assistant.sh b/assistant.sh index 315829e..30216dd 100755 --- a/assistant.sh +++ b/assistant.sh @@ -27,7 +27,7 @@ set -eu dir=$(cd "$(dirname "$0")" && pwd) log="${XDG_RUNTIME_DIR:?not in a login session}/desktop-assistant.log" -pat='^\S*python3 (-u )?\S*assistant\.py' +pat='^\S*python3 (-u )?\S*assistant\.py$' # not a --test run kill_wait() { pkill -u "$USER" -f "$1" || return 0 diff --git a/docs/superpowers/specs/2026-10-16-text-daemon-design.md b/docs/superpowers/specs/2026-10-16-text-daemon-design.md index 40a73c8..58d0240 100644 --- a/docs/superpowers/specs/2026-10-16-text-daemon-design.md +++ b/docs/superpowers/specs/2026-10-16-text-daemon-design.md @@ -1,6 +1,6 @@ # Text assistant daemon: design -Date: 2026-10-16. Status: approved in brainstorming, not implemented. +Date: 2026-10-16. Status: implemented (2026-10-16), live-checked. Supersedes `2026-09-26-overlay-design.md` (the voice overlay protocol). ## Goal @@ -85,7 +85,7 @@ Inbound: | `{"type":"search","q":"..."}` | answers `results` | | `{"type":"delete","id":N}` | deletes N, answers `conversations`; deleting the current one acts as `new` | -Ids in `open` and `delete` must be integers; a bad id, a non-string `text` or `q`, or a command that raises answers `error` (`bad id`, `bad text`, `bad query`, `<type> failed`) and leaves the conversation untouched. +Ids in `open` and `delete` must be integers; a bad id, a non-string `text` or `q`, or a command that raises answers `error` (`bad id`, `bad text`, `bad query`, `<type> failed`) and leaves the conversation untouched. Other errors: `bad command` (not JSON or no `type`), `unknown command <type>`, `no such conversation`, `line too long` (over 1 MB without a newline), and llama-server's own message for an HTTP error or an error chunk. An `ask` with empty text is ignored. An `ask` while a reply is streaming stops the old reply first. An unknown or malformed line is logged and answered with `error`; it never closes the @@ -141,7 +141,7 @@ delete trigger cleans the index. question. - One `turns` row is inserted per `done`, and `updated` is bumped. A crash loses at most the reply in flight. -- `open` loads the last 10 turns' `messages` as the model history and all +- `open` loads every turn's `messages` as the model history (a reply sends the last 10) and all turns' `user`, `reply`, `tools` for the canvas. Tool results come back as the stored placeholder. - `search` wraps the user's text as an FTS5 phrase (double quotes doubled inside) with a prefix star, so FTS syntax in the query cannot break or widen it and a half-typed word still matches. Snippets mark matches with `**`. Results are @@ -161,7 +161,7 @@ tail, and exits 1 when the daemon is down. `python3 assistant.py --test`, extended: -- the existing socketpair check, now in both directions; +- a real temporary Unix socket, in both directions; - a fake LLM stream (a local HTTP server on an ephemeral port) that sends reasoning, content and a tool call, checking the `think`, `token`, `tool`, `done` order on the socket; diff --git a/examples/config.toml b/examples/config.toml index 94bc1e6..85fd846 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -6,7 +6,7 @@ # llm = "http://localhost:8181/v1" # llama-server, OpenAI API # system = "You are a desktop assistant. ..." # tool_rounds = 3 # tool round trips per question -# temperature = 0.6 # per request; unset = the model preset's +# temperature = 0.6 # example; the default is unset: the model preset's # thinking = true # false skips the model's reasoning (faster first word) # Added to every tool's environment. Personal values live here, not in diff --git a/examples/tools/updates.py b/examples/tools/updates.py index 57cc61a..6308bef 100755 --- a/examples/tools/updates.py +++ b/examples/tools/updates.py @@ -102,7 +102,7 @@ def by_action(todo): def summary(newest, applied, n, todo): - # Spoken: counts only, names just for the short lists. + # Counts only, names just for the short lists. if not todo: return f"Up to date with the ChangeLog of {newest}." by = by_action(todo) @@ -117,7 +117,7 @@ def summary(newest, applied, n, todo): for a, label in (("Added", "New"), ("Removed", "Removed")): if by[a]: out.append(f"{label}: " + ", ".join(by[a]) + ".") - out.append("Offer a written recap (report = true); do not read package lists aloud.") + out.append("Offer a written recap (report = true); do not list every package.") return "\n".join(out) diff --git a/examples/tools/updates.toml b/examples/tools/updates.toml index 86eac1d..3b05817 100644 --- a/examples/tools/updates.toml +++ b/examples/tools/updates.toml @@ -1,4 +1,4 @@ -description = "Pending Slackware -current upgrades: ChangeLog entries not yet installed. Summary gives counts (upgraded, rebuilt, new, removed, security fixes) and a pending kernel version bump; say the counts, never read package lists aloud. Can instead open a written markdown recap in the user's editor, or give one package's ChangeLog notes. Read-only." +description = "Pending Slackware -current upgrades: ChangeLog entries not yet installed. Summary gives counts (upgraded, rebuilt, new, removed, security fixes) and a pending kernel version bump; give the counts, not whole package lists. Can instead open a written markdown recap in the user's editor, or give one package's ChangeLog notes. Read-only." command = ["updates.py", "{package}", "{report}"] timeout = 10 |
