atomcode_core/ctx/
ollama.rs1use super::CtxBuilder;
29use crate::config::provider::ProviderConfig;
30use crate::conversation::message::Message;
31use crate::conversation::{ContextStats, Conversation};
32use crate::tool::ToolResult;
33
34#[derive(Debug, Clone)]
36pub struct OllamaCtx {
37 ctx_window: usize,
39
40 model_id: String,
44}
45
46impl OllamaCtx {
47 pub fn new(provider: &ProviderConfig) -> Self {
48 Self {
51 ctx_window: provider.context_window.max(4000),
52 model_id: provider.model.to_lowercase(),
53 }
54 }
55
56 fn tool_output_cap(&self) -> usize {
59 (self.ctx_window / 8).min(6_000).max(2_000)
60 }
61}
62
63impl CtxBuilder for OllamaCtx {
64 fn build_messages(
65 &self,
66 conv: &Conversation,
67 system_prompt: &str,
68 turn_reminder: &str,
69 ) -> (Vec<Message>, ContextStats) {
70 let sys = crate::ctx::render::apply_model_directives(system_prompt, &self.model_id);
76 crate::ctx::render::build_messages(conv, &sys, self.ctx_window, turn_reminder)
77 }
78
79 fn needs_compression(&self, conv: &Conversation, system_tokens: usize) -> bool {
85 crate::ctx::render::needs_compression(conv, system_tokens, self.ctx_window)
86 }
87
88 fn compression_plan(&self, conv: &Conversation) -> Option<(String, usize)> {
89 let (content, n) = crate::ctx::render::build_compression_content(conv);
92 if content.is_empty() || n == 0 {
93 None
94 } else {
95 Some((content, n))
96 }
97 }
98
99 fn truncate_tool_output(&self, result: &mut ToolResult, tool_name: &str) {
100 crate::ctx::truncate::truncate_output(result, tool_name, self.ctx_window);
104
105 let cap = self.tool_output_cap();
107 if result.output.len() > cap {
108 let mut boundary = cap;
111 while boundary > 0 && !result.output.is_char_boundary(boundary) {
112 boundary -= 1;
113 }
114 result.output.truncate(boundary);
115 result
116 .output
117 .push_str("\n[... truncated by OllamaCtx (small window) ...]");
118 }
119 }
120
121 fn ctx_window(&self) -> usize {
122 self.ctx_window
123 }
124
125 fn name(&self) -> &'static str {
126 "ollama"
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::conversation::Conversation;
134 use crate::tool::ToolResult;
135
136 fn ollama_provider(ctx: usize) -> ProviderConfig {
137 ProviderConfig {
138 provider_type: "ollama".into(),
139 api_key: None,
140 model: "llama3-8b".into(),
141 base_url: Some("http://localhost:11434".into()),
142 system_prompt: None,
143 user_agent: None,
144 context_window: ctx,
145 max_tokens: None,
146 thinking_type: None,
147 thinking_keep: None,
148 reasoning_history: None,
149 thinking_enabled: None,
150 thinking_budget: None,
151 skip_tls_verify: false,
152 ephemeral: false,
153
154}
155 }
156
157 #[test]
158 fn name_is_ollama() {
159 let o = OllamaCtx::new(&ollama_provider(8_000));
160 assert_eq!(o.name(), "ollama");
161 }
162
163 #[test]
164 fn ctx_window_clamped_to_4k_minimum() {
165 let o = OllamaCtx::new(&ollama_provider(0));
167 assert_eq!(o.ctx_window, 4_000);
168
169 let o = OllamaCtx::new(&ollama_provider(2_000));
170 assert_eq!(o.ctx_window, 4_000);
171
172 let o = OllamaCtx::new(&ollama_provider(8_000));
174 assert_eq!(o.ctx_window, 8_000);
175
176 let o = OllamaCtx::new(&ollama_provider(32_000));
177 assert_eq!(o.ctx_window, 32_000);
178 }
179
180 #[test]
181 fn tool_output_cap_follows_spec() {
182 assert_eq!(
184 OllamaCtx::new(&ollama_provider(8_000)).tool_output_cap(),
185 2_000
186 );
187 assert_eq!(
189 OllamaCtx::new(&ollama_provider(16_000)).tool_output_cap(),
190 2_000
191 );
192 assert_eq!(
194 OllamaCtx::new(&ollama_provider(32_000)).tool_output_cap(),
195 4_000
196 );
197 assert_eq!(
199 OllamaCtx::new(&ollama_provider(64_000)).tool_output_cap(),
200 6_000
201 );
202 }
203
204 #[test]
205 fn truncate_result_enforces_small_cap() {
206 let o = OllamaCtx::new(&ollama_provider(8_000));
207 let mut r = ToolResult {
208 call_id: "t1".into(),
209 output: "x".repeat(50_000),
210 success: true,
211 };
212 o.truncate_tool_output(&mut r, "bash");
213 assert!(
215 r.output.len() <= 2_200,
216 "OllamaCtx truncate 后输出 {} 字节超过 cap 2200",
217 r.output.len()
218 );
219 }
220
221 #[test]
222 fn truncate_result_utf8_safe_on_cjk_boundary() {
223 let o = OllamaCtx::new(&ollama_provider(8_000));
226 let mut r = ToolResult {
227 call_id: "t1".into(),
228 output: "中".repeat(5_000), success: true,
230 };
231 o.truncate_tool_output(&mut r, "bash");
232 assert!(std::str::from_utf8(r.output.as_bytes()).is_ok());
234 assert!(r.output.len() <= 2_200);
235 }
236
237 #[test]
238 fn needs_compression_triggers_earlier_than_default() {
239 let o = OllamaCtx::new(&ollama_provider(8_000));
240 let empty = Conversation::new();
242 assert!(!o.needs_compression(&empty, 100));
243
244 let mut conv = Conversation::new();
246 for i in 0..8 {
247 conv.add_user_message(&format!("user turn {} with moderate content", i));
248 conv.add_assistant_tool_calls(
249 Some(&format!("some assistant reasoning for turn {}", i)),
250 vec![],
251 None,
252 );
253 }
254 assert!(!o.needs_compression(&conv, 50));
256
257 for _ in 0..20 {
259 conv.add_user_message(&"lorem ipsum ".repeat(50).repeat(2)); conv.add_assistant_tool_calls(Some(&"dolor sit amet ".repeat(50)), vec![], None);
261 }
262 assert!(
264 o.needs_compression(&conv, 50),
265 "大对话下 OllamaCtx 应触发压缩(35% threshold)"
266 );
267 }
268
269 #[test]
270 fn compression_plan_none_below_threshold() {
271 let o = OllamaCtx::new(&ollama_provider(8_000));
272 let conv = Conversation::new();
273 assert!(o.compression_plan(&conv).is_none());
274 }
275
276 #[test]
277 fn build_messages_returns_nonempty_for_simple_conv() {
278 let o = OllamaCtx::new(&ollama_provider(8_000));
279 let mut conv = Conversation::new();
280 conv.add_user_message("hello");
281 let (msgs, stats) = o.build_messages(&conv, "SYS", "");
282 assert!(!msgs.is_empty());
283 assert!(stats.sent_tokens <= 8_000);
284 }
285}