Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions custom_components/jev/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
CONF_ALLOW_WHOLE_HOME,
CONF_DAILY_TOKEN_BUDGET,
CONF_FALLBACK_AGENT,
CONF_LLM_TOOLS,
CONF_MIN_CONFIDENCE,
CONF_MODEL,
CONF_PRICE_PER_MILLION,
Expand Down Expand Up @@ -399,6 +400,10 @@ async def async_step_init(
CONF_ALLOW_WHOLE_HOME,
default=options.get(CONF_ALLOW_WHOLE_HOME, False),
): bool,
vol.Optional(
CONF_LLM_TOOLS,
default=options.get(CONF_LLM_TOOLS, False),
): bool,
}
)
return self.async_show_form(step_id="init", data_schema=schema)
4 changes: 4 additions & 0 deletions custom_components/jev/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@
CONF_MIN_CONFIDENCE: Final = "min_confidence"
CONF_ALLOW_WHOLE_HOME: Final = "allow_whole_home"

# --- Tools for other LLM agents ---

CONF_LLM_TOOLS: Final = "llm_tools"

# Below this, the router hands the sentence to the fallback agent rather than
# guessing. 0.6 is a starting point and not a calibrated figure: TypeSafe publishes
# no calibration evidence for confidence, so treat it as an ordering and measure it
Expand Down
187 changes: 187 additions & 0 deletions custom_components/jev/llm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""Jev's two judgements as tools for an LLM conversation agent.

An LLM agent reads the house through the Assist API and answers in prose. What it
cannot give is a probability it has been calibrated to: asked "is the washing
machine done", it says yes or no. These tools hand that one judgement to Jev and
return the number, so the agent can say how sure it is, or not act at all.

The tools are off unless the entry's options turn them on. Every tool's schema
goes into the prompt of every Assist LLM agent on every turn, which the user pays
for at that agent's provider whether a tool is called or not.

Each call goes through the matching action, so the daily budget, the error
messages and the usage sensors are the actions' own.
"""

from __future__ import annotations

from typing import Any, override

import voluptuous as vol
from homeassistant.components.homeassistant.exposed_entities import (
async_should_expose,
)
from homeassistant.components.llm import LLMTools
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.llm import LLM_API_ASSIST, LLMContext, Tool, ToolInput
from homeassistant.util.json import JsonObjectType

from .const import (
ATTR_CONFIG_ENTRY,
CONF_INSTRUCTIONS,
CONF_LLM_TOOLS,
CONF_OPTIONS,
CONF_STATE_TEMPLATE,
DOMAIN,
MAX_CONVERSATION_ENTITIES,
SERVICE_CHOICE,
SERVICE_NOUL,
)

_QUESTION = "question"
_FACTS = "facts"
_ACCOUNT = "account"

_QUESTION_DESCRIPTION = "The question, in plain words."
_FACTS_DESCRIPTION = (
"Anything the question depends on that is not an entity state, such as what "
"the user said. Optional."
)


class _JevTool(Tool):
"""One Jev action, judged against what Assist may see."""

action: str

def __init__(self, entries: dict[str, str], extra: dict[Any, Any]) -> None:
# entries maps an entry title to its id. With two entries the model picks
# one, because each has its own key and its own budget.
self._entries = entries
fields: dict[Any, Any] = {
vol.Required(_QUESTION, description=_QUESTION_DESCRIPTION): str,
**extra,
vol.Optional(_FACTS, description=_FACTS_DESCRIPTION): str,
}
if len(entries) > 1:
fields[
vol.Required(_ACCOUNT, description="Which Jev account pays for this.")
] = vol.In(sorted(entries))
self.parameters = vol.Schema(fields)

def _data(
self, hass: HomeAssistant, args: dict[str, Any], llm_context: LLMContext
) -> dict[str, Any]:
account = args.get(_ACCOUNT) or next(iter(self._entries))
data: dict[str, Any] = {
CONF_INSTRUCTIONS: args[_QUESTION],
ATTR_CONFIG_ENTRY: self._entries[account],
ATTR_ENTITY_ID: _exposed(hass, llm_context),
}
if facts := args.get(_FACTS):
# A mapping, because the action renders a string state as a template,
# and a template can read entities that are not exposed to Assist.
data[CONF_STATE_TEMPLATE] = {_FACTS: facts}
return data

async def _call(
self, hass: HomeAssistant, data: dict[str, Any], llm_context: LLMContext
) -> dict[str, Any]:
response = await hass.services.async_call(
DOMAIN,
self.action,
data,
blocking=True,
context=llm_context.context,
return_response=True,
)
assert response is not None
return dict(response)


class NoulTool(_JevTool):
name = f"{DOMAIN}__noul"
description = (
"Ask Jev how likely a yes/no statement about the home is, judged from the "
"states of the entities exposed to Assist. Returns the probability that the "
"answer is yes, from 0 to 1. Use it for a judgement, such as whether a "
"machine is done or whether anyone is likely home, not for a state you can "
"read directly."
)
action = SERVICE_NOUL

def __init__(self, entries: dict[str, str]) -> None:
super().__init__(entries, {})

@override
async def async_call(
self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext
) -> JsonObjectType:
args = self.parameters(tool_input.tool_args)
result = await self._call(hass, self._data(hass, args, llm_context), llm_context)
return {"probability_yes": result["noul"]}


class ChoiceTool(_JevTool):
name = f"{DOMAIN}__choice"
description = (
"Ask Jev which of a set of options best describes the home, judged from the "
"states of the entities exposed to Assist. Returns the chosen option, the "
"probability of every option, and a confidence from 0 to 1."
)
action = SERVICE_CHOICE

def __init__(self, entries: dict[str, str]) -> None:
super().__init__(
entries,
{
vol.Required(CONF_OPTIONS, description="Two or more options."): vol.All(
[str], vol.Length(min=2)
)
},
)

@override
async def async_call(
self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext
) -> JsonObjectType:
args = self.parameters(tool_input.tool_args)
data = self._data(hass, args, llm_context) | {CONF_OPTIONS: args[CONF_OPTIONS]}
result = await self._call(hass, data, llm_context)
return {
"choice": result["choice"],
"probabilities": result["probabilities"],
"confidence": result["confidence"],
}


def _exposed(hass: HomeAssistant, llm_context: LLMContext) -> list[str]:
"""The entities this assistant may see, capped as the voice agent caps them.

