Source code for klea_utils.nodes.summarise_memory

#!/usr/bin/env python3
"""
Summarise conversation history node

File: klea_utils/nodes/summarise_memory.py

Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""

import logging
from typing import Any, ClassVar, override

from langchain_core.messages import AIMessage
from pydantic import BaseModel

from ..llm import (
    content_to_str,
    get_last_n_conversations,
    get_recent_messages,
    split_output_by_section,
)
from .base import BaseLLMNode

#: Default char budget for the recent verbatim window that is kept out of
#: the summary.  Must match the base node's ``num_history_chars`` default so
#: the verbatim window in prompts and the window excluded here stay aligned.
_DEFAULT_NUM_HISTORY_CHARS = 10_000


[docs] class SummariseMemoryNode(BaseLLMNode): model_type = "chat" model_defaults: ClassVar[dict[str, Any]] = { "temperature": 0.3, "max_output_tokens": 4096, } """Node that summarises conversation history into a context summary. Uses _pre_exec() to skip execution if there isn't enough *old* conversation to summarise. The most recent messages (within ``num_history_chars``) form the verbatim window that the prompt assembly injects as real messages, so this node only summarises history up to that window -- the summary and the verbatim window never overlap. Does NOT append the summary to messages -- it's metadata, not a turn. Expects state to have the following fields: - messages: list of messages - summarised_till: index of messages that have been summarised already - context_summary: previous memory/context summary """ def __init__( self, logger: logging.Logger, label: str, llm_models: dict[str, Any], summarisation_threshold_chars: int = 10_000, num_history_chars: int = _DEFAULT_NUM_HISTORY_CHARS, memory: bool = False, ): """Initialise the summarisation node. :param logger: Logger instance :param label: Human-readable label for UI progress display :param llm_models: ``{role: LLMModel}`` dict (from ``BaseLangGraph.llm_models``) :param summarisation_threshold_chars: Minimum characters of old conversation (before the recent verbatim window) before summarising. ``0`` summarises as soon as there is any old history. :param num_history_chars: Character budget for the recent verbatim window kept out of the summary. :param memory: Whether to include conversation history in the prompt """ super().__init__( logger=logger, label=label, llm_models=llm_models, output_schema=None, memory=memory, ) self.summarisation_threshold_chars = summarisation_threshold_chars self.num_history_chars = num_history_chars self.conversation = "" self._window_start = 0 @override def _pre_exec(self, state: BaseModel) -> bool: """Skip if not enough old conversation to summarise.""" recent = get_recent_messages( state.messages, # type: ignore self.num_history_chars, ) self._window_start = len(state.messages) - len(recent) # type: ignore self.conversation, _ = get_last_n_conversations( state.messages, # type: ignore state.summarised_till, # type: ignore self._window_start, ) if self._window_start <= state.summarised_till: # type: ignore self.logger.debug("No new history to summarise yet") return False if len(self.conversation) < self.summarisation_threshold_chars: self.logger.debug( f"Not enough conversation to summarise yet: " f"{len(self.conversation)}/{self.summarisation_threshold_chars} chars" ) return False return True @override def _get_prompt_variables(self, state: BaseModel) -> dict: """Format prompt with conversation data.""" return { "old_summary": state.context_summary, # type: ignore "conversation": self.conversation, } @override def _update_state(self, result: Any, state: BaseModel) -> dict[str, Any]: """Extract summary from raw AIMessage output.""" self.logger.debug(f"Current history summary is:\n{result.content}") content = content_to_str(result.content) _, answer = split_output_by_section(content, "<think>", "</think>") return { "context_summary": answer, "summarised_till": self._window_start, } # TODO: may need updating @override def _get_default_error_result(self) -> AIMessage: """Return default result when processing fails.""" return AIMessage(content="")