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.").
177 lines
6.1 KiB
Python
177 lines
6.1 KiB
Python
"""The assistant node: the wiring is its allowlist, and it speaks as it writes."""
|
|
|
|
import asyncio
|
|
import json
|
|
from collections.abc import AsyncIterator
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from fluksio.ai import client
|
|
from fluksio.ai import config as ai_config
|
|
from fluksio.flow.messages import DType, MessageSpec
|
|
from fluksio.flow.nodes.assistant import UNAVAILABLE, AssistantNode, cut
|
|
from fluksio.flow.pipeline import Pipeline
|
|
|
|
PROVIDER = ai_config.AIConfig(api_base="http://llm:11434/v1", api_key="k", model="m")
|
|
|
|
|
|
def _call(name: str, value: Any) -> dict[str, Any]:
|
|
arguments = json.dumps({"value": value})
|
|
return {"id": name, "function": {"name": name, "arguments": arguments}}
|
|
|
|
|
|
def _node() -> AssistantNode:
|
|
node = AssistantNode(
|
|
requires=[
|
|
MessageSpec(name="unhandled", port="prompt", dtype=DType.STR),
|
|
MessageSpec(name="outdoor", port="outside_temperature", dtype=DType.FLOAT),
|
|
],
|
|
provides=[
|
|
MessageSpec(name="kitchen_light", dtype=DType.BOOL),
|
|
MessageSpec(
|
|
name="setpoint", port="living_room_setpoint", dtype=DType.FLOAT
|
|
),
|
|
MessageSpec(name="reply", dtype=DType.STR),
|
|
MessageSpec(name="actions", dtype=DType.LIST),
|
|
],
|
|
)
|
|
node.assign_flow("voice", "assistant")
|
|
Pipeline([node])
|
|
return node
|
|
|
|
|
|
def _ask(node: AssistantNode, prompt: str) -> dict[str, Any] | None:
|
|
async def scenario() -> dict[str, Any] | None:
|
|
await node.start()
|
|
inputs = {"voice.unhandled": prompt, "voice.outdoor": 7.5}
|
|
return await asyncio.to_thread(node.execute, inputs)
|
|
|
|
return asyncio.run(scenario())
|
|
|
|
|
|
def test_a_sentence_is_cut_once_it_ends():
|
|
assert cut("Done. It is 7.5 degr") == (["Done."], "It is 7.5 degr")
|
|
assert cut("Lights off! Anything else? ") == (["Lights off!", "Anything else?"], "")
|
|
|
|
|
|
def test_only_wired_targets_are_tools_a_change_is_confirmed_and_an_answer_streamed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
log: list[Any] = []
|
|
seen: list[dict[str, Any]] = []
|
|
rounds = iter(
|
|
[
|
|
# The model reaches for a tool nothing is wired to, as well.
|
|
[
|
|
_call("kitchen_light", False),
|
|
_call("delete_flow", "demo"),
|
|
_call("living_room_setpoint", 22),
|
|
],
|
|
["It is 7.5", " degrees outside. Wear a ", "jacket."],
|
|
]
|
|
)
|
|
|
|
async def completion(**kwargs: Any) -> AsyncIterator[str | dict[str, Any]]:
|
|
seen.append(kwargs)
|
|
step = next(rounds)
|
|
if isinstance(step[0], dict):
|
|
yield {"role": "assistant", "content": None, "tool_calls": step}
|
|
return
|
|
for piece in step:
|
|
log.append(piece)
|
|
yield piece
|
|
yield {"role": "assistant", "content": "".join(step)}
|
|
|
|
monkeypatch.setattr(ai_config, "load", lambda: PROVIDER)
|
|
monkeypatch.setattr(client, "stream_completion", completion)
|
|
monkeypatch.setattr(AssistantNode, "emit", lambda self, values: log.append(values))
|
|
node = _node()
|
|
|
|
out = _ask(node, "turn off the kitchen light and make it warmer")
|
|
|
|
tools = {tool["function"]["name"] for tool in seen[0]["tools"]}
|
|
assert tools == {"kitchen_light", "living_room_setpoint"}
|
|
system = seen[0]["messages"][0]["content"]
|
|
assert "outside_temperature: 7.5" in system
|
|
assert seen[0]["extra"]["reasoning_effort"] == "none"
|
|
# Set as called, and confirmed without asking the model again.
|
|
assert len(seen) == 1
|
|
assert log == [
|
|
{"kitchen_light": False},
|
|
{"living_room_setpoint": 22.0},
|
|
{"reply": "Kitchen light off, living room setpoint to 22."},
|
|
]
|
|
assert out is not None
|
|
assert [a["message"] for a in out["voice.actions"]] == [
|
|
"voice.kitchen_light",
|
|
"voice.setpoint",
|
|
]
|
|
|
|
log.clear()
|
|
_ask(node, "what is it like outside")
|
|
# The first sentence is said before the second is written.
|
|
assert log == [
|
|
"It is 7.5",
|
|
" degrees outside. Wear a ",
|
|
{"reply": "It is 7.5 degrees outside."},
|
|
"jacket.",
|
|
{"reply": "Wear a jacket."},
|
|
]
|
|
|
|
|
|
def test_no_provider_or_one_that_does_not_answer_is_said(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
said: list[dict[str, Any]] = []
|
|
monkeypatch.setattr(AssistantNode, "emit", lambda self, values: said.append(values))
|
|
|
|
async def refused(**kwargs: Any) -> AsyncIterator[str | dict[str, Any]]:
|
|
raise httpx.ConnectError("refused")
|
|
yield ""
|
|
|
|
monkeypatch.setattr(client, "stream_completion", refused)
|
|
for provider in (None, PROVIDER):
|
|
monkeypatch.setattr(ai_config, "load", lambda provider=provider: provider)
|
|
node = _node()
|
|
_ask(node, "what is the weather tomorrow")
|
|
assert said.pop() == {"reply": UNAVAILABLE}
|
|
assert node._health == "degraded"
|
|
|
|
|
|
def test_a_target_with_values_takes_only_those_and_is_confirmed_in_words(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
seen: list[dict[str, Any]] = []
|
|
log: list[Any] = []
|
|
|
|
async def completion(**kwargs: Any) -> AsyncIterator[str | dict[str, Any]]:
|
|
seen.append(kwargs)
|
|
calls = [_call("canopy", "OPEN"), _call("canopy", "UP")]
|
|
yield {"role": "assistant", "content": None, "tool_calls": calls}
|
|
|
|
monkeypatch.setattr(ai_config, "load", lambda: PROVIDER)
|
|
monkeypatch.setattr(client, "stream_completion", completion)
|
|
monkeypatch.setattr(AssistantNode, "emit", lambda self, values: log.append(values))
|
|
node = AssistantNode(
|
|
requires=[MessageSpec(name="unhandled", port="prompt", dtype=DType.STR)],
|
|
provides=[
|
|
MessageSpec(name="canopy_manual", port="canopy", dtype=DType.STR),
|
|
MessageSpec(name="reply", dtype=DType.STR),
|
|
],
|
|
params={"values": {"canopy": {"UP": "open", "DOWN": "closed"}}},
|
|
)
|
|
node.assign_flow("voice", "assistant")
|
|
Pipeline([node])
|
|
|
|
_ask(node, "open the canopy")
|
|
|
|
[tool] = seen[0]["tools"]
|
|
assert tool["function"]["parameters"]["properties"]["value"]["enum"] == [
|
|
"UP",
|
|
"DOWN",
|
|
]
|
|
assert '"UP" means canopy open' in tool["function"]["description"]
|
|
assert log == [{"canopy": "UP"}, {"reply": "Canopy open."}]
|