Newer
Older
navi-1 / navi / core / agent / runtime.py
"""Turn lifecycle: run, ephemeral runs, compaction and prompt-cache invalidation."""

from typing import TYPE_CHECKING
from collections.abc import AsyncGenerator

import structlog

from navi.config import settings
from navi.exceptions import (
    NothingToCompactError,
    SessionNotFound,
)
from navi.llm.base import Message

from ..events import (
    AgentEvent,
    CompressionStarted,
    StreamEnd,
)
from ..subagent_runner import SubAgentOutcome



log = structlog.get_logger()


class AgentRuntimeMixin:
    async def run(
        self,
        session_id: str,
        user_message: str,
        images: list[str] | None = None,
        files: list[dict] | None = None,
        is_recall: bool = False,
    ) -> str:
        """Non-streaming: run the full tool-calling loop and return the final text."""
        full_content = ""
        async for event in self.run_stream(
            session_id, user_message, images=images, files=files, is_recall=is_recall
        ):
            if isinstance(event, StreamEnd):
                full_content = event.full_content or ""
        return full_content

    async def _drain_task_notes(self, session_id: str, session) -> None:
        """Move finished-background-task notes into the session context.

        Called once at the start of run_stream(), before the first LLM call:
        the note becomes part of the LLM-visible context (and the compression
        window) but is never displayed — clients learned about completions via
        the live task_update event. Only the foreground run_stream mutates the
        session, so there is no race with background jobs.
        """
        from navi.core import task_notes

        try:
            note = await task_notes.drain(session_id)
        except Exception:
            log.exception("agent.task_notes_drain_failed", session_id=session_id)
            return
        if not note:
            return
        session.context.append(
            Message(
                role="system",
                content=note,
                metadata={"source": "task_note"},
                is_display=False,
            )
        )
        await self._sessions.save(session)
        log.info("agent.task_notes_drained", session_id=session_id)

    async def run_ephemeral(
        self,
        user_message: str,
        profile_id: str,
        max_iterations: int = 40,
        exclude_tools: list[str] | None = None,
        briefing: str | None = None,
        custom_system_prompt: str | None = None,
        inherit_system_prompt: bool = False,
        context_transfer: str | None = None,
        parent_session_id: str | None = None,
        timeout_seconds: float = 300.0,
    ) -> SubAgentOutcome:
        """Delegate to SubAgentRunner."""
        return await self._subagent.run(
            user_message=user_message,
            profile_id=profile_id,
            max_iterations=max_iterations,
            exclude_tools=exclude_tools,
            briefing=briefing,
            custom_system_prompt=custom_system_prompt,
            inherit_system_prompt=inherit_system_prompt,
            context_transfer=context_transfer,
            parent_session_id=parent_session_id,
            timeout_seconds=timeout_seconds,
        )

    async def compact_stream(self, session_id: str) -> AsyncGenerator[AgentEvent, None]:
        """Force context compression now, bypassing the token threshold.

        Triggered by the TUI ``/compact`` command. Unlike the pre/mid-turn
        auto-compression (gated by ``should_compress``), this always attempts a
        summary pass as long as there is enough to summarize. Yields
        ``CompressionStarted`` then ``ContextCompressed`` on success; raises
        ``NothingToCompactError`` when the context is too small to compress
        (the orchestrator surfaces it as an error event so the user gets
        feedback).

        Emits NO ``StreamStart``/``StreamEnd`` — the client distinguishes a
        forced compact from an in-turn auto-compress by the absence of a
        streaming turn (the TUI keys off its ``_streaming`` flag).
        """
        from navi.tools._internal.base import current_session_id as _sid_var

        session = await self._sessions.get(session_id)
        if session is None:
            raise SessionNotFound(session_id)
        profile = self._profiles.get(session.profile_id)
        self._set_active_profile(profile)
        llm = self._get_backend(profile.llm_backend)

        _sid_token = _sid_var.set(session_id)
        try:
            if not settings.context_compression_enabled:
                raise NothingToCompactError("Context compression is disabled.")

            window = self._window_for(profile)
            yield CompressionStarted(
                # Real-baseline estimate when available (see _compression_events_preturn);
                # the calibrated estimate is only the fallback before the first LLM
                # call of a session.
                context_tokens=self._compressor.real_baseline_estimate(
                    session.context, session.context, profile.model
                ),
                max_context_tokens=window,
            )
            event = await self._compressor.compress_and_save_session(
                session=session,
                session_store=self._sessions,
                llm=llm,
                model=profile.model,
                temperature=settings.context_summary_temperature,
                session_id=session_id,
                reason="forced",
                memory_store=self._memory,
                keep_recent=settings.context_keep_recent,
                max_tokens=settings.context_summary_max_tokens,
                # Enable the intra-turn fallback so a single long autonomous turn
                # (one user message + many tool iterations = one "turn", the
                # common navi_code shape) can still be compressed — without this
                # keep_recent_messages, partition_messages finds nothing to
                # summarize when turns <= keep_recent and forced compact always
                # reports "nothing to compact". Mirrors the midturn auto-compress.
                keep_recent_messages=max(12, settings.context_keep_recent * 2),
                window=window,
            )
            if event is None:
                raise NothingToCompactError(
                    "Nothing to compact yet — the context is still small."
                )
            yield event
        finally:
            _sid_var.reset(_sid_token)

    def _set_active_profile(self, profile) -> None:
        """Update cached profile and propagate to subsystems that depend on it."""
        self._compressor.set_profile(profile)
        self._subagent.set_profile(profile)

    def invalidate_prompt_cache(self, profile_id: str | None = None) -> None:
        """Drop cached system prompts so a profile change reaches the next turn.

        The cache lives on this agent's ContextBuilder, and the agent handed out
        by the container is process-wide — so an admin toggling a profile's
        availability must clear it, or every other prompt keeps advertising the
        profile list as it was.
        """
        self._ctx_builder.invalidate_system_prompt_cache(profile_id)