"""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]