1use super::{ContextItem, ContextResult};
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct ContextBudget {
9 pub max_items: usize,
10 pub max_tokens: usize,
11}
12
13impl Default for ContextBudget {
14 fn default() -> Self {
15 Self {
16 max_items: 12,
17 max_tokens: 4_000,
18 }
19 }
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct ContextSourcePolicy {
25 pub max_items_per_source: Option<usize>,
26 pub max_tokens_per_source: Option<usize>,
27}
28
29impl Default for ContextSourcePolicy {
30 fn default() -> Self {
31 Self {
32 max_items_per_source: Some(6),
33 max_tokens_per_source: Some(2_500),
34 }
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct ContextAssemblyPolicy {
41 pub budget: ContextBudget,
42 pub source_policy: ContextSourcePolicy,
43}
44
45impl ContextAssemblyPolicy {
46 pub fn balanced() -> Self {
47 Self {
48 budget: ContextBudget {
49 max_items: 12,
50 max_tokens: 4_000,
51 },
52 source_policy: ContextSourcePolicy {
53 max_items_per_source: Some(6),
54 max_tokens_per_source: Some(2_500),
55 },
56 }
57 }
58
59 pub fn compact() -> Self {
60 Self {
61 budget: ContextBudget {
62 max_items: 8,
63 max_tokens: 2_500,
64 },
65 source_policy: ContextSourcePolicy {
66 max_items_per_source: Some(4),
67 max_tokens_per_source: Some(1_200),
68 },
69 }
70 }
71
72 pub fn expansive() -> Self {
73 Self {
74 budget: ContextBudget {
75 max_items: 20,
76 max_tokens: 8_000,
77 },
78 source_policy: ContextSourcePolicy {
79 max_items_per_source: Some(8),
80 max_tokens_per_source: Some(3_500),
81 },
82 }
83 }
84}
85
86impl Default for ContextAssemblyPolicy {
87 fn default() -> Self {
88 Self::balanced()
89 }
90}
91
92#[derive(Debug, Clone, Default)]
94pub struct ContextAssembly {
95 pub items: Vec<ContextItem>,
96 pub total_tokens: usize,
97 pub truncated: bool,
98}
99
100impl ContextAssembly {
101 pub fn to_xml(&self) -> String {
102 self.items
103 .iter()
104 .map(ContextItem::to_xml)
105 .collect::<Vec<_>>()
106 .join("\n\n")
107 }
108
109 pub fn is_empty(&self) -> bool {
110 self.items.is_empty()
111 }
112}
113
114#[derive(Debug, Clone)]
116pub struct ContextAssembler {
117 budget: ContextBudget,
118 source_policy: ContextSourcePolicy,
119}
120
121impl ContextAssembler {
122 pub fn new(budget: ContextBudget) -> Self {
123 Self::from_policy(ContextAssemblyPolicy {
124 budget,
125 source_policy: ContextSourcePolicy::default(),
126 })
127 }
128
129 pub fn from_policy(policy: ContextAssemblyPolicy) -> Self {
130 Self {
131 budget: policy.budget,
132 source_policy: policy.source_policy,
133 }
134 }
135
136 pub fn with_source_policy(mut self, policy: ContextSourcePolicy) -> Self {
137 self.source_policy = policy;
138 self
139 }
140
141 pub fn with_default_budget() -> Self {
142 Self::from_policy(ContextAssemblyPolicy::balanced())
143 }
144
145 pub fn assemble(&self, results: &[ContextResult]) -> ContextAssembly {
146 let mut deduped: HashMap<String, ContextItem> = HashMap::new();
147 let mut source_count = 0usize;
148
149 for result in results {
150 for item in &result.items {
151 source_count = source_count.saturating_add(1);
152 let key = dedupe_key(item);
153 match deduped.get(&key) {
154 Some(existing)
155 if ranking_score(existing)
156 .total_cmp(&ranking_score(item))
157 .then_with(|| existing.relevance.total_cmp(&item.relevance))
158 .is_ge() => {}
159 _ => {
160 deduped.insert(key, item.clone());
161 }
162 }
163 }
164 }
165
166 let mut items = deduped.into_values().collect::<Vec<_>>();
167 items.sort_by(|a, b| {
168 b.is_required()
169 .cmp(&a.is_required())
170 .then_with(|| ranking_score(b).total_cmp(&ranking_score(a)))
171 .then_with(|| b.relevance.total_cmp(&a.relevance))
172 .then_with(|| estimated_tokens(a).cmp(&estimated_tokens(b)))
173 .then_with(|| a.id.cmp(&b.id))
174 });
175
176 let mut selected = Vec::new();
177 let mut total_tokens = 0usize;
178 let mut truncated = source_count > items.len();
179 let mut source_item_counts: HashMap<String, usize> = HashMap::new();
180 let mut source_token_counts: HashMap<String, usize> = HashMap::new();
181
182 for item in items {
183 if item.is_required() {
184 total_tokens = total_tokens.saturating_add(estimated_tokens(&item));
185 selected.push(item);
186 continue;
187 }
188 if selected.len() >= self.budget.max_items {
189 truncated = true;
190 break;
191 }
192
193 let item_tokens = estimated_tokens(&item);
194 if total_tokens.saturating_add(item_tokens) > self.budget.max_tokens {
195 truncated = true;
196 continue;
197 }
198
199 let source_key = source_policy_key(&item);
200 if let Some(max_items) = self.source_policy.max_items_per_source {
201 let count = source_item_counts.get(&source_key).copied().unwrap_or(0);
202 if count >= max_items {
203 truncated = true;
204 continue;
205 }
206 }
207 if let Some(max_tokens) = self.source_policy.max_tokens_per_source {
208 let source_tokens = source_token_counts.get(&source_key).copied().unwrap_or(0);
209 if source_tokens.saturating_add(item_tokens) > max_tokens {
210 truncated = true;
211 continue;
212 }
213 }
214
215 total_tokens = total_tokens.saturating_add(item_tokens);
216 let item_count = source_item_counts.entry(source_key.clone()).or_insert(0);
217 *item_count = item_count.saturating_add(1);
218 let source_tokens = source_token_counts.entry(source_key).or_insert(0);
219 *source_tokens = source_tokens.saturating_add(item_tokens);
220 selected.push(item);
221 }
222
223 ContextAssembly {
224 items: selected,
225 total_tokens,
226 truncated,
227 }
228 }
229}
230
231fn dedupe_key(item: &ContextItem) -> String {
232 item.source.clone().unwrap_or_else(|| item.id.clone())
233}
234
235fn source_policy_key(item: &ContextItem) -> String {
236 if let Some(provenance) = item.provenance() {
237 return format!("provenance:{provenance}");
238 }
239
240 if let Some(source) = &item.source {
241 let family = source
242 .split_once(':')
243 .map(|(family, _)| family)
244 .unwrap_or(source);
245 return format!("source:{family}");
246 }
247
248 format!("type:{:?}", item.context_type)
249}
250
251fn estimated_tokens(item: &ContextItem) -> usize {
252 if item.token_count > 0 {
253 item.token_count
254 } else {
255 item.content.split_whitespace().count().max(1)
256 }
257}
258
259fn ranking_score(item: &ContextItem) -> f32 {
260 item.relevance + item.priority() * 0.25 + item.trust() * 0.15 + item.freshness() * 0.10
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::context::{ContextItem, ContextResult, ContextType};
267
268 fn result(provider: &str, items: Vec<ContextItem>) -> ContextResult {
269 let mut result = ContextResult::new(provider);
270 for item in items {
271 result.add_item(item);
272 }
273 result
274 }
275
276 #[test]
277 fn balanced_policy_matches_default_budget_and_source_caps() {
278 let policy = ContextAssemblyPolicy::balanced();
279
280 assert_eq!(policy.budget, ContextBudget::default());
281 assert_eq!(policy.source_policy, ContextSourcePolicy::default());
282 }
283
284 #[test]
285 fn compact_policy_applies_tighter_caps() {
286 let assembler = ContextAssembler::from_policy(ContextAssemblyPolicy::compact());
287 let assembly = assembler.assemble(&[result(
288 "test",
289 (0..10)
290 .map(|index| {
291 ContextItem::new(
292 format!("file-{index}"),
293 ContextType::Resource,
294 format!("file {index}"),
295 )
296 .with_source(format!("file://{index}"))
297 .with_relevance(1.0 - index as f32 * 0.01)
298 .with_token_count(1)
299 })
300 .collect(),
301 )]);
302
303 assert_eq!(assembly.items.len(), 4);
304 assert!(assembly.truncated);
305 }
306
307 #[test]
308 fn expansive_policy_allows_broader_context() {
309 let assembler = ContextAssembler::from_policy(ContextAssemblyPolicy::expansive());
310 let assembly = assembler.assemble(&[result(
311 "test",
312 (0..8)
313 .map(|index| {
314 ContextItem::new(
315 format!("file-{index}"),
316 ContextType::Resource,
317 format!("file {index}"),
318 )
319 .with_source(format!("file://{index}"))
320 .with_relevance(1.0 - index as f32 * 0.01)
321 .with_token_count(1)
322 })
323 .collect(),
324 )]);
325
326 assert_eq!(assembly.items.len(), 8);
327 assert!(!assembly.truncated);
328 }
329
330 #[test]
331 fn assemble_ranks_by_relevance() {
332 let assembler = ContextAssembler::new(ContextBudget {
333 max_items: 10,
334 max_tokens: 100,
335 });
336 let assembly = assembler.assemble(&[result(
337 "test",
338 vec![
339 ContextItem::new("low", ContextType::Resource, "low")
340 .with_relevance(0.1)
341 .with_token_count(1),
342 ContextItem::new("high", ContextType::Resource, "high")
343 .with_relevance(0.9)
344 .with_token_count(1),
345 ],
346 )]);
347
348 assert_eq!(assembly.items[0].id, "high");
349 assert_eq!(assembly.items[1].id, "low");
350 assert!(!assembly.truncated);
351 }
352
353 #[test]
354 fn assemble_uses_priority_trust_and_freshness_as_ranking_signals() {
355 let assembler = ContextAssembler::new(ContextBudget {
356 max_items: 10,
357 max_tokens: 100,
358 });
359 let assembly = assembler.assemble(&[result(
360 "test",
361 vec![
362 ContextItem::new("plain", ContextType::Resource, "plain")
363 .with_relevance(0.7)
364 .with_token_count(1),
365 ContextItem::new("boosted", ContextType::Resource, "boosted")
366 .with_relevance(0.6)
367 .with_priority(1.0)
368 .with_trust(1.0)
369 .with_freshness(1.0)
370 .with_token_count(1),
371 ],
372 )]);
373
374 assert_eq!(assembly.items[0].id, "boosted");
375 assert_eq!(assembly.items[1].id, "plain");
376 }
377
378 #[test]
379 fn assemble_dedupes_by_source_and_keeps_more_relevant_item() {
380 let assembler = ContextAssembler::with_default_budget();
381 let assembly = assembler.assemble(&[result(
382 "test",
383 vec![
384 ContextItem::new("old", ContextType::Resource, "old")
385 .with_source("file://auth.rs")
386 .with_relevance(0.2),
387 ContextItem::new("new", ContextType::Resource, "new")
388 .with_source("file://auth.rs")
389 .with_relevance(0.8),
390 ],
391 )]);
392
393 assert_eq!(assembly.items.len(), 1);
394 assert_eq!(assembly.items[0].id, "new");
395 assert!(assembly.truncated);
396 }
397
398 #[test]
399 fn assemble_dedupes_by_ranking_score() {
400 let assembler = ContextAssembler::with_default_budget();
401 let assembly = assembler.assemble(&[result(
402 "test",
403 vec![
404 ContextItem::new("plain", ContextType::Resource, "plain")
405 .with_source("file://auth.rs")
406 .with_relevance(0.7),
407 ContextItem::new("boosted", ContextType::Resource, "boosted")
408 .with_source("file://auth.rs")
409 .with_relevance(0.6)
410 .with_priority(1.0),
411 ],
412 )]);
413
414 assert_eq!(assembly.items.len(), 1);
415 assert_eq!(assembly.items[0].id, "boosted");
416 assert!(assembly.truncated);
417 }
418
419 #[test]
420 fn assemble_respects_item_and_token_budget() {
421 let assembler = ContextAssembler::new(ContextBudget {
422 max_items: 1,
423 max_tokens: 5,
424 });
425 let assembly = assembler.assemble(&[result(
426 "test",
427 vec![
428 ContextItem::new("a", ContextType::Resource, "one two")
429 .with_relevance(0.9)
430 .with_token_count(2),
431 ContextItem::new("b", ContextType::Resource, "three four")
432 .with_relevance(0.8)
433 .with_token_count(2),
434 ],
435 )]);
436
437 assert_eq!(assembly.items.len(), 1);
438 assert_eq!(assembly.total_tokens, 2);
439 assert!(assembly.truncated);
440 }
441
442 #[test]
443 fn assemble_caps_items_per_source() {
444 let assembler = ContextAssembler::new(ContextBudget {
445 max_items: 10,
446 max_tokens: 100,
447 })
448 .with_source_policy(ContextSourcePolicy {
449 max_items_per_source: Some(2),
450 max_tokens_per_source: None,
451 });
452 let assembly = assembler.assemble(&[result(
453 "test",
454 vec![
455 ContextItem::new("a", ContextType::Resource, "a")
456 .with_source("file://a")
457 .with_relevance(0.9)
458 .with_token_count(1),
459 ContextItem::new("b", ContextType::Resource, "b")
460 .with_source("file://b")
461 .with_relevance(0.8)
462 .with_token_count(1),
463 ContextItem::new("c", ContextType::Resource, "c")
464 .with_source("file://c")
465 .with_relevance(0.7)
466 .with_token_count(1),
467 ],
468 )]);
469
470 assert_eq!(assembly.items.len(), 2);
471 assert_eq!(assembly.items[0].id, "a");
472 assert_eq!(assembly.items[1].id, "b");
473 assert!(assembly.truncated);
474 }
475
476 #[test]
477 fn assemble_caps_tokens_per_source_but_keeps_other_sources() {
478 let assembler = ContextAssembler::new(ContextBudget {
479 max_items: 10,
480 max_tokens: 100,
481 })
482 .with_source_policy(ContextSourcePolicy {
483 max_items_per_source: None,
484 max_tokens_per_source: Some(3),
485 });
486 let assembly = assembler.assemble(&[result(
487 "test",
488 vec![
489 ContextItem::new("file-a", ContextType::Resource, "file a")
490 .with_source("file://a")
491 .with_relevance(0.9)
492 .with_token_count(2),
493 ContextItem::new("file-b", ContextType::Resource, "file b")
494 .with_source("file://b")
495 .with_relevance(0.8)
496 .with_token_count(2),
497 ContextItem::new("memory", ContextType::Memory, "memory")
498 .with_source("memory://a")
499 .with_relevance(0.7)
500 .with_token_count(2),
501 ],
502 )]);
503
504 assert_eq!(
505 assembly
506 .items
507 .iter()
508 .map(|item| item.id.as_str())
509 .collect::<Vec<_>>(),
510 vec!["file-a", "memory"]
511 );
512 assert_eq!(assembly.total_tokens, 4);
513 assert!(assembly.truncated);
514 }
515
516 #[test]
517 fn required_workspace_instructions_bypass_generic_context_budgets() {
518 let assembler = ContextAssembler::new(ContextBudget {
519 max_items: 1,
520 max_tokens: 2,
521 })
522 .with_source_policy(ContextSourcePolicy {
523 max_items_per_source: Some(1),
524 max_tokens_per_source: Some(1),
525 });
526 let instructions = ContextItem::new(
527 "agents_md",
528 ContextType::Resource,
529 "one two three four five six",
530 )
531 .with_source("a3s://workspace-instructions")
532 .with_provenance("workspace_instructions")
533 .with_token_count(6)
534 .with_required();
535 let ordinary = ContextItem::new("ordinary", ContextType::Resource, "ordinary")
536 .with_token_count(1)
537 .with_relevance(1.0);
538
539 let assembly = assembler.assemble(&[result("test", vec![ordinary, instructions])]);
540
541 assert_eq!(assembly.items.len(), 1);
542 assert_eq!(assembly.items[0].id, "agents_md");
543 assert_eq!(assembly.total_tokens, 6);
544 assert!(assembly.truncated);
545 }
546
547 #[test]
548 fn assemble_saturates_token_accounting_on_overflow() {
549 let assembler = ContextAssembler::new(ContextBudget {
550 max_items: usize::MAX,
551 max_tokens: usize::MAX,
552 })
553 .with_source_policy(ContextSourcePolicy {
554 max_items_per_source: None,
555 max_tokens_per_source: None,
556 });
557 let assembly = assembler.assemble(&[result(
558 "test",
559 vec![
560 ContextItem::new("large", ContextType::Resource, "large")
561 .with_token_count(usize::MAX),
562 ContextItem::new("next", ContextType::Resource, "next").with_token_count(1),
563 ],
564 )]);
565
566 assert_eq!(assembly.items.len(), 2);
567 assert_eq!(assembly.total_tokens, usize::MAX);
568 }
569}