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
7 changes: 2 additions & 5 deletions script-agent/.gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
__pycache__/

/local/

# Local dev scripts (contain a Home Assistant token / host addresses).
run.sh
sync.sh
sync-green.sh
/*.sh
.python-version
13 changes: 13 additions & 0 deletions script-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 1.1.0

- Add a "Create calendar event" blueprint
- Restrict entity fields written with the `domain` shorthand
(`entity: {domain: calendar}`) to that domain, instead of offering every
exposed entity
- Give the model the current date with each command, so a date or date & time
field gets a date instead of "Saturday"
- Edit the system and user prompts on the Settings page
- Reword the system prompt so the no-match reply is asked for in the requested
language, rather than bundled into one sentence with what to say. This rebuilds
the prompt cache once on upgrade

## 1.0.0

- Initial release
40 changes: 39 additions & 1 deletion script-agent/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,20 @@ The following field [selectors][] are supported:
- Boolean
- Color temperature
- Date
- The current date is given to the model with each command, so "Saturday"
and "tomorrow" become real dates
- Date & time
- As above; the model is told to answer with `YYYY-MM-DDTHH:MM:SS`
- Duration
- The model fills this in as `HH:MM:SS`, where Home Assistant's own UI gives
a mapping of `days`/`hours`/`minutes`/`seconds`. Write templates that
accept both — see the `create_calendar_event` [blueprint][blueprints]
- Beware that `as_timedelta` reads a two-part `01:00` as *one minute*, not
one hour
- Entity
- Uses all [exposed][expose] entity names
- Add a [domain filter][] to restrict possible entities
- Add a [domain filter][] to restrict possible entities. Both the current
`filter` form and the older `domain` shorthand work
- Floor
- Uses all available floor names and [aliases][]
- Number
Expand Down Expand Up @@ -230,6 +239,35 @@ effect immediately, and grows an automatically sized context when necessary.
The page also shows the effective model, context size, CPU threads, and flash
attention setting.

### Prompts

The same page edits the two prompts, also saved in `/data/overrides.yaml`:

- The **system prompt** comes before the tools and is the cached prefix, so
changing it rebuilds the prompt cache — the same wait as changing which
scripts are targeted.
- The **user prompt** wraps each command and is built fresh every time, so
anything that changes belongs here rather than in the system prompt. It may
use these placeholders, of which `{text}` is required:

- `{text}` — the sentence to recognize
- `{language}` — the requested response language
- `{date}` — the current date, as `YYYY-MM-DD`
- `{time}` — the current time, as `HH:MM`
- `{datetime}` — the current date and time, ISO 8601
- `{weekday}` — the current day of the week

The default user prompt carries the date, which is how a date or date & time
field gets an actual date out of "for an hour at 2pm on Saturday". Recognized
sentences are cached against the finished prompt, so including `{time}` or
`{datetime}` makes [tool call caching](#tool-call-caching) nearly useless: the
prompt then changes every minute. A longer user prompt also costs time on every
command, since it is evaluated per utterance rather than cached.

**Restore defaults** puts both prompts back. Prompts that match the defaults are
not recorded, so the app keeps following the default if a later version improves
it.

### Choosing which scripts are targeted

Each script has a checkbox. Exposing a script to voice in Home Assistant still
Expand Down
69 changes: 69 additions & 0 deletions script-agent/blueprints/create_calendar_event.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
blueprint:
name: Create calendar event
description: Add an event to a calendar at a date and time for some length of time
domain: script

mode: parallel

fields:
calendar:
name: Calendar
description: Name of the calendar to add the event to
required: true
selector:
entity:
filter:
domain: calendar

start:
name: Start
description: Date and time the event starts
required: true
selector:
datetime:

duration:
name: Duration
description: How long the event lasts
required: true
selector:
duration:

summary:
name: Summary
description: Title of the event
required: true
selector:
text:

sequence:
- variables:
event_start: "{{ as_datetime(start).isoformat() }}"
# A duration selector holds a mapping in Home Assistant's UI, but the model
# fills the field in as an "HH:MM:SS" string, so accept both.
event_end: >-
{%- if duration is mapping -%}
{%- set length = timedelta(
days=duration.get('days', 0) | int(0),
hours=duration.get('hours', 0) | int(0),
minutes=duration.get('minutes', 0) | int(0),
seconds=duration.get('seconds', 0) | int(0)
) -%}
{%- else -%}
{#- Home Assistant reads "01:00" as MM:SS, but a model that leaves the
seconds off means one hour, so fill them in. -#}
{%- set text = duration | string -%}
{%- set length = as_timedelta(
(text ~ ':00') if text.count(':') == 1 else text
) -%}
{%- endif -%}
{{ (as_datetime(start) + length).isoformat() }}

- action: calendar.create_event
target:
entity_id: "{{ calendar }}"
data:
summary: "{{ summary }}"
start_date_time: "{{ event_start }}"
end_date_time: "{{ event_end }}"
2 changes: 1 addition & 1 deletion script-agent/config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
version: 1.0.0
version: 1.1.0
slug: script-agent
name: Script Agent
description: Run Home Assistant scripts by voice with a local language model
Expand Down
5 changes: 5 additions & 0 deletions script-agent/mypy.ini
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
[mypy]
# src is fully annotated. Without this, an unannotated function is silently
# skipped instead of checked, and only shows up as an "annotation-unchecked"
# note the moment it happens to declare a local.
disallow_untyped_defs = True
disallow_incomplete_defs = True

[mypy-wyoming.*]
ignore_missing_imports = True
Expand Down
6 changes: 6 additions & 0 deletions script-agent/requirements.dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
black
flake8
pylint
isort
mypy
pip-tools
2 changes: 1 addition & 1 deletion script-agent/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ typing-extensions==4.15.0
# llama-cpp-python
werkzeug==3.1.8
# via flask
wyoming==1.9.0
wyoming==1.10.2
# via -r requirements.in
yarl==1.24.2
# via aiohttp
37 changes: 37 additions & 0 deletions script-agent/script/update-deps
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Recompile the pinned requirements from the .in files.

The order is the reason this script exists: requirements.in reads
requirements.llama.txt, so llama has to be compiled first or the main file is
pinned against a stale copy of it.

Arguments are passed to pip-compile, so `script/update-deps --upgrade` bumps
every pin and `script/update-deps --upgrade-package flask` bumps just one.
Bumping llama-cpp-python is expensive twice over: it is compiled from source
when the app is installed, and it is part of the model state hash, so every
user pays a prompt cache rebuild on their next boot. Say so in the changelog.
"""

import subprocess
import sys
import venv
from pathlib import Path

_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"

# Compiled in this order; the first is an input to the second.
_REQUIREMENTS = ["requirements.llama.in", "requirements.in"]

if _VENV_DIR.exists():
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
python_exe = context.env_exe
else:
python_exe = "python3"

for requirements_in in _REQUIREMENTS:
subprocess.check_call(
[python_exe, "-m", "piptools", "compile", requirements_in] + sys.argv[1:],
cwd=_PROGRAM_DIR,
)
32 changes: 30 additions & 2 deletions script-agent/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@

import overrides
from const import BASE_DIR, AppState
from gemma4_recognizer import DEFAULT_MAX_TOKENS, Gemma4Recognizer
from gemma4_recognizer import (
DEFAULT_MAX_TOKENS,
DEFAULT_SYSTEM_PROMPT,
DEFAULT_USER_PROMPT,
Gemma4Recognizer,
validate_prompts,
)
from hass_api import HomeAssistant
from intent_server import ScriptAgentEventHandler
from web_server import make_web_server, run_web_server
Expand Down Expand Up @@ -145,13 +151,35 @@ async def main() -> None:
"the web UI. Agent will not function."
)

# Prompts edited in the web UI replace the defaults. They are validated here
# rather than trusted: a prompt saved by an older version, or hand-edited in
# the overrides file, must not leave the app unable to recognize anything.
system_prompt = DEFAULT_SYSTEM_PROMPT
user_prompt = DEFAULT_USER_PROMPT
if (all_overrides.system_prompt is not None) or (
all_overrides.user_prompt is not None
):
try:
system_prompt, user_prompt = validate_prompts(
all_overrides.system_prompt or system_prompt,
all_overrides.user_prompt or user_prompt,
)
except ValueError as err:
_LOGGER.warning("Using the default prompts: %s", err)
all_overrides.system_prompt = None
all_overrides.user_prompt = None
system_prompt = DEFAULT_SYSTEM_PROMPT
user_prompt = DEFAULT_USER_PROMPT

_LOGGER.info(
"Loading Gemma 4 (repo=%s, filename=%s)", args.hf_repo, args.hf_filename
)
recognizer = Gemma4Recognizer(
repo_id=args.hf_repo.strip(),
filename=args.hf_filename.strip(),
state_path=args.llama_state,
system_prompt=system_prompt,
user_prompt=user_prompt,
cache_size=args.tool_call_cache_size,
n_ctx=args.n_ctx if args.n_ctx > 0 else None,
n_ctx_overhead=args.n_ctx_overhead,
Expand Down Expand Up @@ -202,7 +230,7 @@ async def main() -> None:
# Handle graceful termination
stop_event = asyncio.Event()

def request_stop():
def request_stop() -> None:
stop_event.set()

for sig in (signal.SIGTERM, signal.SIGINT):
Expand Down
2 changes: 1 addition & 1 deletion script-agent/src/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
BASE_DIR = Path(__file__).parent
APP_NAME = "Script Agent"
APP_SLUG = "script-agent"
APP_VERSION = "1.8.0"
APP_VERSION = "1.1.0"

if TYPE_CHECKING:
from gemma4_recognizer import Gemma4Recognizer
Expand Down
Loading
Loading