"""The `skills` tool — find, read, write and propose skills and protocols.
The catalog is the agent's own memory of *how* to do things. Skills are written
by the agent freely (they are advice, and the user can edit them in settings);
protocols are not: a protocol is a commitment, so the agent can only *propose*
one and the user's click on "Добавить" is what creates the row (see
``navi.skills.store.SkillStore.create_proposal``).
"""
from __future__ import annotations
import structlog
from navi.skills import SkillError, SkillRef, SkillStore, parse_document
from navi.tools._internal.base import (
Tool,
ToolContext,
ToolResult,
current_session_id,
current_user_id,
current_user_role,
)
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"
"Writing to the catalog:\n"
"- 'create' and 'update' save a SKILL. Write one as soon as a way of working "
"proved itself and is likely to come up again — you do not need permission, and "
"a better version is a normal edit.\n"
"- 'propose' stores a PROTOCOL for the user to accept. Never try to create or "
"edit a protocol by another route: it becomes a catalog entry only when the user "
"clicks 'Добавить' on the proposal card, which appears next to the tool result by "
"itself — just say what the protocol does and wait. Pass 'replaces_id' to propose "
"a new version of a protocol that already exists.\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", "create", "update", "propose"],
"description": (
"search — find entries; load — read one entry in full; create — save a "
"new skill; update — save a new version of a skill you own; propose — "
"offer a protocol to the user."
),
},
"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": "search: 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/update: the entry id from search.",
},
"name": {
"type": "string",
"description": "load: the exact name, when you know it and have no id.",
},
"content": {
"type": "string",
"description": (
"create/update/propose: the whole document — a YAML front matter block "
"between '---' lines, then the markdown body. A protocol must declare "
"steps, post_checks, on_deviation, on_blocked and on_already_done; a "
"skill needs a name, a description and a body."
),
},
"replaces_id": {
"type": "string",
"description": (
"propose: id of an existing protocol you own, when the proposal is a "
"new version of it rather than a new entry."
),
},
},
"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":
return await self._search(params, user_id)
if op == "load":
return await self._load(params, user_id)
if op == "create":
return await self._create(params, user_id)
if op == "update":
return await self._update(params, user_id)
if op == "propose":
session_id = current_session_id.get(None) or (ctx.session_id if ctx else None) or ""
return await self._propose(params, user_id, session_id)
return ToolResult(
success=False, output="",
error=(
f"Unknown op: {op!r}. Use 'search', 'load', 'create', 'update' or 'propose'."
),
)
# ── reads ───────────────────────────────────────────────────────────────
async def _search(self, params: dict, user_id: str) -> ToolResult:
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))
async def _load(self, params: dict, user_id: str) -> ToolResult:
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,
},
)
# ── writes ──────────────────────────────────────────────────────────────
async def _create(self, params: dict, user_id: str) -> ToolResult:
content = params.get("content") or ""
try:
doc = parse_document(content)
except SkillError as e:
return ToolResult(success=False, output="", error=str(e))
if doc.kind == "protocol":
return ToolResult(
success=False, output="",
error=(
"A protocol is not created directly — propose it with op='propose' "
"and render the card; it enters the catalog when the user accepts it."
),
)
try:
entry = await self._store.create(
owner_id=user_id, content=content, edited_by=user_id,
reason="written by the agent",
)
except SkillError as e:
return ToolResult(success=False, output="", error=str(e))
except Exception as e:
log.warning("skills.create_failed", error=str(e))
return ToolResult(success=False, output="", error=f"Cannot write to the catalog: {e}")
return ToolResult(
success=True,
output=(
f"Saved skill {entry.name!r} (id={entry.id}, v{entry.version}, "
f"area={entry.area}). The user can edit it in Settings → Скилы и протоколы."
),
metadata={"skill_id": entry.id, "kind": entry.kind, "version": entry.version},
)
async def _update(self, params: dict, user_id: str) -> ToolResult:
entry_id = (params.get("id") or "").strip()
content = params.get("content") or ""
if not entry_id:
return ToolResult(success=False, output="", error="'id' is required for 'update'")
try:
current = await self._store.get(entry_id)
except Exception as e:
log.warning("skills.update_failed", error=str(e))
return ToolResult(success=False, output="", error=f"Cannot read the catalog: {e}")
if current is None:
return ToolResult(success=False, output="", error=f"No entry with id {entry_id!r}")
if current.kind == "protocol":
return ToolResult(
success=False, output="",
error=(
"A protocol changes only through a proposal: call op='propose' with "
f"replaces_id={entry_id!r} and let the user accept the new version."
),
)
role = current_user_role.get("user")
if current.owner_id != user_id and role != "admin":
return ToolResult(
success=False, output="",
error="This entry belongs to someone else; only its owner or an admin can edit it.",
)
try:
entry = await self._store.update(
entry_id, content=content, edited_by=user_id, reason="updated by the agent",
)
except SkillError as e:
return ToolResult(success=False, output="", error=str(e))
if entry is None:
return ToolResult(success=False, output="", error=f"No entry with id {entry_id!r}")
return ToolResult(
success=True,
output=(
f"Updated skill {entry.name!r} to v{entry.version} (id={entry.id}). "
"The previous version stays in the history."
),
metadata={"skill_id": entry.id, "kind": entry.kind, "version": entry.version},
)
async def _propose(self, params: dict, user_id: str, session_id: str) -> ToolResult:
content = params.get("content") or ""
replaces_id = (params.get("replaces_id") or "").strip() or None
try:
proposal = await self._store.create_proposal(
owner_id=user_id,
content=content,
session_id=session_id,
replaces_id=replaces_id,
)
except SkillError as e:
return ToolResult(success=False, output="", error=str(e))
except Exception as e:
log.warning("skills.propose_failed", error=str(e))
return ToolResult(success=False, output="", error=f"Cannot store the proposal: {e}")
action = "replaces" if replaces_id else "adds"
return ToolResult(
success=True,
output=(
f"Proposal stored: {proposal.name!r} {action} a protocol "
f"(proposal_id={proposal.id}). It is NOT in the catalog yet — the card "
"beside this result carries the document and the 'Добавить' / 'Отклонить' "
"buttons. Tell the user what the protocol does and that you need their "
"decision; do not run it on your own."
),
metadata={
"proposal_id": proposal.id,
"kind": "protocol",
"name": proposal.name,
# Rendered by the webclient exactly like a navi_ui component.
"ui_component": {
"component": "skill_proposal",
"payload": {"proposal_id": proposal.id},
},
},
)