wren leaves unnamed areas to the assistant; assistant targets take a closed set of values

wren drops a call on an area the command does not name (other than the
user's or, with none named, the living room): "switch on the appliances"
turned on the bathroom light at 0.97. The assistant's `values` setting gives
a str target its only values, explains them in the tool and confirms in the
word set for each ("Canopy open.").
This commit is contained in:
2026-10-22 10:56:20 +02:00
parent f1452f3119
commit bf63b3d6d4
5 changed files with 118 additions and 22 deletions
+45 -18
View File
@@ -74,9 +74,11 @@ few words, such as "Sorry, I can't do that."
_END = re.compile(r"(?<=[.!?])\s+")
def phrase(port: str, value: Any) -> str:
"""A value set, as the reply says it: "kitchen light off"."""
def phrase(port: str, value: Any, word: str | None = None) -> str:
"""A value set, as the reply says it: "kitchen light off", "canopy open"."""
label = port.replace("_", " ")
if word:
return f"{label} {word}"
if isinstance(value, bool):
return f"{label} {'on' if value else 'off'}"
return (
@@ -120,6 +122,13 @@ class AssistantNode(Node):
"loaded for 30 minutes on Ollama; drop what a provider refuses."
),
)
values: dict[str, dict[str, str]] = Field(
default_factory=dict,
description=(
"For a str target, by port: the only values it takes, each with "
'what the reply says for it, as {"UP": "open", "DOWN": "closed"}.'
),
)
__slots__ = ("cfg", "_listening", "_lock", "_loop")
@@ -183,22 +192,31 @@ class AssistantNode(Node):
if spec.name and spec.port not in (REPLY, ACTIONS)
]
def _tools(self) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": spec.port,
"description": f"Set the {spec.port.replace('_', ' ')}.",
"parameters": {
"type": "object",
"properties": {"value": _SCHEMAS[spec.dtype]},
"required": ["value"],
},
def _tool(self, spec: MessageSpec) -> dict[str, Any]:
label = spec.port.replace("_", " ")
schema: dict[str, Any] = _SCHEMAS[spec.dtype]
description = f"Set the {label}."
words = self.cfg.values.get(spec.port)
if words:
# A code such as UP means nothing to the model until it is told.
schema = {**schema, "enum": list(words)}
meant = ", ".join(f'"{v}" means {label} {w}' for v, w in words.items())
description = f"Set the {label}: {meant}."
return {
"type": "function",
"function": {
"name": spec.port,
"description": description,
"parameters": {
"type": "object",
"properties": {"value": schema},
"required": ["value"],
},
}
for spec in self._targets()
]
},
}
def _tools(self) -> list[dict[str, Any]]:
return [self._tool(spec) for spec in self._targets()]
def _system(self, context: dict[str, Any]) -> str:
"""The instructions, then what the inputs read and the targets hold."""
@@ -328,7 +346,11 @@ class AssistantNode(Node):
def _confirm(self, actions: list[dict[str, Any]]) -> str:
"""The values set, as one sentence: "Kitchen light off, setpoint to 22."."""
ports = {spec.name: spec.port for spec in self._targets()}
text = ", ".join(phrase(ports[a["message"]], a["value"]) for a in actions)
said = []
for action in actions:
port, value = ports[action["message"]], action["value"]
said.append(phrase(port, value, self.cfg.values.get(port, {}).get(value)))
text = ", ".join(said)
return text[:1].upper() + text[1:] + "." if text else ""
def _caller(self, actions: list[dict[str, Any]]) -> agent.ToolCaller:
@@ -346,6 +368,11 @@ class AssistantNode(Node):
spec.check(value)
except TypeError as exc:
return json.dumps({"error": str(exc)})
words = self.cfg.values.get(name)
if words and value not in words:
# A device takes its own codes only: a motor would run "OPEN"
# as a direction it has never heard of.
return json.dumps({"error": f"{name} is one of {', '.join(words)}."})
await asyncio.to_thread(self.emit, {name: value})
actions.append(
{
+17 -2
View File
@@ -197,6 +197,19 @@ def _plain(command: str) -> str:
return re.sub(r"[.,!?;:]+(?=\s|$)", "", command).strip().lower()
def _asked(house: Any, command: str) -> set[str]:
"""The areas a call may land on: those the command names, or else the
user's and the living room.
That is how wren picks the areas it shows as the command's own. The few
it adds besides are there to be told apart, and a call on one of them is a
word the model never learned landing on a neighbour: "switch on the
appliances" turned on the bathroom light at 0.97.
"""
named = {a for a in house.areas if re.search(rf"\b{re.escape(a)}\b", command)}
return named or {house.user_area, "living room"}
def _text(call: Any) -> str:
"""The call as the chat shows it, less the device it names."""
args = ", ".join(f"{k}={v}" for k, v in call.args.items() if k != "id")
@@ -359,11 +372,13 @@ class WrenNode(Node):
wren = _wren()
specs = {spec.port: spec for spec in self.output_ports}
house = self._house(wren, specs, presence, outdoor, dark)
command = _plain(prompt)
asked = _asked(house, command)
# One BLAS thread answers in about half the time for a model this
# small. Scoped to the call, so numpy elsewhere in the process keeps
# its threads.
with threadpool_limits(1):
calls, confidence = wren.act(_plain(prompt), house)
calls, confidence = wren.act(command, house)
confidence = round(float(confidence), 3)
write = self.cfg.apply and confidence >= self.cfg.min_confidence
writes: dict[str, Any] = {}
@@ -372,7 +387,7 @@ class WrenNode(Node):
for call in calls:
area, _, kind = str(call.args.get("id", "")).partition(":")
ports = self.devices.get((area, kind), [])
if call.tool.split(".")[0] != _TOOLS.get(kind):
if call.tool.split(".")[0] != _TOOLS.get(kind) or area not in asked:
ports = []
hits = [
(specs[port], value)
+36
View File
@@ -138,3 +138,39 @@ def test_no_provider_or_one_that_does_not_answer_is_said(
_ask(node, "what is the weather tomorrow")
assert said.pop() == {"reply": UNAVAILABLE}
assert node._health == "degraded"
def test_a_target_with_values_takes_only_those_and_is_confirmed_in_words(
monkeypatch: pytest.MonkeyPatch,
):
seen: list[dict[str, Any]] = []
log: list[Any] = []
async def completion(**kwargs: Any) -> AsyncIterator[str | dict[str, Any]]:
seen.append(kwargs)
calls = [_call("canopy", "OPEN"), _call("canopy", "UP")]
yield {"role": "assistant", "content": None, "tool_calls": calls}
monkeypatch.setattr(ai_config, "load", lambda: PROVIDER)
monkeypatch.setattr(client, "stream_completion", completion)
monkeypatch.setattr(AssistantNode, "emit", lambda self, values: log.append(values))
node = AssistantNode(
requires=[MessageSpec(name="unhandled", port="prompt", dtype=DType.STR)],
provides=[
MessageSpec(name="canopy_manual", port="canopy", dtype=DType.STR),
MessageSpec(name="reply", dtype=DType.STR),
],
params={"values": {"canopy": {"UP": "open", "DOWN": "closed"}}},
)
node.assign_flow("voice", "assistant")
Pipeline([node])
_ask(node, "open the canopy")
[tool] = seen[0]["tools"]
assert tool["function"]["parameters"]["properties"]["value"]["enum"] == [
"UP",
"DOWN",
]
assert '"UP" means canopy open' in tool["function"]["description"]
assert log == [{"canopy": "UP"}, {"reply": "Canopy open."}]
+12
View File
@@ -204,3 +204,15 @@ def test_a_reply_says_what_was_applied_and_the_rest_is_unhandled(fake_wren):
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}
def test_a_call_on_an_area_the_command_does_not_name_is_not_applied(fake_wren):
"""A word the model never learned lands on a neighbour it was shown."""
node = _node(("bathroom_light", DType.BOOL), apply=True)
fake_wren.answer = ([FakeCall("lights.on", {"id": "bathroom:light"})], 0.97)
out = node.execute({"house.command": "switch on the appliances"})
assert out is not None
assert "house.bathroom_light" not in out
assert [a["applied"] for a in out["house.actions"]] == [False]
+8 -2
View File
@@ -408,7 +408,10 @@ Redis, and not otherwise.
The model sees every area, wired or not, each with at least a light, so "the
bathroom light" is not taken for a light that is wired elsewhere. Such a call
is reported but not applied, and goes on to `unhandled`. The command is read
is reported but not applied, and goes on to `unhandled`. So is a call on an
area the command does not name, unless it is the user's area or, with no area
named, the living room: "switch on the appliances" is a word the model never
learned, and it lands on whichever light it was shown. The command is read
in lower case without sentence marks, as the model was trained.
!!! note "What the current model cannot do"
@@ -440,13 +443,16 @@ on what is wired to it.
|---|---|---|
| `model` | empty | the model to ask; empty asks the instance's |
| `extra_body` | `{"temperature": 0, "reasoning_effort": "none", "keep_alive": "30m"}` | added to every request as it is |
| `values` | empty | for a `str` target, by port: the only values it takes and what the reply says for each, as `{"canopy": {"UP": "open", "DOWN": "closed"}}` |
It asks the provider set under **Settings → Assistant**. As for Wren, the
wiring is the allowlist: the model is offered the targets and nothing else,
never the tools the chat's assistant uses. Its instructions list the context
inputs and what each target holds now. A target is set the moment the model
calls its tool. The reply then names what was set ("Living room light on,
living room setpoint to 22."), so the model is asked once per request.
living room setpoint to 22."), so the model is asked once per request. A
target with `values` is offered only those, each explained to the model, and
confirmed in its word ("Canopy open."). Any other value is refused.
Every request ends in a reply: