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");