Skip to main content

theway_core/agent/compaction/
algorithm.rs

1//! Custom compaction algorithm interface (issue #4).
2//!
3//! `compact()` no longer hard-codes one strategy: it dispatches through
4//! [`CompactAlgorithm`], which composes the three classic decision points —
5//! *when to trigger*, *where to cut*, and *how to summarize*.
6//!
7//! - [`BuiltinCompactAlgorithm`] is the shipped default: the 80%-window trigger heuristic,
8//!   the turn-boundary-safe `keep_recent_tokens` cut, and LLM summarization (with the
9//!   overflow-budget retry loop).
10//! - Custom algorithms implement the same trait. The TS path is host-wired: the CLI
11//!   (`theway-daemon::ts_extensions`) discovers `kind = "compaction"` extensions,
12//!   adapts them to [`CompactAlgorithm`], and injects the registry via
13//!   `AgentHarnessOptions.compact_algorithms` — the core never loads extensions itself.
14//!
15//! The trait methods carry defaults that delegate to the same free functions the builtin
16//! uses, so a custom algorithm that overrides only `select_cut_point` still gets the builtin
17//! trigger + summarizer for free.
18
19use std::collections::HashMap;
20use std::sync::Arc;
21
22use async_trait::async_trait;
23use theway_llm_provider::{Model, Usage};
24use tokio_util::sync::CancellationToken;
25
26use super::super::session::session::SessionTreeEntry;
27use super::compaction::{
28    CompactionSettings, CutPointResult, SummarizeError, find_cut_point, should_compact,
29    summarize_with_llm,
30};
31use crate::types::{AgentMessage, StreamFn};
32
33/// Everything an algorithm needs to produce a summary of the folded prefix.
34#[derive(Clone)]
35pub struct SummarizeRequest<'a> {
36    pub model: &'a Model,
37    /// The message prefix being folded (already cut by [`CompactAlgorithm::select_cut_point`]).
38    pub messages: &'a [AgentMessage],
39    pub custom_instructions: Option<&'a str>,
40    pub settings: &'a CompactionSettings,
41    /// Override stream function; `None` falls back to `theway_llm_provider::stream_simple`.
42    pub stream_fn: Option<&'a StreamFn>,
43    pub cancel: &'a CancellationToken,
44}
45
46/// Result of a summarize hook (`summarize_prefix`). `usage` is meaningful for LLM-backed
47/// algorithms; custom
48/// (e.g. TS) algorithms return `Usage::default()`.
49#[derive(Clone, Debug)]
50pub struct SummaryOutcome {
51    pub summary: String,
52    pub usage: Usage,
53}
54
55/// Custom compaction algorithm — the extension point behind issue #4.
56///
57/// Every method has a default that reproduces the builtin behavior, so an implementation
58/// only overrides the hooks it wants to customize. All methods are async so a future host
59/// (e.g. one that calls the LLM from inside the extension) can block on IO.
60#[async_trait]
61pub trait CompactAlgorithm: Send + Sync {
62    /// Canonical name — matched against `CompactionSettings.algorithm`.
63    fn name(&self) -> &str;
64
65    /// Decide whether a compaction should trigger at this context level.
66    /// Default: the builtin 80%-of-window heuristic.
67    async fn decide_compact(
68        &self,
69        context_tokens: u64,
70        context_window: u32,
71        settings: &CompactionSettings,
72    ) -> bool {
73        should_compact(context_tokens, context_window, settings)
74    }
75
76    /// Choose the cut point (entries[..cut] get folded). Must land on a valid index in
77    /// `[0, entries.len()]`. Default: turn-boundary-safe `keep_recent_tokens` walk.
78    async fn select_cut_point(
79        &self,
80        entries: &[SessionTreeEntry],
81        settings: &CompactionSettings,
82    ) -> CutPointResult {
83        find_cut_point(entries, settings)
84    }
85
86    /// Summarize the folded prefix. Default: LLM summarization with the budget-retry loop.
87    async fn summarize_prefix(
88        &self,
89        request: &SummarizeRequest<'_>,
90    ) -> Result<SummaryOutcome, SummarizeError> {
91        summarize_with_llm(request).await
92    }
93}
94
95/// The shipped default algorithm. All hooks are the trait defaults (builtin behavior).
96#[derive(Clone, Copy, Debug, Default)]
97pub struct BuiltinCompactAlgorithm;
98
99#[async_trait]
100impl CompactAlgorithm for BuiltinCompactAlgorithm {
101    fn name(&self) -> &str {
102        "builtin"
103    }
104}
105
106/// Resolves `CompactionSettings.algorithm` names to implementations. Holds the custom
107/// algorithms (host-injected, e.g. TS extensions); the builtin is always available as
108/// fallback.
109pub struct CompactAlgorithmRegistry {
110    custom: parking_lot::RwLock<HashMap<String, Arc<dyn CompactAlgorithm>>>,
111}
112
113impl Default for CompactAlgorithmRegistry {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119impl CompactAlgorithmRegistry {
120    pub fn new() -> Self {
121        Self {
122            custom: parking_lot::RwLock::new(HashMap::new()),
123        }
124    }
125
126    /// Register a custom algorithm by name. The builtin can never be shadowed.
127    pub fn register(&self, algorithm: Arc<dyn CompactAlgorithm>) {
128        if algorithm.name() != "builtin" {
129            self.custom
130                .write()
131                .insert(algorithm.name().to_string(), algorithm);
132        }
133    }
134
135    /// Atomically replace every custom algorithm while keeping the builtin fallback available.
136    pub fn replace_custom(&self, algorithms: impl IntoIterator<Item = Arc<dyn CompactAlgorithm>>) {
137        let mut replacement = HashMap::new();
138        for algorithm in algorithms {
139            if algorithm.name() != "builtin" {
140                replacement.insert(algorithm.name().to_string(), algorithm);
141            }
142        }
143        *self.custom.write() = replacement;
144    }
145
146    /// Resolve an algorithm name. Unknown / empty names fall back to the builtin with a
147    /// warning — a bad setting must never take down the agent.
148    pub fn algorithm(&self, name: &str) -> Arc<dyn CompactAlgorithm> {
149        if name.is_empty() || name == "builtin" {
150            return Arc::new(BuiltinCompactAlgorithm);
151        }
152        match self.custom.read().get(name) {
153            Some(a) => a.clone(),
154            None => {
155                tracing::warn!(
156                    algorithm = name,
157                    "unknown compaction algorithm, falling back to builtin"
158                );
159                Arc::new(BuiltinCompactAlgorithm)
160            }
161        }
162    }
163
164    /// Names of all registered custom algorithms (excluding the builtin).
165    pub fn custom_names(&self) -> Vec<String> {
166        let mut names: Vec<String> = self.custom.read().keys().cloned().collect();
167        names.sort();
168        names
169    }
170}
171
172#[cfg(test)]
173tests_bridge_macro::tests_bridge!("agent/compaction/algorithm");