Newer
Older
navi-1 / navi / core / context_builder / helpers.py
"""Pure helpers for context assembly: truncation, freshness, prompt blocks."""

from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING

import navi.config as _config
from navi.llm.base import Message


def _fact_age(updated_at: str) -> str:
    """How old a fact is, so a stale one reads as stale.

    Nothing in memory expires on a clock — a fact is wrong when the world moves,
    not when a timer fires — so the injection says how old it is and lets the
    agent weigh it. A missing or unparseable timestamp simply says nothing.
    """
    try:
        then = datetime.fromisoformat(updated_at)
    except (TypeError, ValueError):
        return ""
    if then.tzinfo is None:
        then = then.replace(tzinfo=timezone.utc)
    days = (datetime.now(timezone.utc) - then).days
    if days <= 0:
        return "today"
    if days < 30:
        return f"{days}d ago"
    return f"{days // 30}mo ago"


# Roles whose content may be head/tail-truncated when a single message is too
# large for the context window. System/summary messages are already compact.
# User messages carry intent, so their cap is deliberately more generous than
# tool output's (see `_truncation_budget`) — but leaving them uncapped at all
# turned a single oversized paste into a dead session: the size check refused
# every subsequent call while compression had nothing it could split.
_TRUNCATABLE_ROLES = {"tool", "assistant", "user"}

# A user message may use up to this fraction of the window before its middle is
# elided for the LLM view (the stored history keeps the full text).
_USER_MESSAGE_WINDOW_FRAC = 1 / 3


