Assistant node, and the Control tab as one conversation (openpanel V5)

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.
This commit is contained in:
Melvin Strobl
2026-10-19 23:23:25 +02:00
parent 4df7bdb057
commit a286cf4ceb
21 changed files with 937 additions and 184 deletions
+133
View File
@@ -0,0 +1,133 @@
"""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"
+5
View File
@@ -105,6 +105,11 @@ FIXTURES: dict[str, dict] = {
"requires": [MessageSpec(name="prompt", dtype=DType.STR)],
"provides": [MessageSpec(name="kitchen_light", dtype=DType.BOOL)],
},
"assistant": {
"params": {"model": "gemma4:12b"},
"requires": [MessageSpec(name="unhandled", port="prompt", dtype=DType.STR)],
"provides": [MessageSpec(name="kitchen_light", dtype=DType.BOOL)],
},
"stt": {
"params": {},
"requires": [
+12 -4
View File
@@ -10,7 +10,8 @@ from typing import Any
import pytest
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes.wren import WrenNode, _value
from fluksio.flow.nodes.base import Node
from fluksio.flow.nodes.wren import CANT, WrenNode, _value
from fluksio.flow.pipeline import Pipeline
@@ -184,8 +185,15 @@ def test_a_reply_says_what_was_applied_and_the_rest_is_unhandled(fake_wren):
assert out == {"house.kitchen_light": True, "house.reply": "Kitchen light on."}
# Nothing wired for the call, or nothing to call at all.
# Nothing wired for the call, or nothing to call at all. Nothing reads
# what is left, so the node says so itself.
asked = "what is the weather tomorrow"
for calls in ([FakeCall("lights.on", {"id": "garage:light"})], []):
fake_wren.answer = (calls, 0.99)
out = node.execute({"house.command": "what is the weather tomorrow"})
assert out == {"house.unhandled": "what is the weather tomorrow"}
out = node.execute({"house.command": asked})
assert out == {"house.unhandled": asked, "house.reply": CANT}
# An assistant reading it answers instead.
unhandled = MessageSpec(name="house.unhandled", port="unhandled", dtype=DType.STR)
Pipeline([node, Node(lambda params, unhandled: None, requires=[unhandled])])
assert node.execute({"house.command": asked}) == {"house.unhandled": asked}