Skip to main content

koda_core/
output_caps.rs

1//! Centralized tool output caps, scaled to the model's context window.
2//!
3//! All tool output limits live here instead of being scattered across 6+ files.
4//! Caps scale linearly from a floor (current defaults) up to a 4× ceiling
5//! as the context window grows.
6//!
7//! Scaling formula:
8//!   `clamp(base × (ctx / BASELINE), base, base × MAX_SCALE)`
9//!
10//! | Context window | Scale factor | Effect            |
11//! |----------------|-------------|-------------------|
12//! | 4K             | 0.04×       | Floor (base)      |
13//! | 100K           | 1.0×        | Base (current)    |
14//! | 200K           | 2.0×        | 2× current        |
15//! | 1M             | 10.0×       | Ceiling (4× base) |
16
17/// Baseline context window for scaling (100K tokens = 1.0× factor).
18const BASELINE: f64 = 100_000.0;
19
20/// Maximum scale multiplier (4× base values).
21const MAX_SCALE: f64 = 4.0;
22
23/// Pre-computed output caps for all tools in a session.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct OutputCaps {
26    /// Max chars for tool results stored in conversation history.
27    /// Base: 10,000 (was `MAX_TOOL_RESULT_CHARS` in `tool_dispatch.rs`).
28    pub tool_result_chars: usize,
29
30    /// Max chars for web page body content.
31    /// Base: 15,000 (was `MAX_BODY_CHARS` in `web_fetch.rs`).
32    pub web_body_chars: usize,
33
34    /// Max lines of shell command output.
35    /// Base: 256 (was `MAX_OUTPUT_LINES` in `shell.rs`).
36    pub shell_output_lines: usize,
37
38    /// Max grep matches returned.
39    /// Base: 100 (was `MAX_MATCHES` in `grep.rs`).
40    pub grep_matches: usize,
41
42    /// Max directory listing entries.
43    /// Base: 200 (was `MAX_ENTRIES` in `file_tools.rs`).
44    pub list_entries: usize,
45
46    /// Max glob search results.
47    /// Base: 200 (was `MAX_RESULTS` in `glob_tool.rs`).
48    pub glob_results: usize,
49}
50
51impl OutputCaps {
52    // ── Base values (floors) ─────────────────────────────────
53    const BASE_TOOL_RESULT_CHARS: usize = 10_000;
54    const BASE_WEB_BODY_CHARS: usize = 15_000;
55    const BASE_SHELL_OUTPUT_LINES: usize = 256;
56    const BASE_GREP_MATCHES: usize = 100;
57    const BASE_LIST_ENTRIES: usize = 200;
58    const BASE_GLOB_RESULTS: usize = 200;
59
60    /// Compute caps scaled to the given context window size (in tokens).
61    ///
62    /// ```
63    /// use koda_core::output_caps::OutputCaps;
64    ///
65    /// // 100K context = 1× (baseline)
66    /// let caps = OutputCaps::for_context(100_000);
67    /// assert_eq!(caps.grep_matches, 100);
68    ///
69    /// // 200K context = 2×
70    /// let caps = OutputCaps::for_context(200_000);
71    /// assert_eq!(caps.grep_matches, 200);
72    /// ```
73    pub fn for_context(max_context_tokens: usize) -> Self {
74        let factor = (max_context_tokens as f64 / BASELINE).clamp(1.0, MAX_SCALE);
75
76        Self {
77            tool_result_chars: scale(Self::BASE_TOOL_RESULT_CHARS, factor),
78            web_body_chars: scale(Self::BASE_WEB_BODY_CHARS, factor),
79            shell_output_lines: scale(Self::BASE_SHELL_OUTPUT_LINES, factor),
80            grep_matches: scale(Self::BASE_GREP_MATCHES, factor),
81            list_entries: scale(Self::BASE_LIST_ENTRIES, factor),
82            glob_results: scale(Self::BASE_GLOB_RESULTS, factor),
83        }
84    }
85}
86
87impl Default for OutputCaps {
88    /// Default caps (100K context baseline — matches legacy hardcoded values).
89    fn default() -> Self {
90        Self::for_context(100_000)
91    }
92}
93
94/// Scale a base value by factor, rounding to nearest integer.
95fn scale(base: usize, factor: f64) -> usize {
96    (base as f64 * factor).round() as usize
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn small_context_gets_base_values() {
105        let caps = OutputCaps::for_context(4_096);
106        assert_eq!(caps.tool_result_chars, OutputCaps::BASE_TOOL_RESULT_CHARS);
107        assert_eq!(caps.shell_output_lines, OutputCaps::BASE_SHELL_OUTPUT_LINES);
108        assert_eq!(caps.grep_matches, OutputCaps::BASE_GREP_MATCHES);
109        assert_eq!(caps.list_entries, OutputCaps::BASE_LIST_ENTRIES);
110    }
111
112    #[test]
113    fn baseline_context_gets_base_values() {
114        let caps = OutputCaps::for_context(100_000);
115        assert_eq!(caps.tool_result_chars, 10_000);
116        assert_eq!(caps.web_body_chars, 15_000);
117        assert_eq!(caps.shell_output_lines, 256);
118        assert_eq!(caps.grep_matches, 100);
119        assert_eq!(caps.list_entries, 200);
120        assert_eq!(caps.glob_results, 200);
121    }
122
123    #[test]
124    fn double_context_doubles_caps() {
125        let caps = OutputCaps::for_context(200_000);
126        assert_eq!(caps.tool_result_chars, 20_000);
127        assert_eq!(caps.web_body_chars, 30_000);
128        assert_eq!(caps.shell_output_lines, 512);
129        assert_eq!(caps.grep_matches, 200);
130        assert_eq!(caps.list_entries, 400);
131        assert_eq!(caps.glob_results, 400);
132    }
133
134    #[test]
135    fn million_context_caps_at_4x() {
136        let caps = OutputCaps::for_context(1_000_000);
137        assert_eq!(caps.tool_result_chars, 40_000);
138        assert_eq!(caps.web_body_chars, 60_000);
139        assert_eq!(caps.shell_output_lines, 1024);
140        assert_eq!(caps.grep_matches, 400);
141        assert_eq!(caps.list_entries, 800);
142        assert_eq!(caps.glob_results, 800);
143    }
144
145    #[test]
146    fn default_matches_baseline() {
147        assert_eq!(OutputCaps::default(), OutputCaps::for_context(100_000));
148    }
149
150    #[test]
151    fn intermediate_context_scales_linearly() {
152        let caps = OutputCaps::for_context(150_000);
153        // 1.5× base
154        assert_eq!(caps.tool_result_chars, 15_000);
155        assert_eq!(caps.shell_output_lines, 384);
156    }
157}