Skip to main content

a3s_code_core/context/
assembler.rs

1//! Budgeted assembly for context items.
2
3use super::{ContextItem, ContextResult};
4use std::collections::HashMap;
5
6/// Budget limits for prompt-bound context.
7#[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/// Source-aware limits that prevent one context source from dominating the prompt.
23#[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/// Named budget policy for context assembly.
39#[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/// Final context selected for prompt rendering.
93#[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/// Assembles raw provider results into a ranked, deduplicated, budgeted set.
115#[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}