run_turn takes answer_tools: false ends the turn once the tools have
answered. The assistant node uses it and says what was set ("Kitchen light
off, living room light off."), which saves one model round, about 1.1 s on
the P100. A turn without a call still streams the model's own words.
rebuild-frontend keeps the running stack's FRONTEND_HOST, so the token
issuer no longer flips between the LAN address and app.localhost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljdk7nDaguh9Uhi3zhcSjn
141 lines
4.8 KiB
Python
141 lines
4.8 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"
|