Skip to main content

everruns_core/
context_report.rs

1use crate::capabilities::{CapabilityRegistry, SystemPromptContext};
2use crate::events::{LlmGenerationData, TokenUsage, ToolDefinitionSummary};
3use crate::llm_model_profiles::get_model_profile;
4use crate::mcp_server::parse_mcp_tool_name;
5use crate::message::{ContentPart, Message, MessageRole};
6use crate::runtime_context::AssembledTurnContext;
7use crate::tool_types::ToolDefinition;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10
11/// One logical section of the assembled LLM context (system prompt, tool
12/// definitions, message history, etc.) with its rolled-up token budget.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15pub struct ContextReportSection {
16    /// Stable section key (e.g. `system_prompt`, `tools`, `history`). Used as a join key for contributions.
17    pub key: String,
18    /// Human-readable section label suitable for UI display.
19    pub label: String,
20    /// Total tokens this section contributes to the assembled context.
21    pub tokens: u32,
22    /// Number of items this section comprises (messages, tool defs, etc.).
23    pub items: u32,
24}
25
26/// Single-source token contribution within a `ContextReportSection` — the
27/// per-tool / per-capability / per-message attribution that lets operators
28/// see which source is eating the context window.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31pub struct ContextReportContribution {
32    /// Section this contribution rolls up into; matches `ContextReportSection.key`.
33    pub section_key: String,
34    /// Stable id of the contributing source (capability id, tool name, message id, etc.).
35    pub source_id: String,
36    /// Human-readable label suitable for UI display.
37    pub label: String,
38    /// Tokens this single source contributes to the assembled context.
39    pub tokens: u32,
40}
41
42/// Token-budget report for a session — a model-aware breakdown of the
43/// context window into named sections plus per-source contributions, so
44/// callers can answer "what's filling the context?" without reverse-
45/// engineering the prompt assembly.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
48pub struct SessionContextReport {
49    /// Prefixed session identifier this report describes.
50    pub session_id: String,
51    /// Model identifier the report's token estimates target (used to scope context-window math).
52    pub model: String,
53    /// Total context window size in tokens for `model`. `None` if the model's profile lacks limits data.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub context_window_tokens: Option<u32>,
56    /// Estimated number of input tokens consumed by the next generation given the current context.
57    pub estimated_input_tokens: u32,
58    /// Logical sections of the assembled context (system prompt, tool defs, message history, etc.) for inspection.
59    pub sections: Vec<ContextReportSection>,
60    /// Per-source token contributions (per-tool, per-capability, per-message) for attribution.
61    pub contributions: Vec<ContextReportContribution>,
62    /// Cumulative LLM usage observed across the session so far (token + cost rollup).
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub cumulative_usage: Option<TokenUsage>,
65}
66
67pub fn build_session_context_report_from_generation(
68    session_id: impl Into<String>,
69    generation: &LlmGenerationData,
70    context_window_tokens: Option<u32>,
71    cumulative_usage: Option<TokenUsage>,
72) -> SessionContextReport {
73    let mut builder = ContextReportBuilder::default();
74    let tool_calls_by_id = tool_calls_by_id(&generation.messages);
75
76    for message in &generation.messages {
77        if message.role == MessageRole::System {
78            add_system_prompt_breakdown(&mut builder, &message.content_to_llm_string());
79        } else {
80            add_message_breakdown(&mut builder, message, &tool_calls_by_id);
81        }
82    }
83
84    for tool in &generation.tools {
85        let key = classify_tool_summary(tool);
86        let tokens = estimate_serialized_tokens(tool);
87        let (source_id, label) = tool_summary_contribution_source(tool, key);
88        builder.add_contribution(key, source_id, label, tokens, 1);
89    }
90
91    let sections = builder.sections();
92    let estimated_input_tokens = sections.iter().map(|section| section.tokens).sum();
93    let contributions = builder.contributions;
94
95    SessionContextReport {
96        session_id: session_id.into(),
97        model: generation.metadata.model.clone(),
98        context_window_tokens,
99        estimated_input_tokens,
100        sections,
101        contributions,
102        cumulative_usage,
103    }
104}
105
106pub async fn build_session_context_report(
107    assembled: &AssembledTurnContext,
108    _capability_registry: &CapabilityRegistry,
109    _prompt_ctx: &SystemPromptContext,
110) -> SessionContextReport {
111    let mut builder = ContextReportBuilder::default();
112
113    add_system_prompt_breakdown(&mut builder, &assembled.runtime_agent.system_prompt);
114
115    for tool in &assembled.runtime_agent.tools {
116        let section_key = classify_tool(tool);
117        let tokens = estimate_tool_tokens(tool);
118        let (source_id, label) = tool_definition_contribution_source(tool, section_key);
119        builder.add_contribution(section_key, source_id, label, tokens, 1);
120    }
121
122    let tool_calls_by_id = tool_calls_by_id(&assembled.messages);
123    for message in &assembled.messages {
124        add_message_breakdown(&mut builder, message, &tool_calls_by_id);
125    }
126
127    let sections = builder.sections();
128    let estimated_input_tokens = sections.iter().map(|section| section.tokens).sum();
129    let context_window_tokens = get_model_profile(
130        &assembled.model_with_provider.provider_type,
131        &assembled.runtime_agent.model,
132    )
133    .and_then(|profile| profile.limits)
134    .and_then(|limits| u32::try_from(limits.context).ok());
135
136    SessionContextReport {
137        session_id: assembled.session.id.to_string(),
138        model: assembled.runtime_agent.model.clone(),
139        context_window_tokens,
140        estimated_input_tokens,
141        sections,
142        contributions: builder.contributions,
143        cumulative_usage: assembled.session.usage.clone(),
144    }
145}
146
147fn add_system_prompt_breakdown(builder: &mut ContextReportBuilder, prompt: &str) {
148    let mut cursor = 0usize;
149    while let Some(relative_start) = prompt[cursor..].find("<capability id=\"") {
150        let start = cursor + relative_start;
151        if start > cursor {
152            builder.add(
153                "system_prompt",
154                "System prompt",
155                estimate_text_tokens(&prompt[cursor..start]),
156                1,
157            );
158        }
159
160        let id_start = start + "<capability id=\"".len();
161        let Some(relative_id_end) = prompt[id_start..].find('"') else {
162            break;
163        };
164        let id_end = id_start + relative_id_end;
165        let capability_id = &prompt[id_start..id_end];
166        let Some(relative_end) = prompt[id_end..].find("</capability>") else {
167            break;
168        };
169        let end = id_end + relative_end + "</capability>".len();
170        let key = classify_capability_prompt(capability_id);
171        let tokens = estimate_text_tokens(&prompt[start..end]);
172        builder.add_contribution(
173            key,
174            capability_id.to_string(),
175            capability_label(capability_id),
176            tokens,
177            1,
178        );
179        cursor = end;
180    }
181
182    if cursor < prompt.len() {
183        builder.add(
184            "system_prompt",
185            "System prompt",
186            estimate_text_tokens(&prompt[cursor..]),
187            1,
188        );
189    }
190}
191
192#[derive(Default)]
193struct ContextReportBuilder {
194    sections: Vec<ContextReportSection>,
195    contributions: Vec<ContextReportContribution>,
196}
197
198impl ContextReportBuilder {
199    fn add(&mut self, key: &str, label: &str, tokens: u32, items: u32) {
200        if tokens == 0 && items == 0 {
201            return;
202        }
203        if let Some(section) = self.sections.iter_mut().find(|section| section.key == key) {
204            section.tokens = section.tokens.saturating_add(tokens);
205            section.items = section.items.saturating_add(items);
206            return;
207        }
208        self.sections.push(ContextReportSection {
209            key: key.to_string(),
210            label: label.to_string(),
211            tokens,
212            items,
213        });
214    }
215
216    fn add_contribution(
217        &mut self,
218        section_key: &str,
219        source_id: String,
220        label: String,
221        tokens: u32,
222        items: u32,
223    ) {
224        self.add(section_key, section_label(section_key), tokens, items);
225        if tokens == 0 {
226            return;
227        }
228        if let Some(contribution) = self.contributions.iter_mut().find(|contribution| {
229            contribution.section_key == section_key && contribution.source_id == source_id
230        }) {
231            contribution.tokens = contribution.tokens.saturating_add(tokens);
232            return;
233        }
234        self.contributions.push(ContextReportContribution {
235            section_key: section_key.to_string(),
236            source_id,
237            label,
238            tokens,
239        });
240    }
241
242    fn sections(&self) -> Vec<ContextReportSection> {
243        let mut sections = self.sections.clone();
244        let order = [
245            "system_prompt",
246            "tools",
247            "rules",
248            "skills",
249            "mcp",
250            "subagents",
251            "plugins",
252            "conversation",
253        ];
254        sections.sort_by_key(|section| {
255            order
256                .iter()
257                .position(|key| *key == section.key)
258                .unwrap_or(order.len())
259        });
260        sections
261    }
262}
263
264fn section_label(key: &str) -> &'static str {
265    match key {
266        "system_prompt" => "System prompt",
267        "rules" => "Rules",
268        "skills" => "Skills",
269        "mcp" => "MCP",
270        "subagents" => "Subagents",
271        "plugins" => "Plugins",
272        "conversation" => "Conversation",
273        _ => "Tools",
274    }
275}
276
277fn capability_label(capability_id: &str) -> String {
278    if let Some(skill_id) = capability_id.strip_prefix("skill:") {
279        format!("/{skill_id}")
280    } else if let Some(mcp_id) = capability_id.strip_prefix("mcp:") {
281        mcp_id.to_string()
282    } else {
283        capability_id.to_string()
284    }
285}
286
287fn classify_capability_prompt(capability_id: &str) -> &'static str {
288    if capability_id == "agent_instructions" {
289        "rules"
290    } else if capability_id == "skills" || capability_id.starts_with("skill:") {
291        "skills"
292    } else if capability_id == "subagents" {
293        "subagents"
294    } else if capability_id.starts_with("mcp:") {
295        "mcp"
296    } else {
297        "tools"
298    }
299}
300
301fn classify_tool(tool: &ToolDefinition) -> &'static str {
302    let name = tool.name();
303    let category = tool.category().unwrap_or_default();
304    let capability_id = tool
305        .capability_attribution()
306        .map(|(capability_id, _)| capability_id)
307        .unwrap_or_default();
308    if is_mcp_tool_source(name, category, capability_id) {
309        "mcp"
310    } else if is_subagent_tool_name(name) {
311        "subagents"
312    } else if is_skill_tool_source(name, category, capability_id) {
313        "skills"
314    } else if category.eq_ignore_ascii_case("plugins") || category.eq_ignore_ascii_case("plugin") {
315        "plugins"
316    } else {
317        "tools"
318    }
319}
320
321fn classify_tool_summary(tool: &ToolDefinitionSummary) -> &'static str {
322    let category = tool.category.as_deref().unwrap_or_default();
323    let capability_id = tool.capability_id.as_deref().unwrap_or_default();
324    if is_mcp_tool_source(&tool.name, category, capability_id) {
325        "mcp"
326    } else if is_subagent_tool_name(&tool.name) {
327        "subagents"
328    } else if is_skill_tool_source(&tool.name, category, capability_id) {
329        "skills"
330    } else if category.eq_ignore_ascii_case("plugins") || category.eq_ignore_ascii_case("plugin") {
331        "plugins"
332    } else {
333        "tools"
334    }
335}
336
337fn is_mcp_tool_source(name: &str, category: &str, capability_id: &str) -> bool {
338    name.starts_with("mcp_")
339        || category.eq_ignore_ascii_case("mcp")
340        || category.eq_ignore_ascii_case("mcp servers")
341        || capability_id.starts_with("mcp:")
342}
343
344fn is_skill_tool_source(name: &str, category: &str, capability_id: &str) -> bool {
345    matches!(name, "list_skills" | "activate_skill")
346        || category.eq_ignore_ascii_case("skills")
347        || capability_id == "skills"
348        || capability_id.starts_with("skill:")
349}
350
351fn is_subagent_tool_name(name: &str) -> bool {
352    matches!(
353        name,
354        "spawn_subagent" | "get_subagents" | "message_subagent"
355    )
356}
357
358fn tool_definition_contribution_source(
359    tool: &ToolDefinition,
360    section_key: &str,
361) -> (String, String) {
362    let capability_attribution = tool.capability_attribution();
363    tool_contribution_source(
364        tool.name(),
365        tool.display_name(),
366        capability_attribution.map(|(id, _)| id),
367        capability_attribution.and_then(|(_, name)| name),
368        section_key,
369    )
370}
371
372fn tool_summary_contribution_source(
373    tool: &ToolDefinitionSummary,
374    section_key: &str,
375) -> (String, String) {
376    tool_contribution_source(
377        &tool.name,
378        tool.display_name.as_deref(),
379        tool.capability_id.as_deref(),
380        tool.capability_name.as_deref(),
381        section_key,
382    )
383}
384
385fn tool_contribution_source(
386    tool_name: &str,
387    display_name: Option<&str>,
388    capability_id: Option<&str>,
389    capability_name: Option<&str>,
390    section_key: &str,
391) -> (String, String) {
392    match section_key {
393        "mcp" => {
394            let server = parse_mcp_tool_name(tool_name).map(|(server, _)| server);
395            let source_id = capability_id
396                .map(str::to_string)
397                .or_else(|| server.as_ref().map(|server| format!("mcp:{server}")))
398                .unwrap_or_else(|| format!("tool:{tool_name}"));
399            let label = capability_name
400                .map(str::to_string)
401                .or(server)
402                .unwrap_or_else(|| display_name.unwrap_or(tool_name).to_string());
403            (source_id, label)
404        }
405        "skills" => {
406            let source_id = capability_id
407                .map(str::to_string)
408                .unwrap_or_else(|| "skills:tools".to_string());
409            let label = capability_name
410                .map(str::to_string)
411                .unwrap_or_else(|| "Skills tools".to_string());
412            (source_id, label)
413        }
414        "subagents" => ("subagents:tools".to_string(), "Subagent tools".to_string()),
415        "plugins" => {
416            let source_id = capability_id
417                .map(str::to_string)
418                .unwrap_or_else(|| format!("plugin:{tool_name}"));
419            let label = capability_name
420                .or(display_name)
421                .unwrap_or(tool_name)
422                .to_string();
423            (source_id, label)
424        }
425        _ => (
426            format!("tool:{tool_name}"),
427            display_name.unwrap_or(tool_name).to_string(),
428        ),
429    }
430}
431
432fn tool_calls_by_id(messages: &[Message]) -> BTreeMap<String, String> {
433    let mut tool_calls = BTreeMap::new();
434    for message in messages {
435        for tool_call in message.tool_calls() {
436            tool_calls.insert(tool_call.id.clone(), tool_call.name.clone());
437        }
438    }
439    tool_calls
440}
441
442fn add_message_breakdown(
443    builder: &mut ContextReportBuilder,
444    message: &Message,
445    tool_calls_by_id: &BTreeMap<String, String>,
446) {
447    let tokens = estimate_serialized_tokens(message);
448    if let Some((section_key, source_id, label)) =
449        message_contribution_source(message, tool_calls_by_id)
450    {
451        builder.add_contribution(section_key, source_id, label, tokens, 1);
452        return;
453    }
454
455    builder.add("conversation", "Conversation", tokens, 1);
456}
457
458fn message_contribution_source(
459    message: &Message,
460    tool_calls_by_id: &BTreeMap<String, String>,
461) -> Option<(&'static str, String, String)> {
462    if message.role != MessageRole::ToolResult {
463        return None;
464    }
465    let tool_call_id = message.tool_call_id()?;
466    let tool_name = tool_calls_by_id.get(tool_call_id)?;
467    if tool_name == "activate_skill" {
468        let skill_name = extract_json_string_field(message, "skill")?;
469        return Some((
470            "skills",
471            format!("skill:{skill_name}"),
472            format!("/{skill_name}"),
473        ));
474    }
475    if is_subagent_tool_name(tool_name) {
476        let name = extract_json_string_field(message, "name").unwrap_or_else(|| "Subagent".into());
477        return Some(("subagents", format!("subagent:{name}"), name));
478    }
479    if let Some((server, _)) = parse_mcp_tool_name(tool_name) {
480        return Some(("mcp", format!("mcp:{server}"), server));
481    }
482    None
483}
484
485fn extract_json_string_field(message: &Message, field: &str) -> Option<String> {
486    message.content.iter().find_map(|part| {
487        let ContentPart::ToolResult(result) = part else {
488            return None;
489        };
490        result
491            .result
492            .as_ref()
493            .and_then(|value| value.get(field))
494            .and_then(|value| value.as_str())
495            .map(str::to_string)
496    })
497}
498
499fn estimate_tool_tokens(tool: &ToolDefinition) -> u32 {
500    estimate_serialized_tokens(tool)
501}
502
503fn estimate_serialized_tokens(value: &impl Serialize) -> u32 {
504    serde_json::to_string(value)
505        .ok()
506        .map(|text| estimate_text_tokens(&text))
507        .unwrap_or(0)
508}
509
510pub fn estimate_text_tokens(text: &str) -> u32 {
511    let chars = text.chars().count();
512    if chars == 0 {
513        0
514    } else {
515        u32::try_from(chars.div_ceil(4)).unwrap_or(u32::MAX)
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use crate::BuiltinTool;
523    use serde_json::json;
524
525    #[test]
526    fn classifies_attribution_sections() {
527        assert_eq!(classify_capability_prompt("agent_instructions"), "rules");
528        assert_eq!(classify_capability_prompt("skills"), "skills");
529        assert_eq!(classify_capability_prompt("skill:abc"), "skills");
530        assert_eq!(classify_capability_prompt("mcp:abc"), "mcp");
531        assert_eq!(classify_capability_prompt("subagents"), "subagents");
532    }
533
534    #[test]
535    fn classifies_mcp_and_subagent_tools() {
536        let mcp = ToolDefinition::Builtin(BuiltinTool {
537            name: "mcp_docs__search".into(),
538            display_name: None,
539            description: "Search docs".into(),
540            parameters: json!({"type": "object"}),
541            policy: Default::default(),
542            category: None,
543            deferrable: Default::default(),
544            hints: Default::default(),
545            full_parameters: None,
546        });
547        let subagent = ToolDefinition::Builtin(BuiltinTool {
548            name: "spawn_subagent".into(),
549            display_name: None,
550            description: "Spawn".into(),
551            parameters: json!({"type": "object"}),
552            policy: Default::default(),
553            category: None,
554            deferrable: Default::default(),
555            hints: Default::default(),
556            full_parameters: None,
557        });
558
559        assert_eq!(classify_tool(&mcp), "mcp");
560        assert_eq!(classify_tool(&subagent), "subagents");
561    }
562
563    #[test]
564    fn classifies_skill_tools() {
565        let skill = ToolDefinition::Builtin(BuiltinTool {
566            name: "activate_skill".into(),
567            display_name: None,
568            description: "Activate".into(),
569            parameters: json!({"type": "object"}),
570            policy: Default::default(),
571            category: None,
572            deferrable: Default::default(),
573            hints: Default::default(),
574            full_parameters: None,
575        });
576
577        assert_eq!(classify_tool(&skill), "skills");
578    }
579
580    #[test]
581    fn estimates_tokens_with_minimum_for_nonempty_text() {
582        assert_eq!(estimate_text_tokens(""), 0);
583        assert_eq!(estimate_text_tokens("abc"), 1);
584        assert_eq!(estimate_text_tokens("abcd"), 1);
585        assert_eq!(estimate_text_tokens("abcde"), 2);
586    }
587
588    #[test]
589    fn generation_report_attributes_capability_prompt_blocks() {
590        let data = LlmGenerationData::success(
591            vec![crate::Message::system(
592                "<capability id=\"agent_instructions\">Rules</capability>\n\n<system-prompt>\nBase\n</system-prompt>",
593            )],
594            vec![],
595            Some("ok".into()),
596            vec![],
597            "gpt-test".into(),
598            Some("openai".into()),
599            None,
600            None,
601            None,
602        );
603
604        let report =
605            build_session_context_report_from_generation("session_test", &data, None, None);
606        assert!(report.sections.iter().any(|section| section.key == "rules"));
607        assert!(
608            report
609                .contributions
610                .iter()
611                .any(|contribution| contribution.source_id == "agent_instructions")
612        );
613    }
614
615    #[test]
616    fn generation_report_attributes_tool_definitions_by_source() {
617        let data = LlmGenerationData::success(
618            vec![crate::Message::user("hello")],
619            vec![
620                crate::events::ToolDefinitionSummary {
621                    name: "mcp_docs__search".into(),
622                    display_name: None,
623                    category: Some("MCP Servers".into()),
624                    capability_id: None,
625                    capability_name: None,
626                    description: "Search docs".into(),
627                },
628                crate::events::ToolDefinitionSummary {
629                    name: "mcp_docs__read".into(),
630                    display_name: None,
631                    category: Some("MCP Servers".into()),
632                    capability_id: None,
633                    capability_name: None,
634                    description: "Read docs".into(),
635                },
636                crate::events::ToolDefinitionSummary {
637                    name: "activate_skill".into(),
638                    display_name: Some("Activate Skill".into()),
639                    category: Some("Skills".into()),
640                    capability_id: Some("skills".into()),
641                    capability_name: Some("Agent Skills".into()),
642                    description: "Activate".into(),
643                },
644            ],
645            Some("ok".into()),
646            vec![],
647            "gpt-test".into(),
648            Some("openai".into()),
649            None,
650            None,
651            None,
652        );
653
654        let report =
655            build_session_context_report_from_generation("session_test", &data, None, None);
656        assert!(report.contributions.iter().any(|contribution| {
657            contribution.section_key == "mcp" && contribution.source_id == "mcp:docs"
658        }));
659        assert!(report.contributions.iter().any(|contribution| {
660            contribution.section_key == "skills"
661                && contribution.source_id == "skills"
662                && contribution.label == "Agent Skills"
663        }));
664    }
665
666    #[test]
667    fn generation_report_attributes_skill_activation_results() {
668        let data = LlmGenerationData::success(
669            vec![
670                crate::Message::assistant_with_tools(
671                    "",
672                    vec![crate::ToolCall {
673                        id: "call_skill".into(),
674                        name: "activate_skill".into(),
675                        arguments: json!({"name": "pdf-tool"}),
676                    }],
677                ),
678                crate::Message::tool_result(
679                    "call_skill",
680                    Some(json!({
681                        "skill": "pdf-tool",
682                        "instructions": "<skill name=\"pdf-tool\">Use the PDF flow.</skill>",
683                    })),
684                    None,
685                ),
686            ],
687            vec![],
688            Some("ok".into()),
689            vec![],
690            "gpt-test".into(),
691            Some("openai".into()),
692            None,
693            None,
694            None,
695        );
696
697        let report =
698            build_session_context_report_from_generation("session_test", &data, None, None);
699        assert!(report.contributions.iter().any(|contribution| {
700            contribution.section_key == "skills"
701                && contribution.source_id == "skill:pdf-tool"
702                && contribution.label == "/pdf-tool"
703        }));
704    }
705
706    #[test]
707    fn generation_report_attributes_subagent_results_by_name() {
708        let data = LlmGenerationData::success(
709            vec![
710                crate::Message::assistant_with_tools(
711                    "",
712                    vec![crate::ToolCall {
713                        id: "call_subagent".into(),
714                        name: "spawn_subagent".into(),
715                        arguments: json!({"name": "Scout", "task": "look around"}),
716                    }],
717                ),
718                crate::Message::tool_result(
719                    "call_subagent",
720                    Some(json!({
721                        "name": "Scout",
722                        "status": "completed",
723                        "result": "Found the answer.",
724                    })),
725                    None,
726                ),
727            ],
728            vec![],
729            Some("ok".into()),
730            vec![],
731            "gpt-test".into(),
732            Some("openai".into()),
733            None,
734            None,
735            None,
736        );
737
738        let report =
739            build_session_context_report_from_generation("session_test", &data, None, None);
740        assert!(report.contributions.iter().any(|contribution| {
741            contribution.section_key == "subagents"
742                && contribution.source_id == "subagent:Scout"
743                && contribution.label == "Scout"
744        }));
745    }
746
747    #[test]
748    fn empty_system_prompt_does_not_add_section() {
749        let mut builder = ContextReportBuilder::default();
750        add_system_prompt_breakdown(&mut builder, "");
751        assert!(builder.sections().is_empty());
752    }
753}