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
96 changes: 83 additions & 13 deletions src/iotcli/skills/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,35 @@ def build_device_context(device: Device) -> dict[str, Any]:
}


# Per-profile one-liners appended to the frontmatter description.
# Keep these short — they appear in the agent's skill-selection context.
_PROFILE_DESCRIPTION_NOTES: dict[str, str] = {
"petfeeder": " Use `set portions=N` to feed — `on` only triggers one quick portion.",
"light": " Use `set brightness` / `set color_temperature` for fine control, not just on/off.",
"bulb": " Use `set brightness` / `set color_temperature` for fine control, not just on/off.",
}

# Per-profile pitfall bullets shown in the ## Agent Guidance section.
_PROFILE_PITFALLS: dict[str, list[str]] = {
"petfeeder": [
"`on` triggers exactly **one quick portion** — it does NOT let you choose the amount.",
"To dispense a specific amount use `set \"portions=N\"` (N = 1–10). This is the correct command for scheduled or on-demand feeding.",
"To dispense a specific amount use `set \"portions=N\"` (N = 1–60). This is the correct command for scheduled or on-demand feeding.",
"Always query status first to check `food_level` before feeding — dispense may fail silently if the hopper is empty.",
],
"airfryer": [
"Setting only `temperature` without `timer` (or vice-versa) will not start cooking.",
"Check `status` after issuing a cook command — the appliance may reject the combination if the basket is not inserted.",
],
"light": [
"Sending `on` without setting `brightness` may restore the last brightness, which could be 0% — always set brightness explicitly after turning on.",
],
"bulb": [
"Sending `on` without setting `brightness` may restore the last brightness, which could be 0% — always set brightness explicitly after turning on.",
],
}


class SkillGenerator:
"""Generates AI agent skill files for configured devices."""

Expand Down Expand Up @@ -240,25 +269,66 @@ def _device_context(self, device: Device) -> dict[str, Any]:
"""Build template context for a device, enriched with profile metadata."""
ctx = build_device_context(device)
ctx["description"] = self._build_description(
device, ctx["profile_name"], ctx["meta"],
device, ctx["profile_name"], ctx["meta"], ctx["properties"],
ctx["trigger_names"], ctx["actions"],
)
ctx["pitfalls"] = self._build_pitfalls(ctx["profile_name"], ctx["properties"])
return ctx

def _build_description(
self, device: Device, profile_name: str | None, meta: Any
self,
device: Device,
profile_name: str | None,
meta: Any,
properties: list,
trigger_names: list[str],
actions: dict[str, str],
) -> str:
"""Build the SKILL.md `description` field that agents use to decide invocation."""
"""Build a concise, action-oriented description for the skill frontmatter.

The description is the primary signal agents use to decide whether to invoke
this skill — it should name concrete actions and key property ranges, not just
say "control the device".
"""
proto = meta.display_name if meta else device.protocol
if profile_name and profile_name != "generic":
return (
f"Control the {device.name} ({profile_name}) — a {proto} device at "
f"{device.ip}. Use this skill to query status, turn on/off, and set "
f"device-specific properties via the iotcli CLI."
)
return (
f"Control the {device.name} — a {proto} device at {device.ip}. "
f"Use this skill to query status, turn on/off, and set properties via the iotcli CLI."
)
location = f"at {device.ip}" if device.ip and device.ip != "0.0.0.0" else "(cloud)"

# Build a compact list of what the agent can actually do.
action_parts: list[str] = []

settable = [p for p in properties if p.settable and p.type != "trigger"]
for p in settable[:4]:
if p.enum:
vals = "/".join(str(v) for v in p.enum[:4])
if len(p.enum) > 4:
vals += "/…"
action_parts.append(f"set {p.name} ({vals})")
elif p.minimum is not None and p.maximum is not None:
unit = f" {p.unit}" if p.unit else ""
action_parts.append(f"set {p.name} ({p.minimum}–{p.maximum}{unit})")
else:
action_parts.append(f"set {p.name}")

for name in trigger_names[:2]:
action_parts.append(f"trigger {name}")

for name in list(actions.keys())[:2]:
action_parts.append(name)

if not action_parts:
action_parts = ["turn on/off", "query status"]

action_str = "; ".join(action_parts)

# Profile-specific clarification to prevent common agent mistakes.
note = _PROFILE_DESCRIPTION_NOTES.get(profile_name or "", "")

return f"{proto} '{device.name}' {location} — {action_str}.{note}"

@staticmethod
def _build_pitfalls(profile_name: str | None) -> list[str]:
"""Return a list of agent-facing pitfall warnings for this device profile."""
return list(_PROFILE_PITFALLS.get(profile_name or "", []))
Comment thread
joeVenner marked this conversation as resolved.

def _write_device_skill(self, device: Device, out_dir: Path) -> Path:
ctx = self._device_context(device)
Expand Down
31 changes: 23 additions & 8 deletions src/iotcli/skills/templates/device_skill.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,32 @@ metadata: {{ metadata_json }}
{% endif %}
| Address | `{{ device.ip }}:{{ device.port }}` |
| Connection | {% if meta and meta.is_cloud %}Cloud (requires internet){% else %}Local network{% endif %} |
| Capabilities | {% for c in capabilities %}`{{ c }}`{% if not loop.last %}, {% endif %}{% endfor %} |

## What This Skill Does

Controls the `{{ device.name }}` device via the `iotcli` CLI. Supports status queries,
power control, and property updates. All commands use `--json` for machine-readable output.
Use this skill to:

- **Check device state** — is it on, offline, what are the current settings?
{% if settable_names %}
- **Change settings** — {{ settable_names | join(", ") }}
{% endif %}
{% if trigger_names %}
- **Trigger commands** — {{ trigger_names | join(", ") }}
{% endif %}
{% if actions %}
- **Run high-level actions** — {{ actions.keys() | join(", ") }}
{% endif %}
- **Turn on / off** the device{% if device.protocol in ("tuya", "petfeeder") and profile_name == "petfeeder" %} (on = one quick portion — see Agent Guidance below){% endif %}

{% if pitfalls %}
## Agent Guidance

> These are common mistakes — read before issuing commands.

{% for p in pitfalls %}
- {{ p }}
{% endfor %}
{% endif %}

## Process

Expand All @@ -33,11 +53,6 @@ power control, and property updates. All commands use `--json` for machine-reada
iotcli --json control on "{{ device.name }}"
iotcli --json control off "{{ device.name }}"
```
{% if device.protocol in ("tuya", "petfeeder") and profile_name == "petfeeder" %}

> **Pet feeder note:** `on` triggers a single quick feed (1 portion). There is no
> meaningful `off` state. Use `set ... "portions=N"` to dispense N portions.
{% endif %}

{% if settable_names %}
## Settable Properties
Expand Down
Loading