Newer
Older
navi-1 / navi / tools / notify.py
"""notify — the agent's user-facing signal channel (application + Synapse).

Respects the user's push_target setting: app-only, app+Synapse, or Synapse-only.
The Synapse leg needs the source key; without it that leg is skipped and the
result says so explicitly, so the agent never wastes retries.
"""

from navi.synapse.settings_store import SynapseSettingsStore
from navi.tools._internal.base import (
    Tool,
    ToolContext,
    ToolResult,
    current_session_id,
    current_user_id,
)

_LEVEL_TO_PRIORITY = {"info": "normal", "warning": "high", "intervention": "critical"}


class NotifyTool(Tool):
    name = "notify"
    description = (
        "Push a notification to the user (application web push and/or a Synapse "
        "event, per the user's notification settings). Use it to signal that "
        "something needs the user's attention or that a background task finished. "
        "For 'intervention' level state clearly WHAT decision you need from the "
        "user and by when — the push opens this session."
    )
    parameters = {
        "type": "object",
        "properties": {
            "message": {"type": "string", "description": "Notification text, one plain sentence or two."},
            "level": {
                "type": "string",
                "enum": ["info", "warning", "intervention"],
                "description": "info: FYI. warning: something went wrong / degraded. intervention: the user's decision is needed now.",
            },
        },
        "required": ["message"],
    }

    async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
        user_id = ctx.user_id if ctx is not None else current_user_id.get(None)
        if user_id is None:
            return ToolResult(
                success=False, output="",
                error="No user context — cannot address a notification.",
            )
        message = (params.get("message") or "").strip()
        if not message:
            return ToolResult(success=False, output="", error="Empty notification text.")
        level = params.get("level") or "info"
        if level not in _LEVEL_TO_PRIORITY:
            return ToolResult(
                success=False, output="",
                error=f"Unknown level '{level}': info, warning, intervention.",
            )
        session_id = ctx.session_id if ctx is not None else current_session_id.get()

        delivered: list[str] = []
        skipped: list[str] = []

        settings_row = await SynapseSettingsStore(await self._store_pool()).get(user_id)
        push_target = settings_row.push_target

        if push_target in ("app", "app_synapse"):
            push_service = self._push_service()
            if push_service is not None and push_service.enabled:
                try:
                    await push_service.notify_custom(
                        session_id or "", user_id,
                        title=_title_for(level),
                        body=message,
                    )
                    delivered.append("app")
                except Exception as e:
                    skipped.append(f"app ({e})")
            else:
                skipped.append("app (web push not configured)")

        if push_target in ("app_synapse", "synapse"):
            from navi.synapse.outbound import emit_low_level, synapse_source_ready

            if synapse_source_ready():
                try:
                    await emit_low_level(
                        subject="navi-notification",
                        action=level,
                        payload={
                            "session_id": session_id,
                            "user_id": user_id,
                            "message": message,
                        },
                        priority=_LEVEL_TO_PRIORITY[level],
                        dedup_key=f"notify:{session_id}:{abs(hash(message)) % 10**10}",
                    )
                    delivered.append("synapse")
                except Exception as e:
                    skipped.append(f"synapse ({e})")
            else:
                skipped.append("synapse (source key not configured)")

        parts = []
        if delivered:
            parts.append("Delivered: " + ", ".join(delivered) + ".")
        if skipped:
            parts.append("Skipped: " + "; ".join(skipped) + ".")
        return ToolResult(success=True, output=" ".join(parts))

    @staticmethod
    async def _store_pool():
        from navi.api.deps import get_session_store

        return await get_session_store()._get_pool()

    @staticmethod
    def _push_service():
        try:
            from navi.api.deps import get_push_service

            return get_push_service()
        except Exception:
            return None


def _title_for(level: str) -> str:
    return {
        "info": "Navi: уведомление",
        "warning": "Navi: предупреждение",
        "intervention": "Navi: нужно ваше решение",
    }[level]