summaryrefslogtreecommitdiffstats
path: root/assistant.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-26 08:00:55 +0200
committerDanilo M. <danix@danix.xyz>2026-09-26 08:00:55 +0200
commitde179d288a1c67bc77756141b16e95c364e941a1 (patch)
treeeb245a116d93c7d51467cc2543aad0858768e158 /assistant.py
parentea2b7bc716f0d642ce1405b22ac6e4fc4c37c578 (diff)
downloaddesktop-assistant-de179d288a1c67bc77756141b16e95c364e941a1.tar.gz
desktop-assistant-de179d288a1c67bc77756141b16e95c364e941a1.zip
Let the model call tools, with a spoken note while slow ones run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'assistant.py')
-rw-r--r--assistant.py96
1 files changed, 79 insertions, 17 deletions
diff --git a/assistant.py b/assistant.py
index 11333dc..16e6133 100644
--- a/assistant.py
+++ b/assistant.py
@@ -25,6 +25,7 @@ import glob
import json
import os
import queue
+import random
import re
import signal
import subprocess
@@ -209,6 +210,26 @@ def run_tool(tools, name, arguments, d=TOOLS_DIR):
return out.strip()[:2000]
+def collect_calls(calls, fragments):
+ # A streamed tool call arrives in pieces keyed by index: id and name in
+ # the first, the JSON arguments spread over the rest.
+ for f in fragments:
+ c = calls.setdefault(f["index"], {"id": "", "name": "", "arguments": ""})
+ fn = f.get("function") or {}
+ c["id"] = f.get("id") or c["id"]
+ c["name"] = fn.get("name") or c["name"]
+ c["arguments"] += fn.get("arguments") or ""
+
+
+def announcement(tools, calls, lang):
+ # Something to hear while a slow tool runs: there is no screen.
+ for c in calls.values():
+ phrases = tools.get(c["name"], {}).get("announce", {}).get(lang)
+ if phrases:
+ return random.choice(phrases)
+ return None
+
+
def llm_model():
# The router keeps one model resident; asking for another would swap it
# out under other clients, so use whatever is not unloaded.
@@ -218,9 +239,12 @@ def llm_model():
models[0]["id"])
-def ask(messages):
+def ask(messages, calls, tools=None):
+ # Yields reply text as it streams; tool-call fragments go into calls.
body = {"model": llm_model(), "messages": messages, "stream": True,
"chat_template_kwargs": {"enable_thinking": False}}
+ if tools:
+ body["tools"] = tools
req = urllib.request.Request(CFG["llm"] + "/chat/completions", json.dumps(body).encode(),
{"Content-Type": "application/json"})
with urllib.request.urlopen(req) as r:
@@ -228,8 +252,12 @@ def ask(messages):
if not line.startswith(b"data: ") or line.strip() == b"data: [DONE]":
continue
choices = json.loads(line[6:])["choices"]
- if choices and choices[0]["delta"].get("content"):
- yield choices[0]["delta"]["content"]
+ if not choices:
+ continue
+ delta = choices[0]["delta"]
+ collect_calls(calls, delta.get("tool_calls") or [])
+ if delta.get("content"):
+ yield delta["content"]
def speaker(q):
@@ -304,23 +332,45 @@ def reply(history, q):
return
log(f"you ({lang}, {time.perf_counter() - t:.2f}s): {text}")
history.append({"role": "user", "content": text})
- t, said, buf = time.perf_counter(), [], ""
- for delta in ask([{"role": "system", "content": CFG["system"]}] + history[-20:]):
- # ponytail: last 20 messages, summarise if long sessions need memory
- buf += delta
- done, buf = split_sentences(buf)
- if not said and done:
- log(f"first sentence after {time.perf_counter() - t:.2f}s")
- for s in done:
- said.append(s)
- lang = lang_of(s, lang)
- q.put((re.sub(r"[*_#`]", "", s), lang))
- if buf.strip():
+ # ponytail: last 20 messages, summarise if long sessions need memory
+ msgs = [{"role": "system", "content": CFG["system"]}] + history[-20:]
+ t, said, voice, announced = time.perf_counter(), [], lang, False
+
+ def say(s):
+ nonlocal voice
if not said:
log(f"first sentence after {time.perf_counter() - t:.2f}s")
- said.append(buf.strip())
- q.put((re.sub(r"[*_#`]", "", buf.strip()), lang_of(buf, lang)))
+ said.append(s)
+ voice = lang_of(s, voice)
+ q.put((re.sub(r"[*_#`]", "", s), voice))
+
+ for rnd in range(CFG["tool_rounds"] + 1):
+ calls, buf = {}, ""
+ # The last round offers no tools, which forces a spoken answer.
+ for delta in ask(msgs, calls, TOOL_SCHEMA if rnd < CFG["tool_rounds"] else None):
+ buf += delta
+ done, buf = split_sentences(buf)
+ for s in done:
+ say(s)
+ if buf.strip():
+ say(buf.strip())
+ if not calls:
+ break
+ msgs.append({"role": "assistant", "content": "", "tool_calls": [
+ {"id": c["id"], "type": "function",
+ "function": {"name": c["name"], "arguments": c["arguments"]}}
+ for c in calls.values()]})
+ phrase = None if announced else announcement(TOOLS, calls, lang)
+ if phrase:
+ # The question's language: the reply has no text yet to follow.
+ q.put((phrase, lang))
+ announced = True
+ for c in calls.values():
+ result = run_tool(TOOLS, c["name"], c["arguments"])
+ log(f"tool {c['name']}({c['arguments']}): {result[:120]!r}")
+ msgs.append({"role": "tool", "tool_call_id": c["id"], "content": result})
q.put(None)
+ # Tool calls stay inside the turn: history keeps only what was said.
history.append({"role": "assistant", "content": " ".join(said)})
log("assistant:", " ".join(said))
@@ -411,6 +461,18 @@ def test():
assert run_tool(tools, "nope", "{}", d) == "error: no tool named nope"
assert run_tool(tools, "echo", "{bad", d) == "error: arguments are not valid JSON"
assert run_tool(tools, "echo", "[1]", d) == "error: arguments are not valid JSON"
+ calls = {}
+ collect_calls(calls, [{"index": 0, "id": "c1", "type": "function",
+ "function": {"name": "weather", "arguments": "{"}}])
+ for piece in ['"place', '":', '"', "Rome", '"', "}"]:
+ collect_calls(calls, [{"index": 0, "function": {"arguments": piece}}])
+ collect_calls(calls, [{"index": 1, "id": "c2", "function": {"name": "now", "arguments": "{}"}}])
+ assert calls == {0: {"id": "c1", "name": "weather", "arguments": '{"place":"Rome"}'},
+ 1: {"id": "c2", "name": "now", "arguments": "{}"}}
+ tools = {"now": {}, "search": {"announce": {"it": ["Cerco."], "en": ["Searching."]}}}
+ assert announcement(tools, {0: {"name": "now"}}, "it") is None
+ assert announcement(tools, {0: {"name": "now"}, 1: {"name": "search"}}, "en") == "Searching."
+ assert announcement(tools, {0: {"name": "unknown"}}, "it") is None
print("ok")