wren drops a call on an area the command does not name (other than the
user's or, with none named, the living room): "switch on the appliances"
turned on the bathroom light at 0.97. The assistant's `values` setting gives
a str target its only values, explains them in the tool and confirms in the
word set for each ("Canopy open.").
393 lines
15 KiB
Python
393 lines
15 KiB
Python
"""Assistant: a request answered by the instance's language model.
|
|
|
|
The request arrives on ``prompt``, typically what a wren node could not do.
|
|
Every output but ``reply`` and ``actions`` is a target the model may set,
|
|
offered as one tool named after its port and typed by its message, so the
|
|
wiring is the allowlist, as for wren: the MCP authoring tools are never
|
|
offered. Every other input is read as context ("outside_temperature: 7.5").
|
|
|
|
An answer goes out on ``reply`` a sentence at a time while the model writes
|
|
it, so a ``tts`` node speaks the first before the last is written. A change is
|
|
confirmed from the values set ("Kitchen light off."), without asking the model
|
|
a second time.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import dataclasses
|
|
import json
|
|
import logging
|
|
import re
|
|
import threading
|
|
from collections.abc import Coroutine, Iterable
|
|
from concurrent.futures import Future
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import httpx
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from fluksio.ai import agent, client
|
|
from fluksio.ai import config as ai_config
|
|
from fluksio.flow.messages import DType, MessageSpec
|
|
from fluksio.flow.nodes.base import Node
|
|
|
|
if TYPE_CHECKING:
|
|
from fastapi import FastAPI
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
REPLY = "reply"
|
|
ACTIONS = "actions"
|
|
#: An optional input: true warms the provider while the request is spoken.
|
|
LISTENING = "listening"
|
|
|
|
#: Said when there is no provider, or it does not answer.
|
|
UNAVAILABLE = "The assistant isn't available right now."
|
|
#: Said when the model ends a turn without a word or a change.
|
|
CANT = "Sorry, I can't do that."
|
|
|
|
#: A server that is not there is given up on after a few seconds, while a
|
|
#: model that has to be loaded first takes about ten.
|
|
TIMEOUT = httpx.Timeout(15.0, connect=3.0)
|
|
|
|
#: A target's type, as its tool's parameter.
|
|
_SCHEMAS: dict[DType, dict[str, str]] = {
|
|
DType.BOOL: {"type": "boolean"},
|
|
DType.INT: {"type": "integer"},
|
|
DType.FLOAT: {"type": "number"},
|
|
DType.STR: {"type": "string"},
|
|
}
|
|
|
|
_SYSTEM = """You are the voice of a home: someone spoke to a panel on the wall, \
|
|
and you answer out loud.
|
|
|
|
- To change something, call its tool without a word: the change is confirmed \
|
|
for you. The tools are all you can change.
|
|
- If no tool can do what was asked, or you did not understand it, say so in a \
|
|
few words, such as "Sorry, I can't do that."
|
|
- Answer questions from the readings below.
|
|
- Speak plainly: one or two short sentences, no lists, no markdown, no emoji.
|
|
"""
|
|
|
|
_END = re.compile(r"(?<=[.!?])\s+")
|
|
|
|
|
|
def phrase(port: str, value: Any, word: str | None = None) -> str:
|
|
"""A value set, as the reply says it: "kitchen light off", "canopy open"."""
|
|
label = port.replace("_", " ")
|
|
if word:
|
|
return f"{label} {word}"
|
|
if isinstance(value, bool):
|
|
return f"{label} {'on' if value else 'off'}"
|
|
return (
|
|
f"{label} to {value:g}" if isinstance(value, float) else f"{label} to {value}"
|
|
)
|
|
|
|
|
|
def cut(text: str) -> tuple[list[str], str]:
|
|
"""The sentences ``text`` has finished, and the one still being written."""
|
|
*done, rest = _END.split(text)
|
|
return [s.strip() for s in done if s.strip()], rest
|
|
|
|
|
|
class AssistantNode(Node):
|
|
"""Answer the request on ``prompt``, acting only on the targets wired to it.
|
|
|
|
Inputs: ``prompt`` (the request, which wakes the node), ``listening``
|
|
(optional; turning true opens the connection and loads the model while
|
|
the request is still spoken) and any other, read as context.
|
|
|
|
Outputs: ``reply``, the answer a sentence at a time, ``actions``, a list
|
|
of records, one per value set, and any number of targets, each a tool.
|
|
When the provider is not set up or does not answer, the reply says so.
|
|
"""
|
|
|
|
class Params(BaseModel):
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
model: str = Field(
|
|
default="", description="The model to ask. Empty asks the instance's."
|
|
)
|
|
extra_body: dict[str, Any] = Field(
|
|
default_factory=lambda: {
|
|
"temperature": 0,
|
|
"reasoning_effort": "none",
|
|
"keep_alive": "30m",
|
|
},
|
|
description=(
|
|
"Added to every request as it is. The default answers a request "
|
|
"the same way each time, turns thinking off and keeps the model "
|
|
"loaded for 30 minutes on Ollama; drop what a provider refuses."
|
|
),
|
|
)
|
|
values: dict[str, dict[str, str]] = Field(
|
|
default_factory=dict,
|
|
description=(
|
|
"For a str target, by port: the only values it takes, each with "
|
|
'what the reply says for it, as {"UP": "open", "DOWN": "closed"}.'
|
|
),
|
|
)
|
|
|
|
__slots__ = ("cfg", "_listening", "_lock", "_loop")
|
|
|
|
# A tool call is a command to a device, as wren's are.
|
|
idempotent = False
|
|
|
|
def __init__(
|
|
self,
|
|
requires: MessageSpec | Iterable[MessageSpec] = (),
|
|
provides: MessageSpec | Iterable[MessageSpec] = (),
|
|
params: dict[str, Any] | None = None,
|
|
name: str | None = None,
|
|
):
|
|
self.cfg = self.Params.model_validate(params or {})
|
|
self._lock = threading.Lock()
|
|
#: What ``listening`` held at the last run, to tell its change apart.
|
|
self._listening: bool | None = None
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
inputs = self._normalize_ports(requires)
|
|
outputs = self._normalize_ports(provides)
|
|
typed = {"prompt": DType.STR, LISTENING: DType.BOOL}
|
|
for spec in inputs:
|
|
if spec.port in typed and spec.dtype is not typed[spec.port]:
|
|
raise ValueError(f"'{spec.port}' is a {typed[spec.port].value}.")
|
|
if not any(spec.port == "prompt" for spec in inputs):
|
|
raise ValueError("assistant needs a 'prompt' input: the request.")
|
|
for spec in outputs:
|
|
if spec.port == REPLY and spec.dtype is not DType.STR:
|
|
raise ValueError(f"'{REPLY}' is a str.")
|
|
if spec.port == ACTIONS and (
|
|
spec.dtype is not DType.LIST or spec.item_dtype is not DType.RECORD
|
|
):
|
|
raise ValueError(f"'{ACTIONS}' is a list of records.")
|
|
if spec.port not in (REPLY, ACTIONS) and spec.dtype not in _SCHEMAS:
|
|
raise ValueError(
|
|
f"'{spec.port}' is {spec.dtype.value}; a target is bool, int, "
|
|
"float or str."
|
|
)
|
|
super().__init__(
|
|
self._answer,
|
|
requires=[
|
|
spec.model_copy(update={"trigger": spec.port in ("prompt", LISTENING)})
|
|
for spec in inputs
|
|
],
|
|
provides=outputs,
|
|
params=params,
|
|
name=name or "assistant",
|
|
)
|
|
|
|
async def start(self, app: FastAPI | None = None) -> None:
|
|
self._loop = asyncio.get_running_loop()
|
|
names = [spec.name for spec in self.input_ports if spec.port == LISTENING]
|
|
if names and self._pipeline is not None:
|
|
held = await asyncio.to_thread(self._pipeline.state.get_present, names)
|
|
self._listening = held.get(names[0])
|
|
|
|
def _targets(self) -> list[MessageSpec]:
|
|
return [
|
|
spec
|
|
for spec in self.output_ports
|
|
if spec.name and spec.port not in (REPLY, ACTIONS)
|
|
]
|
|
|
|
def _tool(self, spec: MessageSpec) -> dict[str, Any]:
|
|
label = spec.port.replace("_", " ")
|
|
schema: dict[str, Any] = _SCHEMAS[spec.dtype]
|
|
description = f"Set the {label}."
|
|
words = self.cfg.values.get(spec.port)
|
|
if words:
|
|
# A code such as UP means nothing to the model until it is told.
|
|
schema = {**schema, "enum": list(words)}
|
|
meant = ", ".join(f'"{v}" means {label} {w}' for v, w in words.items())
|
|
description = f"Set the {label}: {meant}."
|
|
return {
|
|
"type": "function",
|
|
"function": {
|
|
"name": spec.port,
|
|
"description": description,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"value": schema},
|
|
"required": ["value"],
|
|
},
|
|
},
|
|
}
|
|
|
|
def _tools(self) -> list[dict[str, Any]]:
|
|
return [self._tool(spec) for spec in self._targets()]
|
|
|
|
def _system(self, context: dict[str, Any]) -> str:
|
|
"""The instructions, then what the inputs read and the targets hold."""
|
|
targets = self._targets()
|
|
held = (
|
|
self._pipeline.state.get_present([spec.name for spec in targets])
|
|
if self._pipeline is not None
|
|
else {}
|
|
)
|
|
lines = [_SYSTEM, "Readings:"]
|
|
lines += [f"{port}: {json.dumps(value)}" for port, value in context.items()]
|
|
lines += ["", "What the tools set, now:"]
|
|
lines += [
|
|
f"{spec.port}: {json.dumps(held[spec.name])}"
|
|
for spec in targets
|
|
if spec.name in held
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
def _config(self) -> ai_config.AIConfig | None:
|
|
config = ai_config.load()
|
|
if config is None or not config.ready:
|
|
return None
|
|
return dataclasses.replace(config, model=self.cfg.model or config.model)
|
|
|
|
def _call(self, work: Coroutine[Any, Any, Any]) -> Future[Any]:
|
|
"""Run on the engine's loop, where the provider's connection is kept."""
|
|
if self._loop is None:
|
|
work.close()
|
|
raise RuntimeError(f"{self.name} only runs in a live flow")
|
|
return asyncio.run_coroutine_threadsafe(work, self._loop)
|
|
|
|
def _answer(
|
|
self,
|
|
params: dict[str, Any],
|
|
prompt: str = "",
|
|
listening: bool | None = None,
|
|
**context: Any,
|
|
) -> dict[str, Any] | None:
|
|
with self._lock:
|
|
was, self._listening = self._listening, listening
|
|
config = self._config()
|
|
if listening != was:
|
|
# Not a request: the device started or stopped listening.
|
|
if listening and config is not None:
|
|
self._call(self._warm(config))
|
|
return None
|
|
if not prompt.strip():
|
|
return None
|
|
actions: list[dict[str, Any]] = []
|
|
problem = self._call(
|
|
self._turn(config, prompt, self._system(context), actions)
|
|
).result()
|
|
if problem:
|
|
self.report_health("degraded", problem)
|
|
elif self._health != "ok":
|
|
self.report_health("ok")
|
|
wired = {spec.port for spec in self.output_ports if spec.name}
|
|
return {ACTIONS: actions} if ACTIONS in wired else None
|
|
|
|
async def _warm(self, config: ai_config.AIConfig) -> None:
|
|
"""A request for one token: the connection opens and the model loads.
|
|
|
|
Nothing more is worth sending ahead: a model with a sliding window,
|
|
such as Gemma, reads the whole prompt again on every request anyway.
|
|
"""
|
|
with contextlib.suppress(Exception):
|
|
async for _ in client.stream_completion(
|
|
api_base=config.api_base,
|
|
api_key=config.api_key,
|
|
model=config.model,
|
|
messages=[{"role": "user", "content": "Hello."}],
|
|
tools=[],
|
|
timeout=60.0,
|
|
extra={**self.cfg.extra_body, "max_tokens": 1},
|
|
):
|
|
pass
|
|
|
|
async def _turn(
|
|
self,
|
|
config: ai_config.AIConfig | None,
|
|
prompt: str,
|
|
system: str,
|
|
actions: list[dict[str, Any]],
|
|
) -> str:
|
|
"""Ask, act and say. Returns what went wrong, or an empty string."""
|
|
said = False
|
|
|
|
async def say(text: str) -> None:
|
|
nonlocal said
|
|
said = True
|
|
await asyncio.to_thread(self._say, text)
|
|
|
|
if config is None:
|
|
await say(UNAVAILABLE)
|
|
return "no AI provider is set up"
|
|
rest, problem = "", ""
|
|
try:
|
|
async for event in agent.run_turn(
|
|
config,
|
|
[],
|
|
prompt,
|
|
tools=self._tools(),
|
|
system=system,
|
|
call_tool=self._caller(actions),
|
|
extra=self.cfg.extra_body,
|
|
timeout=TIMEOUT,
|
|
answer_tools=False,
|
|
):
|
|
if event["type"] == "delta":
|
|
done, rest = cut(rest + event["content"])
|
|
for sentence in done:
|
|
await say(sentence)
|
|
elif event["type"] == "message" and rest.strip():
|
|
await say(rest.strip())
|
|
rest = ""
|
|
elif event["type"] == "error":
|
|
problem = str(event["detail"])
|
|
except Exception as exc: # noqa: BLE001 - said, then reported as the health
|
|
problem = f"{type(exc).__name__}: {exc}"
|
|
if problem:
|
|
logger.warning("'%s' got no answer: %s", self.id, problem)
|
|
if not said:
|
|
await say(UNAVAILABLE if problem else self._confirm(actions) or CANT)
|
|
return problem
|
|
|
|
def _confirm(self, actions: list[dict[str, Any]]) -> str:
|
|
"""The values set, as one sentence: "Kitchen light off, setpoint to 22."."""
|
|
ports = {spec.name: spec.port for spec in self._targets()}
|
|
said = []
|
|
for action in actions:
|
|
port, value = ports[action["message"]], action["value"]
|
|
said.append(phrase(port, value, self.cfg.values.get(port, {}).get(value)))
|
|
text = ", ".join(said)
|
|
return text[:1].upper() + text[1:] + "." if text else ""
|
|
|
|
def _caller(self, actions: list[dict[str, Any]]) -> agent.ToolCaller:
|
|
"""Set the target a call names, and record it in ``actions``."""
|
|
specs = {spec.port: spec for spec in self._targets()}
|
|
|
|
async def call(name: str, arguments: dict[str, Any]) -> str:
|
|
spec = specs.get(name)
|
|
if spec is None:
|
|
return json.dumps({"error": f"There is no tool named {name}."})
|
|
value = arguments.get("value")
|
|
if spec.dtype is DType.FLOAT and type(value) is int:
|
|
value = float(value)
|
|
try:
|
|
spec.check(value)
|
|
except TypeError as exc:
|
|
return json.dumps({"error": str(exc)})
|
|
words = self.cfg.values.get(name)
|
|
if words and value not in words:
|
|
# A device takes its own codes only: a motor would run "OPEN"
|
|
# as a direction it has never heard of.
|
|
return json.dumps({"error": f"{name} is one of {', '.join(words)}."})
|
|
await asyncio.to_thread(self.emit, {name: value})
|
|
actions.append(
|
|
{
|
|
"message": spec.name,
|
|
"value": value,
|
|
"applied": True,
|
|
"delay_s": None,
|
|
"call": f"{name}({json.dumps(value)})",
|
|
}
|
|
)
|
|
return json.dumps({name: value})
|
|
|
|
return call
|
|
|
|
def _say(self, text: str) -> None:
|
|
if any(spec.port == REPLY and spec.name for spec in self.output_ports):
|
|
self.emit({REPLY: text})
|