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:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."}]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user