Skip to main content

everruns_core/
context_report.rs

1use crate::capabilities::{CapabilityRegistry, SystemPromptContext};
2use crate::events::{LlmGenerationData, TokenUsage, ToolDefinitionSummary};
3use crate::mcp_server::parse_mcp_tool_name;
4use crate::message::{ContentPart, Message, MessageRole};
5use crate::model_profiles::get_model_profile;
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_agent_delegation_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_agent_delegation_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_agent_delegation_tool_name(name: &str) -> bool {
352    matches!(name, "spawn_agent")
353}
354
355fn tool_definition_contribution_source(
356    tool: &ToolDefinition,
357    section_key: &str,
358) -> (String, String) {
359    let capability_attribution = tool.capability_attribution();
360    tool_contribution_source(
361        tool.name(),
362        tool.display_name(),
363        capability_attribution.map(|(id, _)| id),
364        capability_attribution.and_then(|(_, name)| name),
365        section_key,
366    )
367}
368
369fn tool_summary_contribution_source(
370    tool: &ToolDefinitionSummary,
371    section_key: &str,
372) -> (String, String) {
373    tool_contribution_source(
374        &tool.name,
375        tool.display_name.as_deref(),
376        tool.capability_id.as_deref(),
377        tool.capability_name.as_deref(),
378        section_key,
379    )
380}
381
382fn tool_contribution_source(
383    tool_name: &str,
384    display_name: Option<&str>,
385    capability_id: Option<&str>,
386    capability_name: Option<&str>,
387    section_key: &str,
388) -> (String, String) {
389    match section_key {
390        "mcp" => {
391            let server = parse_mcp_tool_name(tool_name).map(|(server, _)| server);
392            let source_id = capability_id
393                .map(str::to_string)
394                .or_else(|| server.as_ref().map(|server| format!("mcp:{server}")))
395                .unwrap_or_else(|| format!("tool:{tool_name}"));
396            let label = capability_name
397                .map(str::to_string)
398                .or(server)
399                .unwrap_or_else(|| display_name.unwrap_or(tool_name).to_string());
400            (source_id, label)
401        }
402        "skills" => {
403            let source_id = capability_id
404                .map(str::to_string)
405                .unwrap_or_else(|| "skills:tools".to_string());
406            let label = capability_name
407                .map(str::to_string)
408                .unwrap_or_else(|| "Skills tools".to_string());
409            (source_id, label)
410        }
411        "subagents" => ("subagents:tools".to_string(), "Subagent tools".to_string()),
412        "plugins" => {
413            let source_id = capability_id
414                .map(str::to_string)
415                .unwrap_or_else(|| format!("plugin:{tool_name}"));
416            let label = capability_name
417                .or(display_name)
418                .unwrap_or(tool_name)
419                .to_string();
420            (source_id, label)
421        }
422        _ => (
423            format!("tool:{tool_name}"),
424            display_name.unwrap_or(tool_name).to_string(),
425        ),
426    }
427}
428
429fn tool_calls_by_id(messages: &[Message]) -> BTreeMap<String, String> {
430    let mut tool_calls = BTreeMap::new();
431    for message in messages {
432        for tool_call in message.tool_calls() {
433            tool_calls.insert(tool_call.id.clone(), tool_call.name.clone());
434        }
435    }
436    tool_calls
437}
438
439fn add_message_breakdown(
440    builder: &mut ContextReportBuilder,
441    message: &Message,
442    tool_calls_by_id: &BTreeMap<String, String>,
443) {
444    let tokens = estimate_serialized_tokens(message);
445    if let Some((section_key, source_id, label)) =
446        message_contribution_source(message, tool_calls_by_id)
447    {
448        builder.add_contribution(section_key, source_id, label, tokens, 1);
449        return;
450    }
451
452    builder.add("conversation", "Conversation", tokens, 1);
453}
454
455fn message_contribution_source(
456    message: &Message,
457    tool_calls_by_id: &BTreeMap<String, String>,
458) -> Option<(&'static str, String, String)> {
459    if message.role != MessageRole::ToolResult {
460        return None;
461    }
462    let tool_call_id = message.tool_call_id()?;
463    let tool_name = tool_calls_by_id.get(tool_call_id)?;
464    if tool_name == "activate_skill" {
465        let skill_name = extract_json_string_field(message, "skill")?;
466        return Some((
467            "skills",
468            format!("skill:{skill_name}"),
469            format!("/{skill_name}"),
470        ));
471    }
472    if is_agent_delegation_tool_name(tool_name) {
473        let name = extract_json_string_field(message, "name").unwrap_or_else(|| "Subagent".into());
474        return Some(("subagents", format!("subagent:{name}"), name));
475    }
476    if let Some((server, _)) = parse_mcp_tool_name(tool_name) {
477        return Some(("mcp", format!("mcp:{server}"), server));
478    }
479    None
480}
481
482fn extract_json_string_field(message: &Message, field: &str) -> Option<String> {
483    message.content.iter().find_map(|part| {
484        let ContentPart::ToolResult(result) = part else {
485            return None;
486        };
487        result
488            .result
489            .as_ref()
490            .and_then(|value| value.get(field))
491            .and_then(|value| value.as_str())
492            .map(str::to_string)
493    })
494}
495
496fn estimate_tool_tokens(tool: &ToolDefinition) -> u32 {
497    estimate_serialized_tokens(tool)
498}
499
500fn estimate_serialized_tokens(value: &impl Serialize) -> u32 {
501    serde_json::to_string(value)
502        .ok()
503        .map(|text| estimate_text_tokens(&text))
504        .unwrap_or(0)
505}
506
507pub fn estimate_text_tokens(text: &str) -> u32 {
508    let chars = text.chars().count();
509    if chars == 0 {
510        0
511    } else {
512        u32::try_from(chars.div_ceil(4)).unwrap_or(u32::MAX)
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use crate::BuiltinTool;
520    use serde_json::json;
521
522    #[test]
523    fn classifies_attribution_sections() {
524        assert_eq!(classify_capability_prompt("agent_instructions"), "rules");
525        assert_eq!(classify_capability_prompt("skills"), "skills");
526        assert_eq!(classify_capability_prompt("skill:abc"), "skills");
527        assert_eq!(classify_capability_prompt("mcp:abc"), "mcp");
528        assert_eq!(classify_capability_prompt("subagents"), "subagents");
529    }
530
531    #[test]
532    fn classifies_mcp_and_delegation_tools() {
533        let mcp = ToolDefinition::Builtin(BuiltinTool {
534            name: "mcp_docs__search".into(),
535            display_name: None,
536            description: "Search docs".into(),
537            parameters: json!({"type": "object"}),
538            policy: Default::default(),
539            category: None,
540            deferrable: Default::default(),
541            hints: Default::default(),
542            full_parameters: None,
543        });
544        let delegation = ToolDefinition::Builtin(BuiltinTool {
545            name: "spawn_agent".into(),
546            display_name: None,
547            description: "Spawn".into(),
548            parameters: json!({"type": "object"}),
549            policy: Default::default(),
550            category: None,
551            deferrable: Default::default(),
552            hints: Default::default(),
553            full_parameters: None,
554        });
555
556        assert_eq!(classify_tool(&mcp), "mcp");
557        assert_eq!(classify_tool(&delegation), "subagents");
558    }
559
560    #[test]
561    fn classifies_skill_tools() {
562        let skill = ToolDefinition::Builtin(BuiltinTool {
563            name: "activate_skill".into(),
564            display_name: None,
565            description: "Activate".into(),
566            parameters: json!({"type": "object"}),
567            policy: Default::default(),
568            category: None,
569            deferrable: Default::default(),
570            hints: Default::default(),
571            full_parameters: None,
572        });
573
574        assert_eq!(classify_tool(&skill), "skills");
575    }
576
577    #[test]
578    fn estimates_tokens_with_minimum_for_nonempty_text() {
579        assert_eq!(estimate_text_tokens(""), 0);
580        assert_eq!(estimate_text_tokens("abc"), 1);
581        assert_eq!(estimate_text_tokens("abcd"), 1);
582        assert_eq!(estimate_text_tokens("abcde"), 2);
583    }
584
585    #[test]
586    fn generation_report_attributes_capability_prompt_blocks() {
587        let data = LlmGenerationData::success(
588            vec![crate::Message::system(
589                "<system-prompt>\nBase\n</system-prompt>\n\n<capability id=\"agent_instructions\">Rules</capability>",
590            )],
591            vec![],
592            Some("ok".into()),
593            vec![],
594            "gpt-test".into(),
595            Some("openai".into()),
596            None,
597            None,
598            None,
599        );
600
601        let report =
602            build_session_context_report_from_generation("session_test", &data, None, None);
603        assert!(report.sections.iter().any(|section| section.key == "rules"));
604        assert!(
605            report
606                .contributions
607                .iter()
608                .any(|contribution| contribution.source_id == "agent_instructions")
609        );
610    }
611
612    #[test]
613    fn generation_report_attributes_tool_definitions_by_source() {
614        let data = LlmGenerationData::success(
615            vec![crate::Message::user("hello")],
616            vec![
617                crate::events::ToolDefinitionSummary {
618                    name: "mcp_docs__search".into(),
619                    display_name: None,
620                    category: Some("MCP Servers".into()),
621                    capability_id: None,
622                    capability_name: None,
623                    description: "Search docs".into(),
624                },
625                crate::events::ToolDefinitionSummary {
626                    name: "mcp_docs__read".into(),
627                    display_name: None,
628                    category: Some("MCP Servers".into()),
629                    capability_id: None,
630                    capability_name: None,
631                    description: "Read docs".into(),
632                },
633                crate::events::ToolDefinitionSummary {
634                    name: "activate_skill".into(),
635                    display_name: Some("Activate Skill".into()),
636                    category: Some("Skills".into()),
637                    capability_id: Some("skills".into()),
638                    capability_name: Some("Agent Skills".into()),
639                    description: "Activate".into(),
640                },
641            ],
642            Some("ok".into()),
643            vec![],
644            "gpt-test".into(),
645            Some("openai".into()),
646            None,
647            None,
648            None,
649        );
650
651        let report =
652            build_session_context_report_from_generation("session_test", &data, None, None);
653        assert!(report.contributions.iter().any(|contribution| {
654            contribution.section_key == "mcp" && contribution.source_id == "mcp:docs"
655        }));
656        assert!(report.contributions.iter().any(|contribution| {
657            contribution.section_key == "skills"
658                && contribution.source_id == "skills"
659                && contribution.label == "Agent Skills"
660        }));
661    }
662
663    #[test]
664    fn generation_report_attributes_skill_activation_results() {
665        let data = LlmGenerationData::success(
666            vec![
667                crate::Message::assistant_with_tools(
668                    "",
669                    vec![crate::ToolCall {
670                        id: "call_skill".into(),
671                        name: "activate_skill".into(),
672                        arguments: json!({"name": "pdf-tool"}),
673                    }],
674                ),
675                crate::Message::tool_result(
676                    "call_skill",
677                    Some(json!({
678                        "skill": "pdf-tool",
679                        "instructions": "<skill name=\"pdf-tool\">Use the PDF flow.</skill>",
680                    })),
681                    None,
682                ),
683            ],
684            vec![],
685            Some("ok".into()),
686            vec![],
687            "gpt-test".into(),
688            Some("openai".into()),
689            None,
690            None,
691            None,
692        );
693
694        let report =
695            build_session_context_report_from_generation("session_test", &data, None, None);
696        assert!(report.contributions.iter().any(|contribution| {
697            contribution.section_key == "skills"
698                && contribution.source_id == "skill:pdf-tool"
699                && contribution.label == "/pdf-tool"
700        }));
701    }
702
703    #[test]
704    fn generation_report_attributes_subagent_results_by_name() {
705        let data = LlmGenerationData::success(
706            vec![
707                crate::Message::assistant_with_tools(
708                    "",
709                    vec![crate::ToolCall {
710                        id: "call_subagent".into(),
711                        name: "spawn_agent".into(),
712                        arguments: json!({
713                            "name": "Scout",
714                            "instructions": "look around",
715                            "target": {"type": "subagent"}
716                        }),
717                    }],
718                ),
719                crate::Message::tool_result(
720                    "call_subagent",
721                    Some(json!({
722                        "name": "Scout",
723                        "status": "completed",
724                        "result": "Found the answer.",
725                    })),
726                    None,
727                ),
728            ],
729            vec![],
730            Some("ok".into()),
731            vec![],
732            "gpt-test".into(),
733            Some("openai".into()),
734            None,
735            None,
736            None,
737        );
738
739        let report =
740            build_session_context_report_from_generation("session_test", &data, None, None);
741        assert!(report.contributions.iter().any(|contribution| {
742            contribution.section_key == "subagents"
743                && contribution.source_id == "subagent:Scout"
744                && contribution.label == "Scout"
745        }));
746    }
747
748    #[test]
749    fn empty_system_prompt_does_not_add_section() {
750        let mut builder = ContextReportBuilder::default();
751        add_system_prompt_breakdown(&mut builder, "");
752        assert!(builder.sections().is_empty());
753    }
754}