Jev judges from what the user exposed to Assist and nothing wider, the same
list the LLM agent itself reads.
"""
exposed = sorted(
state.entity_id
for state in hass.states.async_all()
if async_should_expose(hass, llm_context.assistant, state.entity_id)
)
return exposed[:MAX_CONVERSATION_ENTITIES]


@callback
def async_get_tools(
hass: HomeAssistant, llm_context: LLMContext, api_id: str
) -> LLMTools | None:
"""The tools, for each loaded entry whose options turn them on."""
if api_id != LLM_API_ASSIST:
return None
entries = {
entry.title: entry.entry_id
for entry in hass.config_entries.async_loaded_entries(DOMAIN)
if entry.options.get(CONF_LLM_TOOLS, False)
}
if not entries:
return None
return LLMTools(tools=[NoulTool(entries), ChoiceTool(entries)])
6 changes: 4 additions & 2 deletions custom_components/jev/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,16 @@
"price_per_million": "Price per million input tokens, in USD",
"fallback_agent": "Fall back to this agent",
"min_confidence": "Act only above this confidence (0 to 1)",
"allow_whole_home": "Allow commands that name no room or device"
"allow_whole_home": "Allow commands that name no room or device",
"llm_tools": "Offer Jev as a tool to other LLM agents"
},
"data_description": {
"daily_token_budget": "Counted from midnight in the Home Assistant time zone. 0 means no limit.",
"price_per_million": "Sets the estimated cost sensor and the cost in the question preview. Nothing that is sent depends on it.",
"fallback_agent": "Receives the whole sentence when Jev is not sure what to do.",
"min_confidence": "Below this, Jev does nothing and passes the command to the fallback agent.",
"allow_whole_home": "Turning everything off is always allowed, with or without this."
"allow_whole_home": "Turning everything off is always allowed, with or without this.",
"llm_tools": "Adds two tools to the Assist API, one for yes/no questions and one for picking an option. Each LLM agent that uses Assist gets their descriptions in every prompt, which costs tokens at that agent's provider. Each call also counts against the budget above."
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions custom_components/jev/translations/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,16 @@
"price_per_million": "Cena za milion vstupních tokenů v USD",
"fallback_agent": "Záložní agent",
"min_confidence": "Jednat jen nad touto jistotou (0 až 1)",
"allow_whole_home": "Povolit příkazy, které neuvádějí místnost ani zařízení"
"allow_whole_home": "Povolit příkazy, které neuvádějí místnost ani zařízení",
"llm_tools": "Nabídnout Jev jako nástroj ostatním LLM agentům"
},
"data_description": {
"daily_token_budget": "Počítá se od půlnoci v časovém pásmu Home Assistantu. 0 znamená bez limitu.",
"price_per_million": "Určuje senzor odhadované ceny a cenu v náhledu otázky. Nic z toho, co se odesílá, na tom nezávisí.",
"fallback_agent": "Dostane celou větu, když si Jev není jistý, co udělat.",
"min_confidence": "Pod touto hodnotou Jev nic neudělá a předá příkaz záložnímu agentovi.",
"allow_whole_home": "Vypnout všechno je dovoleno vždy, s touto volbou i bez ní."
"allow_whole_home": "Vypnout všechno je dovoleno vždy, s touto volbou i bez ní.",
"llm_tools": "Přidá do API Assist dva nástroje, jeden pro otázky ano/ne a jeden pro výběr možnosti. Každý LLM agent, který používá Assist, dostane jejich popisy v každém promptu, což stojí tokeny u poskytovatele tohoto agenta. Každé volání se také počítá do rozpočtu výše."
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions custom_components/jev/translations/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,16 @@
"price_per_million": "Pris pr. million input-tokens, i USD",
"fallback_agent": "Fald tilbage til denne agent",
"min_confidence": "Handl kun over denne konfidens (0 til 1)",
"allow_whole_home": "Tillad kommandoer, der ikke nævner et rum eller en enhed"
"allow_whole_home": "Tillad kommandoer, der ikke nævner et rum eller en enhed",
"llm_tools": "Tilbyd Jev som værktøj til andre LLM-agenter"
},
"data_description": {
"daily_token_budget": "Tælles fra midnat i Home Assistants tidszone. 0 betyder ingen grænse.",
"price_per_million": "Bestemmer sensoren for anslået pris og prisen i forhåndsvisningen af spørgsmålet. Intet af det, der sendes, afhænger af den.",
"fallback_agent": "Modtager hele sætningen, når Jev ikke er sikker på, hvad der skal gøres.",
"min_confidence": "Under denne værdi gør Jev intet og sender kommandoen videre til reserveagenten.",
"allow_whole_home": "At slukke alt er altid tilladt, med eller uden denne indstilling."
"allow_whole_home": "At slukke alt er altid tilladt, med eller uden denne indstilling.",
"llm_tools": "Tilføjer to værktøjer til Assist-API'et, et til ja/nej-spørgsmål og et til at vælge en mulighed. Hver LLM-agent, der bruger Assist, får deres beskrivelser i hver prompt, hvilket koster tokens hos den agents udbyder. Hvert kald tæller også med i budgettet ovenfor."
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions custom_components/jev/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,16 @@
"price_per_million": "Preis pro Million Eingabe-Tokens, in USD",
"fallback_agent": "Auf diesen Agenten zurückfallen",
"min_confidence": "Nur oberhalb dieser Konfidenz handeln (0 bis 1)",
"allow_whole_home": "Befehle erlauben, die keinen Raum und kein Gerät nennen"
"allow_whole_home": "Befehle erlauben, die keinen Raum und kein Gerät nennen",
"llm_tools": "Jev anderen LLM-Agenten als Werkzeug anbieten"
},
"data_description": {
"daily_token_budget": "Gezählt ab Mitternacht in der Zeitzone von Home Assistant. 0 bedeutet kein Limit.",
"price_per_million": "Bestimmt den Sensor für die geschätzten Kosten und die Kosten in der Vorschau einer Frage. Nichts, was gesendet wird, hängt davon ab.",
"fallback_agent": "Bekommt den ganzen Satz, wenn Jev nicht sicher ist, was zu tun ist.",
"min_confidence": "Darunter tut Jev nichts und gibt den Befehl an den Ersatz-Agenten weiter.",
"allow_whole_home": "Alles ausschalten ist immer erlaubt, mit oder ohne diese Option."
"allow_whole_home": "Alles ausschalten ist immer erlaubt, mit oder ohne diese Option.",
"llm_tools": "Fügt der Assist-API zwei Werkzeuge hinzu, eines für Ja/Nein-Fragen und eines für die Wahl einer Option. Jeder LLM-Agent, der Assist nutzt, erhält ihre Beschreibungen in jedem Prompt, was beim Anbieter dieses Agenten Tokens kostet. Jeder Aufruf zählt außerdem zum Budget oben."
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions custom_components/jev/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,16 @@
"price_per_million": "Price per million input tokens, in USD",
"fallback_agent": "Fall back to this agent",
"min_confidence": "Act only above this confidence (0 to 1)",
"allow_whole_home": "Allow commands that name no room or device"
"allow_whole_home": "Allow commands that name no room or device",
"llm_tools": "Offer Jev as a tool to other LLM agents"
},
"data_description": {
"daily_token_budget": "Counted from midnight in the Home Assistant time zone. 0 means no limit.",
"price_per_million": "Sets the estimated cost sensor and the cost in the question preview. Nothing that is sent depends on it.",
"fallback_agent": "Receives the whole sentence when Jev is not sure what to do.",
"min_confidence": "Below this, Jev does nothing and passes the command to the fallback agent.",
"allow_whole_home": "Turning everything off is always allowed, with or without this."
"allow_whole_home": "Turning everything off is always allowed, with or without this.",
"llm_tools": "Adds two tools to the Assist API, one for yes/no questions and one for picking an option. Each LLM agent that uses Assist gets their descriptions in every prompt, which costs tokens at that agent's provider. Each call also counts against the budget above."
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions custom_components/jev/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,16 @@
"price_per_million": "Precio por millón de tokens de entrada, en USD",
"fallback_agent": "Recurrir a este agente",
"min_confidence": "Actuar solo por encima de esta confianza (de 0 a 1)",
"allow_whole_home": "Permitir órdenes que no nombren ninguna habitación ni dispositivo"
"allow_whole_home": "Permitir órdenes que no nombren ninguna habitación ni dispositivo",
"llm_tools": "Ofrecer Jev como herramienta a otros agentes LLM"
},
"data_description": {
"daily_token_budget": "Se cuenta desde la medianoche en la zona horaria de Home Assistant. 0 significa sin límite.",
"price_per_million": "Fija el sensor de coste estimado y el coste en la vista previa de la pregunta. Nada de lo que se envía depende de ello.",
"fallback_agent": "Recibe la frase entera cuando Jev no está seguro de qué hacer.",
"min_confidence": "Por debajo de este valor, Jev no hace nada y pasa el comando al agente de respaldo.",
"allow_whole_home": "Apagarlo todo siempre está permitido, con o sin esta opción."
"allow_whole_home": "Apagarlo todo siempre está permitido, con o sin esta opción.",
"llm_tools": "Añade dos herramientas a la API de Assist, una para preguntas de sí o no y otra para elegir una opción. Cada agente LLM que usa Assist recibe sus descripciones en cada prompt, lo que cuesta tokens en el proveedor de ese agente. Cada llamada también cuenta para el presupuesto de arriba."
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions custom_components/jev/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,16 @@
"price_per_million": "Prix par million de jetons d'entrée, en USD",
"fallback_agent": "Se replier sur cet agent",
"min_confidence": "N'agir qu'au-dessus de cette confiance (0 à 1)",
"allow_whole_home": "Autoriser les commandes qui ne nomment ni pièce ni appareil"
"allow_whole_home": "Autoriser les commandes qui ne nomment ni pièce ni appareil",
"llm_tools": "Proposer Jev comme outil aux autres agents LLM"
},
"data_description": {
"daily_token_budget": "Compté à partir de minuit dans le fuseau horaire de Home Assistant. 0 signifie aucune limite.",
"price_per_million": "Détermine le capteur de coût estimé et le coût dans l'aperçu de la question. Rien de ce qui est envoyé n'en dépend.",
"fallback_agent": "Reçoit la phrase entière quand Jev ne sait pas quoi faire.",
"min_confidence": "En dessous, Jev ne fait rien et transmet la commande à l'agent de secours.",
"allow_whole_home": "Tout éteindre est toujours autorisé, avec ou sans cette option."
"allow_whole_home": "Tout éteindre est toujours autorisé, avec ou sans cette option.",
"llm_tools": "Ajoute deux outils à l'API Assist, l'un pour les questions oui/non et l'autre pour choisir une option. Chaque agent LLM qui utilise Assist reçoit leurs descriptions dans chaque prompt, ce qui coûte des tokens chez le fournisseur de cet agent. Chaque appel compte aussi dans le budget ci-dessus."
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions custom_components/jev/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,16 @@
"price_per_million": "Prezzo per milione di token di input, in USD",
"fallback_agent": "Ripiega su questo agente",
"min_confidence": "Agisci solo sopra questa confidenza (da 0 a 1)",
"allow_whole_home": "Consenti comandi che non nominano una stanza o un dispositivo"
"allow_whole_home": "Consenti comandi che non nominano una stanza o un dispositivo",
"llm_tools": "Offri Jev come strumento ad altri agenti LLM"
},
"data_description": {
"daily_token_budget": "Conteggiato dalla mezzanotte nel fuso orario di Home Assistant. 0 significa nessun limite.",
"price_per_million": "Determina il sensore del costo stimato e il costo nell'anteprima della domanda. Niente di ciò che viene inviato dipende da questo.",
"fallback_agent": "Riceve l'intera frase quando Jev non è sicuro di cosa fare.",
"min_confidence": "Sotto questo valore Jev non fa nulla e passa il comando all'agente di riserva.",
"allow_whole_home": "Spegnere tutto è sempre consentito, con o senza questa opzione."
"allow_whole_home": "Spegnere tutto è sempre consentito, con o senza questa opzione.",
"llm_tools": "Aggiunge due strumenti all'API Assist, uno per le domande sì/no e uno per scegliere un'opzione. Ogni agente LLM che usa Assist riceve le loro descrizioni in ogni prompt, il che costa token presso il fornitore di quell'agente. Ogni chiamata conta anche nel budget qui sopra."
}
}
}
Expand Down
Loading
Loading