def _truncation_budget(role: str) -> int:
    """Per-message view cap in tokens for a truncatable role."""
    budget = _config.settings.context_message_token_budget
    if budget <= 0:
        budget = max(1, _config.settings.ollama_num_ctx // 6)
    if role == "user":
        budget = max(
            budget,
            max(1, int(_config.settings.ollama_num_ctx * _USER_MESSAGE_WINDOW_FRAC)),
        )
    return budget

# Which stored system-role messages are infrastructure (and therefore dropped
# from the LLM view) and which are addressed to the model. `task_note` carries
# detached tool results; `synapse_event` is the visible record of the event a
# Synapse reaction is answering — the model must see the same request the user
# sees. Anything else in the transcript with role="system" is bookkeeping.
_LLM_SYSTEM_SOURCES = {"task_note", "synapse_event"}


def _truncate_content(content: str, budget_tokens: int, *, label: str = "truncated") -> str:
    """Head/tail-truncate ``content`` to roughly ``budget_tokens`` tokens.

    Keeps the head and tail verbatim with a marker naming the elided middle, so
    the model retains the beginning and end of a large result (usually the
    most informative parts) without the whole thing blowing the context window.
    """
    budget_chars = budget_tokens * 3
    if len(content) <= budget_chars:
        return content
    half = budget_chars // 2
    dropped_tokens = (len(content) - 2 * half) // 3
    marker = f"\n[…{label} ~{dropped_tokens:,} tokens…]\n"
    return content[:half] + marker + content[-half:]


def truncate_oversized(conv: list[Message]) -> list[Message]:
    """Head/tail-truncate oversized messages for the LLM view.

    A single huge tool result (e.g. reading a large file) can alone exceed
    the context window, and the turn/message-count-based compressor cannot
    shrink a single message. This per-build pass caps each truncatable
    message at its role budget — ``context_message_token_budget`` tokens
    (0 = num_ctx // 6) for tool/assistant output, at least num_ctx // 3 for
    user text — keeping head + tail so the model still sees the start and end.
    Copies are returned — stored history (session.messages) is never
    mutated, so the user still sees full content and reloads are unaffected.

    Module-level because the compressor reports the session's context size in
    the same units: a raw context holding a truncated-away megabyte is not what
    the model is sent, and counting it as such shows a scary 800k over a 128k
    window.
    """
    out: list[Message] = []
    for m in conv:
        if m.role not in _TRUNCATABLE_ROLES:
            out.append(m)
            continue
        budget = _truncation_budget(m.role)
        if len(m.content or "") <= budget * 3:
            out.append(m)
            continue
        label = "user message truncated" if m.role == "user" else "truncated"
        out.append(
            m.model_copy(
                update={"content": _truncate_content(m.content or "", budget, label=label)}
            )
        )
    return out


_INJECTED_IDS_LIMIT = 100


def _recall_query(context: list, user_message: str, max_chars: int = 1200) -> str:
    """A search query made of the recent turn window, not just the last message.

    The user's "and what about the port?" is unsearchable on its own; in the
    company of the previous two exchanges it names the topic. Assistant replies
    are included in shortened form because the concrete names (a file, a value, a
    plan) usually appear there, not in the user's own wording.
    """
    parts: list[str] = []
    turns = [m for m in context if m.role in ("user", "assistant") and not m.is_summary]
    for msg in turns[-4:]:
        text = (msg.content or "").strip()
        if text:
            parts.append(text[:300])
    tail = (user_message or "").strip()
    if tail:
        parts.append(tail[:300])
    query = "\n".join(parts)
    return query[-max_chars:].strip()


def _fact_value_is_abs_path_outside(value: str, cwd: str) -> bool:
    """True when a fact's value is an absolute filesystem path outside `cwd`.

    Used to keep context-dependent path facts (e.g. "project_root → /home/.../navi-1")
    from being injected into sessions running in a different project, where they
    would misdirect the agent. Relative values are left through (we cannot know
    what they were recorded relative to). Non-path values always pass.
    """
    v = str(value).strip()
    if not v or not (v.startswith("/") or v.startswith("~")):
        return False
    try:
        p = Path(v).expanduser().resolve()
        base = Path(cwd).expanduser().resolve()
    except Exception:
        return True  # unresolvable path — treat as out-of-scope, drop it
    try:
        p.relative_to(base)
        return False  # within the session cwd — keep
    except ValueError:
        return True  # absolute path outside the cwd — drop

if TYPE_CHECKING:
    from navi.profiles.base import AgentProfile


def methodologies_block(profile: "AgentProfile") -> str | None:
    """The "how to use the methodologies catalog" block for profiles that have it.

    ``skills`` and ``protocol`` are useless as bare tool schemas: the model meets
    two unfamiliar tools with no reason to reach for them, and probes them
    instead of working. The rules that say when to search the catalog, how to run
    a protocol and when to write an entry belong in the system prompt, once,
    instead of seven profile prompts drifting apart.
    """
    scope = getattr(getattr(profile, "tools", None), "agent", None)
    native = list(getattr(scope, "native", None) or [])
    has_skills = "skills" in native
    has_protocol = "protocol" in native
    if not (has_skills or has_protocol):
        return None

    tools = " and ".join(f"`{t}`" for t in ("skills", "protocol") if t in native)
    manuals = " / ".join(
        f'`tool_manual("{t}")`' for t in ("skills", "protocol") if t in native
    )
    intro = (
        f"{tools} are this Navi's catalog of *how* to do things: what it has already "
        f"learned and agreed on. To read up on them use {manuals} — do not call them "
        f"with a guess to see what happens."
    )
    lines = [
        "## Methodologies — skills and protocols",
        intro,
    ]
    if has_skills:
        lines.append(
            '- **Recurring work: search first.** "как обычно", "как мы делали", a standard '
            'shutdown, a deployment, a routine document — call `skills(op="search")` once '
            "before improvising, then `load` the entry that fits. One call replaces a whole "
            "rediscovery. Not for a one-off question, never twice for the same query."
        )
        lines.append(
            "- **A skill is advice** — follow it as far as it fits the task, and say where "
            "you departed from it."
        )
    if has_protocol:
        lines.append(
            '- **A protocol is a commitment.** Run it with `protocol(op="start")`, never by '
            "retyping its steps into the todo. A protocol whose triggers match the request is "
            "the user's pre-agreed procedure: say you are running it and start. Each injected "
            "step is marked done only after its check passes; the run ends with `finish` plus "
            "a report on the post-checks, or with `fail`/`cancel` and the reason. Never "
            "improvise around a step — that is the error a protocol exists to prevent."
        )
    if has_skills:
        lines.append(
            "- **Record what proved itself.** An approach that worked and will recur is one "
            '`skills(op="create")` and a line to the user. A protocol is never created '
            'directly: `skills(op="propose")` it, say what it does, and wait for the user\'s '
            "decision."
        )
    return "\n\n".join(lines)


def profiles_block(current_id: str, others: list) -> str:
    """The "Available profiles" block: the cross-profile map, plus how to act on it.

    The block is built in one place because the admin prompt-debug view renders
    it too — two copies used to tell the model different things about switching.
    The rules themselves live in the persona; this is the short reminder beside
    the list, where the model is about to make the choice.
    """
    lines = ["## Available profiles", f"Current: **{current_id}**"]
    for profile in others:
        desc = profile.short_description or profile.description
        lines.append(f"· {profile.id}: {desc}")
    lines.append(
        "→ Decide on your own — do not ask permission. The task you were given belongs "
        "to another profile's domain → `switch_profile`, called alone, then say which "
        "profile is active and why. Another profile's tools are needed only for a side "
        "piece of your own task → `spawn_agent(profile_id=…)` and keep this session's "
        "profile. Details: `list_profiles`; what a candidate can actually call: "
        "`list_tools(profile_id=…)`."
    )
    return "\n".join(lines)


async def render_todo_lines(session_id: str) -> list[str]:
    """Return a list of formatted todo lines for goal anchoring."""
    try:
        from navi.tools.todo import render_todo_lines as _rtl

        return await _rtl(session_id)
    except Exception:
        return []