"""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_and_each_sentence_goes_out_as_written( 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), ], ["Kitchen light off", " and heating to 22. It is ", "7.5 degrees outside."], ] ) 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)) 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" answers = [m["content"] for m in seen[1]["messages"] if m["role"] == "tool"] assert "error" in json.loads(answers[1]) # Set as called, and the first sentence said before the second is written. assert log == [ {"kitchen_light": False}, {"living_room_setpoint": 22.0}, "Kitchen light off", " and heating to 22. It is ", {"reply": "Kitchen light off and heating to 22."}, "7.5 degrees outside.", {"reply": "It is 7.5 degrees outside."}, ] assert out is not None assert [a["message"] for a in out["voice.actions"]] == [ "voice.kitchen_light", "voice.setpoint", ] 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"