1use std::path::PathBuf;
27
28use super::support::{ratio_of, scope_depth};
29use crate::context::{
30 ContextBudget, ContextDiagnostic, ContextItem, ContextItemKind, ContextLedger, ContextVisibility,
31 DiagnosticSeverity, ModelContextLimits, estimate_tokens, item_id_for_path, item_id_for_session_range,
32};
33
34#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct TranscriptCandidate {
41 pub seq: u64,
43 pub session_id: String,
45 pub label: String,
47 pub bytes: usize,
49 pub artifact_handle: Option<String>,
51 pub ui_only: bool,
53 pub streaming: bool,
55}
56
57impl TranscriptCandidate {
58 pub fn new(session_id: impl Into<String>, seq: u64, label: impl Into<String>, bytes: usize) -> Self {
60 TranscriptCandidate {
61 seq,
62 session_id: session_id.into(),
63 label: label.into(),
64 bytes,
65 artifact_handle: None,
66 ui_only: false,
67 streaming: false,
68 }
69 }
70
71 pub fn ui_only(mut self) -> Self {
73 self.ui_only = true;
74 self
75 }
76
77 pub fn streaming(mut self) -> Self {
79 self.streaming = true;
80 self
81 }
82}
83
84#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct PinnedCandidate {
90 pub id: String,
92 pub kind: ContextItemKind,
94 pub label: String,
96 pub source_path: Option<PathBuf>,
98 pub scope: String,
100 pub content_hash: Option<u64>,
102 pub artifact_handle: Option<String>,
104 pub bytes: usize,
106}
107
108impl PinnedCandidate {
109 pub fn file(kind: ContextItemKind, path: PathBuf, scope: impl Into<String>, bytes: usize) -> Self {
111 let id = item_id_for_path(&kind, &path);
112 let label = path.display().to_string();
113 PinnedCandidate {
114 id,
115 kind,
116 label,
117 source_path: Some(path),
118 scope: scope.into(),
119 content_hash: None,
120 artifact_handle: None,
121 bytes,
122 }
123 }
124}
125
126#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct CompactionSummaryCandidate {
129 pub id: String,
131 pub start_seq: u64,
133 pub end_seq: u64,
135 pub label: String,
137 pub content: Option<String>,
141 pub bytes: usize,
143 pub latest: bool,
145}
146
147impl CompactionSummaryCandidate {
148 pub fn new(session_id: &str, start_seq: u64, end_seq: u64, bytes: usize, latest: bool) -> Self {
150 let id = item_id_for_session_range(&ContextItemKind::Summary, session_id, start_seq, end_seq);
151 CompactionSummaryCandidate {
152 id,
153 start_seq,
154 end_seq,
155 label: format!("summary {start_seq}..{end_seq}"),
156 content: None,
157 bytes,
158 latest,
159 }
160 }
161}
162
163#[derive(Clone, Debug, Eq, PartialEq)]
165pub struct SkillCandidate {
166 pub name: String,
168 pub path: PathBuf,
170 pub content_hash: u64,
172 pub bytes: usize,
174 pub loaded: bool,
177}
178
179impl SkillCandidate {
180 pub fn discovered(name: impl Into<String>, path: PathBuf, content_hash: u64, bytes: usize) -> Self {
182 SkillCandidate { name: name.into(), path, content_hash, bytes, loaded: false }
183 }
184
185 pub fn loaded(name: impl Into<String>, path: PathBuf, rendered_bytes: usize, rendered_hash: u64) -> Self {
187 SkillCandidate { name: name.into(), path, content_hash: rendered_hash, bytes: rendered_bytes, loaded: true }
188 }
189}
190
191#[derive(Clone, Debug, Eq, PartialEq)]
197pub struct InstructionCandidate {
198 pub path: PathBuf,
200 pub scope: String,
202 pub content_hash: u64,
204 pub byte_count: usize,
206 pub content: Option<String>,
210 pub truncated: bool,
212 pub applicable: bool,
215}
216
217impl InstructionCandidate {
218 pub fn scope_depth(&self) -> usize {
220 scope_depth(&self.scope)
221 }
222}
223
224#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct HarnessCandidate {
230 pub id: String,
232 pub label: String,
234 pub bytes: usize,
236}
237
238impl HarnessCandidate {
239 pub fn new(label: impl Into<String>, bytes: usize) -> Self {
241 let label = label.into();
242 let id = format!("ctx_harness_{}", slug(&label));
243 HarnessCandidate { id, label, bytes }
244 }
245}
246
247#[derive(Clone, Debug, Default, Eq, PartialEq)]
253pub struct SelectionInput {
254 pub harness: Vec<HarnessCandidate>,
256 pub user_turn: Option<UserTurnCandidate>,
258 pub instructions: Vec<InstructionCandidate>,
260 pub pins: Vec<PinnedCandidate>,
262 pub compaction_summaries: Vec<CompactionSummaryCandidate>,
264 pub transcript: Vec<TranscriptCandidate>,
266 pub skills: Vec<SkillCandidate>,
268 pub dropped_ids: Vec<String>,
270}
271
272#[derive(Clone, Debug, Eq, PartialEq)]
274pub struct UserTurnCandidate {
275 pub id: String,
277 pub bytes: usize,
279}
280
281impl UserTurnCandidate {
282 pub fn new(session_id: &str, seq: u64, bytes: usize) -> Self {
284 UserTurnCandidate { id: item_id_for_session_range(&ContextItemKind::Transcript, session_id, seq, seq), bytes }
285 }
286}
287
288#[expect(
293 clippy::cognitive_complexity,
294 reason = "The selection order is intentionally explicit so every candidate receives an auditable state and reason."
295)]
296pub fn select_context(input: &SelectionInput, limits: ModelContextLimits) -> ContextLedger {
297 let mut items: Vec<ContextItem> = Vec::new();
298 let mut diagnostics = Vec::new();
299
300 let available_input = limits.available_input_budget();
301
302 for harness in &input.harness {
303 items.push(ContextItem {
304 id: harness.id.clone(),
305 kind: ContextItemKind::Harness,
306 label: harness.label.clone(),
307 source_path: None,
308 scope: ".".to_string(),
309 content_hash: None,
310 artifact_handle: None,
311 byte_count: harness.bytes,
312 content: None,
313 token_estimate: estimate_tokens(harness.bytes),
314 visibility: ContextVisibility::Visible,
315 reason_code: "harness_always_loaded".to_string(),
316 reason: "always-loaded harness context".to_string(),
317 });
318 }
319
320 if let Some(turn) = &input.user_turn {
321 items.push(ContextItem {
322 id: turn.id.clone(),
323 kind: ContextItemKind::Transcript,
324 label: "current user turn".to_string(),
325 source_path: None,
326 scope: ".".to_string(),
327 content_hash: None,
328 artifact_handle: None,
329 byte_count: turn.bytes,
330 content: None,
331 token_estimate: estimate_tokens(turn.bytes),
332 visibility: ContextVisibility::Visible,
333 reason_code: "current_user_turn".to_string(),
334 reason: "current user turn, outside ordinary budget eviction".to_string(),
335 });
336 }
337
338 for pin in &input.pins {
339 let visibility = if input.dropped_ids.iter().any(|id| id == &pin.id) {
340 ContextVisibility::Dropped
341 } else if estimate_tokens(pin.bytes) as u64 > available_input {
342 diagnostics.push(blocked_oversized(&pin.id, "pinned item"));
343 ContextVisibility::Blocked
344 } else {
345 ContextVisibility::Pinned
346 };
347 let reason = visibility.reason("user pin");
348 let reason_code = match visibility {
349 ContextVisibility::Dropped => "explicit_drop",
350 ContextVisibility::Blocked => "pinned_item_over_budget",
351 ContextVisibility::Pinned => "user_pin",
352 _ => "user_pin",
353 };
354 items.push(ContextItem {
355 id: pin.id.clone(),
356 kind: pin.kind.clone(),
357 label: pin.label.clone(),
358 source_path: pin.source_path.clone(),
359 scope: pin.scope.clone(),
360 content_hash: pin.content_hash,
361 artifact_handle: pin.artifact_handle.clone(),
362 byte_count: pin.bytes,
363 content: None,
364 token_estimate: estimate_tokens(pin.bytes),
365 visibility,
366 reason_code: reason_code.to_string(),
367 reason,
368 });
369 }
370
371 let mut ordered_instructions: Vec<&InstructionCandidate> = input.instructions.iter().collect();
372 ordered_instructions.sort_by_key(|b| std::cmp::Reverse(b.scope_depth()));
373 for instruction in ordered_instructions {
374 let id = item_id_for_path(&ContextItemKind::ProjectInstruction, &instruction.path);
375 let visibility = if input.dropped_ids.iter().any(|d| d == &id) {
376 ContextVisibility::Dropped
377 } else if !instruction.applicable {
378 ContextVisibility::Candidate
379 } else if estimate_tokens(instruction.byte_count) as u64 > available_input {
380 diagnostics.push(blocked_oversized(&id, "project instruction"));
381 ContextVisibility::Blocked
382 } else {
383 ContextVisibility::Visible
384 };
385 let reason =
386 visibility.reason(if instruction.applicable { "applicable AGENTS.md" } else { "discovered AGENTS.md" });
387 let reason_code = match visibility {
388 ContextVisibility::Dropped => "explicit_drop",
389 ContextVisibility::Blocked => "project_instruction_over_budget",
390 ContextVisibility::Candidate => "instruction_not_applicable",
391 _ => "applicable_project_instruction",
392 };
393 items.push(ContextItem {
394 id,
395 kind: ContextItemKind::ProjectInstruction,
396 label: instruction.path.display().to_string(),
397 source_path: Some(instruction.path.clone()),
398 scope: instruction.scope.clone(),
399 content_hash: Some(instruction.content_hash),
400 artifact_handle: None,
401 byte_count: instruction.byte_count,
402 content: if visibility.is_rendered() { instruction.content.clone() } else { None },
403 token_estimate: estimate_tokens(instruction.byte_count),
404 visibility,
405 reason_code: reason_code.to_string(),
406 reason,
407 });
408 }
409
410 let mut ordered_skills: Vec<&SkillCandidate> = input.skills.iter().collect();
411 ordered_skills.sort_by_key(|s| !s.loaded);
412 for skill in ordered_skills {
413 let id = item_id_for_path(&ContextItemKind::Skill, &skill.path);
414 let visibility = if input.dropped_ids.iter().any(|d| d == &id) {
415 ContextVisibility::Dropped
416 } else if estimate_tokens(skill.bytes) as u64 > available_input {
417 diagnostics.push(blocked_oversized(&id, "skill"));
418 ContextVisibility::Blocked
419 } else if skill.loaded {
420 ContextVisibility::Visible
421 } else {
422 ContextVisibility::Candidate
423 };
424 let reason = visibility.reason(if skill.loaded { "loaded skill" } else { "discovered skill metadata" });
425 let reason_code = match visibility {
426 ContextVisibility::Dropped => "explicit_drop",
427 ContextVisibility::Blocked => "skill_over_budget",
428 ContextVisibility::Candidate => "skill_metadata_only",
429 _ => "skill_loaded",
430 };
431 items.push(ContextItem {
432 id,
433 kind: ContextItemKind::Skill,
434 label: skill.name.clone(),
435 source_path: Some(skill.path.clone()),
436 scope: ".".to_string(),
437 content_hash: Some(skill.content_hash),
438 artifact_handle: None,
439 byte_count: skill.bytes,
440 content: None,
441 token_estimate: estimate_tokens(skill.bytes),
442 visibility,
443 reason_code: reason_code.to_string(),
444 reason,
445 });
446 }
447
448 let omitting_older = should_omit_older_transcript(input, available_input);
449 for summary in &input.compaction_summaries {
450 let visibility = if input.dropped_ids.iter().any(|d| d == &summary.id) {
451 ContextVisibility::Dropped
452 } else if !omitting_older {
453 ContextVisibility::Archived
454 } else if summary.latest {
455 if estimate_tokens(summary.bytes) as u64 > available_input {
456 diagnostics.push(blocked_oversized(&summary.id, "compaction summary"));
457 ContextVisibility::Blocked
458 } else {
459 ContextVisibility::Visible
460 }
461 } else {
462 ContextVisibility::Archived
463 };
464 let reason = visibility.reason("compaction summary");
465 let reason_code = match visibility {
466 ContextVisibility::Dropped => "explicit_drop",
467 ContextVisibility::Blocked => "summary_over_budget",
468 ContextVisibility::Archived => "summary_not_current",
469 _ => "latest_compaction_summary",
470 };
471 items.push(ContextItem {
472 id: summary.id.clone(),
473 kind: ContextItemKind::Summary,
474 label: summary.label.clone(),
475 source_path: None,
476 scope: ".".to_string(),
477 content_hash: None,
478 artifact_handle: None,
479 byte_count: summary.bytes,
480 content: if visibility.is_rendered() { summary.content.clone() } else { None },
481 token_estimate: estimate_tokens(summary.bytes),
482 visibility,
483 reason_code: reason_code.to_string(),
484 reason,
485 });
486 }
487
488 push_transcript(&mut items, input, available_input);
489
490 let budget = ContextBudget::from_limits(limits, &items);
491 ContextLedger { items, budget, diagnostics }
492}
493
494fn push_transcript(items: &mut Vec<ContextItem>, input: &SelectionInput, available_input: u64) {
497 let target = ratio_of(available_input, super::control::TARGET_BUDGET_RATIO);
498
499 let consumed: u64 = items
500 .iter()
501 .filter(|item| item.visibility.is_rendered())
502 .map(|item| item.token_estimate as u64)
503 .sum();
504
505 let transcript: Vec<&TranscriptCandidate> = input.transcript.iter().collect();
506 let mut selected: Vec<&TranscriptCandidate> = Vec::new();
507 let mut running = consumed;
508 for candidate in transcript.iter().rev() {
509 if candidate.ui_only || candidate.streaming {
510 continue;
511 }
512 let tokens = estimate_tokens(candidate.bytes) as u64;
513 if running + tokens > target {
514 break;
515 }
516 running += tokens;
517 selected.push(candidate);
518 }
519
520 let selected_seq: std::collections::HashSet<u64> = selected.iter().map(|c| c.seq).collect();
521
522 for candidate in &transcript {
523 let id = item_id_for_session_range(
524 &ContextItemKind::Transcript,
525 &candidate.session_id,
526 candidate.seq,
527 candidate.seq,
528 );
529 let (visibility, reason_code, reason) = if candidate.ui_only {
530 (
531 ContextVisibility::Candidate,
532 "ui_only_transcript",
533 "omitted: ui-only transcript entry",
534 )
535 } else if candidate.streaming {
536 (
537 ContextVisibility::Candidate,
538 "live_only_transcript",
539 "omitted: live-only streaming entry",
540 )
541 } else if input.dropped_ids.iter().any(|d| d == &id) {
542 (ContextVisibility::Dropped, "explicit_drop", "explicit drop")
543 } else if selected_seq.contains(&candidate.seq) {
544 (
545 ContextVisibility::Visible,
546 "recent_transcript",
547 "recent transcript entry",
548 )
549 } else {
550 (
551 ContextVisibility::Archived,
552 "evicted_under_budget",
553 "archived: evicted under budget pressure",
554 )
555 };
556 items.push(ContextItem {
557 id,
558 kind: ContextItemKind::Transcript,
559 label: candidate.label.clone(),
560 source_path: None,
561 scope: ".".to_string(),
562 content_hash: None,
563 artifact_handle: candidate.artifact_handle.clone(),
564 byte_count: candidate.bytes,
565 content: None,
566 token_estimate: estimate_tokens(candidate.bytes),
567 visibility,
568 reason_code: reason_code.to_string(),
569 reason: reason.to_string(),
570 });
571 }
572}
573
574fn should_omit_older_transcript(input: &SelectionInput, available_input: u64) -> bool {
581 if input.compaction_summaries.iter().all(|s| !s.latest) {
582 return false;
583 }
584 let target = ratio_of(available_input, super::control::TARGET_BUDGET_RATIO);
585 let transcript_tokens: u64 = input
586 .transcript
587 .iter()
588 .filter(|c| !c.ui_only && !c.streaming)
589 .map(|c| estimate_tokens(c.bytes) as u64)
590 .sum();
591 transcript_tokens > target
592}
593
594fn slug(label: &str) -> String {
596 label
597 .chars()
598 .map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '_' })
599 .collect::<String>()
600 .trim_matches('_')
601 .to_string()
602}
603
604fn blocked_oversized(id: &str, kind: &str) -> ContextDiagnostic {
607 ContextDiagnostic {
608 severity: DiagnosticSeverity::Warning,
609 code: "blocked_oversized".to_string(),
610 message: format!("{kind} {id} exceeds available input budget; marked blocked instead of truncating"),
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::context::{ModelLimitConfidence, ModelLimitSource};
618
619 fn limits(context_window: u64) -> ModelContextLimits {
620 ModelContextLimits {
621 provider: "test".to_string(),
622 model: "test".to_string(),
623 context_window,
624 max_completion_tokens: 1_024,
625 recommended_completion_tokens: 512,
626 source: ModelLimitSource::LiveMetadata,
627 confidence: ModelLimitConfidence::Exact,
628 }
629 }
630
631 #[test]
632 fn pin_is_selected_and_can_be_dropped() {
633 let pin = PinnedCandidate::file(
634 ContextItemKind::PinnedFile,
635 PathBuf::from("/repo/src/lib.rs"),
636 "src",
637 120,
638 );
639 let input = SelectionInput { pins: vec![pin.clone()], ..Default::default() };
640 let visible = select_context(&input, limits(200_000));
641 assert!(
642 visible
643 .items
644 .iter()
645 .any(|item| item.id == pin.id && item.visibility == ContextVisibility::Pinned)
646 );
647
648 let dropped = SelectionInput { dropped_ids: vec![pin.id], ..input };
649 let ledger = select_context(&dropped, limits(200_000));
650 assert!(
651 ledger
652 .items
653 .iter()
654 .any(|item| item.visibility == ContextVisibility::Dropped)
655 );
656 }
657
658 #[test]
659 fn latest_summary_replaces_older_transcript_under_pressure() {
660 let input = SelectionInput {
661 harness: vec![HarnessCandidate::new("base", 1_000)],
662 user_turn: Some(UserTurnCandidate::new("session", 0, 100)),
663 compaction_summaries: vec![CompactionSummaryCandidate::new("session", 1, 10, 300, true)],
664 transcript: vec![
665 TranscriptCandidate::new("session", 1, "old", 5_000),
666 TranscriptCandidate::new("session", 2, "recent", 100),
667 ],
668 ..Default::default()
669 };
670 let ledger = select_context(&input, limits(4_000));
671 assert!(
672 ledger
673 .items
674 .iter()
675 .any(|item| item.kind == ContextItemKind::Summary && item.visibility == ContextVisibility::Visible)
676 );
677 assert!(
678 ledger
679 .items
680 .iter()
681 .any(|item| item.kind == ContextItemKind::Transcript && item.visibility == ContextVisibility::Archived)
682 );
683 }
684}