run_turn takes its tools, system prompt and tool caller; the chat passes the MCP tools as before. The assistant node offers one tool per wired target, reads its other inputs as context, streams its reply a sentence at a time, warms the provider on `listening` and says so when the provider is not there. Completions share the per-server HTTP client. wren replies when nothing reads `unhandled`, stt shows a note when nothing was heard. /ai/control lists one conversation per entry node with the messages to watch.
134 lines
4.7 KiB
Python
134 lines
4.7 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_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"
|