diff options
| -rw-r--r-- | assistant.py | 96 |
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") |
