Newer
Older
navi-1 / navi / tools / skills.py
"""The `skills` tool — find and load skills and protocols from the catalog."""

from __future__ import annotations

import structlog

from navi.skills import SkillError, SkillRef, SkillStore
from navi.tools._internal.base import (
    Tool,
    ToolContext,
    ToolResult,
    current_user_id,
)

log = structlog.get_logger()

_KIND_HINT = "'skill' — a flexible approach with heuristics; 'protocol' — a strict ordered procedure."


class SkillTool(Tool):
    name = "skills"
    description = (
        "The catalog of skills and protocols — reusable methodologies this Navi has been "
        "taught, owned by you or shared by the hive.\n\n"
        "Call 'search' before improvising an approach to a recurring kind of task (home "
        f"automation, shutting down or bringing up infrastructure, workflows). {_KIND_HINT}\n\n"
        "Call 'load' with the id from a search result (or an exact name) to get the full "
        "instructions. A skill is advice: follow it as far as it fits. A protocol is a "
        "commitment: its steps are run in order with a check after each, and its escape "
        "hatches must be honoured when reality differs from the plan.\n\n"
        "Search costs one call and often replaces several turns of rediscovery — use it."
    )
    parameters = {
        "type": "object",
        "properties": {
            "op": {
                "type": "string",
                "enum": ["search", "load"],
                "description": "search — find entries; load — read one entry in full.",
            },
            "query": {
                "type": "string",
                "description": (
                    "search: words to match against name, description, triggers and tags "
                    "(e.g. 'energy saving', 'shutdown', 'backup')."
                ),
            },
            "kind": {
                "type": "string",
                "enum": ["skill", "protocol"],
                "description": "Only entries of this kind.",
            },
            "tag": {"type": "string", "description": "search: require this tag."},
            "area": {
                "type": "string",
                "enum": ["home", "infrastructure", "workflows", "general"],
                "description": "search: only this area.",
            },
            "id": {"type": "string", "description": "load: the entry id from search."},
            "name": {
                "type": "string",
                "description": "load: the exact name, when you know it and have no id.",
            },
        },
        "required": ["op"],
    }

    def __init__(self, store: SkillStore) -> None:
        self._store = store

    async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
        op = params.get("op")
        user_id = current_user_id.get(None) or (ctx.user_id if ctx else None) or ""

        if op == "search":
            try:
                entries = await self._store.list(
                    user_id=user_id,
                    kind=params.get("kind"),
                    query=(params.get("query") or "").strip() or None,
                    tag=(params.get("tag") or "").strip() or None,
                    area=params.get("area"),
                )
            except Exception as e:  # the catalog being down must not look like "nothing found"
                log.warning("skills.search_failed", error=str(e))
                return ToolResult(success=False, output="", error=f"Cannot search the catalog: {e}")
            if not entries:
                return ToolResult(
                    success=True,
                    output=(
                        "No matching skills or protocols. Nothing is stored for this query yet — "
                        "either it was never taught, or the wording differs: try one word "
                        "('power', 'backup', 'shutdown') or drop the filters."
                    ),
                )
            lines = [f"{len(entries)} matching entries (personal first):"]
            lines.extend(SkillRef.of(e).line() for e in entries)
            lines.append("Load one with op='load' and its id.")
            return ToolResult(success=True, output="\n".join(lines))

        if op == "load":
            entry_id = (params.get("id") or "").strip()
            name = (params.get("name") or "").strip()
            if not entry_id and not name:
                return ToolResult(
                    success=False, output="",
                    error="'id' or 'name' is required for 'load'",
                )
            try:
                entry = await self._store.find(
                    user_id=user_id, skill_id=entry_id or None,
                    name=name or None, kind=params.get("kind"),
                )
            except Exception as e:
                log.warning("skills.load_failed", error=str(e))
                return ToolResult(success=False, output="", error=f"Cannot read the catalog: {e}")
            if entry is None:
                return ToolResult(
                    success=False, output="",
                    error=(
                        f"No such entry ({entry_id or name!r}). Search first: "
                        "op='search' with a word from its name or purpose."
                    ),
                )
            try:
                rendered = entry.doc
            except SkillError as e:
                return ToolResult(
                    success=False, output="",
                    error=f"The stored document is invalid and cannot be loaded: {e}",
                )
            from navi.skills import render_for_agent

            return ToolResult(
                success=True,
                output=render_for_agent(
                    rendered, entry_id=entry.id, version=entry.version,
                    visibility=entry.visibility,
                ),
                metadata={
                    "skill_id": entry.id,
                    "kind": entry.kind,
                    "name": entry.name,
                    "version": entry.version,
                },
            )

        return ToolResult(
            success=False, output="",
            error=f"Unknown op: {op!r}. Use 'search' or 'load'.",
        )