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:
@@ -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"
|
||||
@@ -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": [
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user