diff --git a/src/iotcli/skills/generator.py b/src/iotcli/skills/generator.py index 3030a55..b82b6c0 100644 --- a/src/iotcli/skills/generator.py +++ b/src/iotcli/skills/generator.py @@ -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.""" @@ -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 "", [])) def _write_device_skill(self, device: Device, out_dir: Path) -> Path: ctx = self._device_context(device) diff --git a/src/iotcli/skills/templates/device_skill.md.j2 b/src/iotcli/skills/templates/device_skill.md.j2 index d9c6f5d..bd67230 100644 --- a/src/iotcli/skills/templates/device_skill.md.j2 +++ b/src/iotcli/skills/templates/device_skill.md.j2 @@ -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 @@ -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