Newer
Older
navi-1 / navi / tools / synapse_instructions.py
"""Reaction instructions tool — read / update the per-user Synapse rules.

There are two standing documents, and both are the user's own words:

- `reaction` (the default) — which events to react to, what to do about them.
  Read before acting on a Synapse event.
- `dispatcher` — routing: which event types belong to which profile, and what
  counts as the same conversation. Read only by the dispatcher meta-pass.

Navi may refine either through this tool (self-improvement); every edit is
recorded with edited_by='navi' in that document's version history.
"""

import inspect

from navi.synapse.settings_store import INSTRUCTION_DOCS, SynapseSettingsStore
from navi.tools._internal.base import Tool, ToolContext, ToolResult, current_user_id

_DOC_OP_LABEL = {
    "reaction": "reaction instructions",
    "dispatcher": "dispatcher (routing) instructions",
}


class SynapseInstructionsTool(Tool):
    name = "synapse_instructions"
    description = (
        "Read or update the user's Synapse instruction documents. `doc='reaction'` "
        "(the default) holds the rules for which platform events Navi reacts to and "
        "how — read them before acting on Synapse events. `doc='dispatcher'` holds the "
        "routing rules for the dispatcher that picks the profile and the conversation. "
        "Update either only when a change is clearly wanted and explained (the user "
        "reviews the edit history)."
    )
    parameters = {
        "type": "object",
        "properties": {
            "op": {
                "type": "string",
                "enum": ["read", "update"],
                "description": "read: return the current document. update: replace it with `content`.",
            },
            "doc": {
                "type": "string",
                "enum": list(INSTRUCTION_DOCS),
                "description": (
                    "reaction (default): the reaction rules. "
                    "dispatcher: the routing rules the dispatcher reads."
                ),
            },
            "content": {
                "type": "string",
                "description": "update only: the FULL new text of the document.",
            },
            "reason": {
                "type": "string",
                "description": "update only: one sentence explaining why you are changing the rules.",
            },
        },
        "required": ["op"],
    }

    def __init__(self, pool_provider=None) -> None:
        self._pool_provider = pool_provider

    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 — reaction instructions are per-user.",
            )

        doc = params.get("doc") or "reaction"
        if doc not in INSTRUCTION_DOCS:
            return ToolResult(success=False, output="", error=f"Unknown doc '{doc}'.")

        if not callable(self._pool_provider):
            return ToolResult(
                success=False, output="",
                error="Reaction settings storage is not available (no pool).",
            )
        pool = self._pool_provider()
        if inspect.isawaitable(pool):  # PgSessionStore._get_pool is async
            pool = await pool
        store = SynapseSettingsStore(pool)
        settings_row = await store.get(user_id)

        op = params.get("op", "read")
        if op == "read":
            body = settings_row.document(doc).strip()
            if not body:
                return ToolResult(
                    success=True,
                    output=f"The {_DOC_OP_LABEL[doc]} document is empty — nothing is configured yet.",
                )
            return ToolResult(success=True, output=body)

        if op != "update":
            return ToolResult(success=False, output="", error=f"Unknown op '{op}'.")

        content = (params.get("content") or "").strip()
        if not content:
            return ToolResult(
                success=False, output="",
                error="update requires non-empty `content` — use the full new text of the document.",
            )
        reason = (params.get("reason") or "").strip()
        await store.save_instructions(
            user_id, content, edited_by="navi", reason=reason or None, doc=doc,
        )
        return ToolResult(
            success=True,
            output=(
                f"Updated the {_DOC_OP_LABEL[doc]} and recorded the edit"
                + (f" ({reason})" if reason else "")
                + ". The change applies to future Synapse reactions."
            ),
        )