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#[derive(Debug, Clone, Serialize, Deserialize)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15pub struct ContextReportSection {
16 pub key: String,
18 pub label: String,
20 pub tokens: u32,
22 pub items: u32,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31pub struct ContextReportContribution {
32 pub section_key: String,
34 pub source_id: String,
36 pub label: String,
38 pub tokens: u32,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
47#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
48pub struct SessionContextReport {
49 pub session_id: String,
51 pub model: String,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub context_window_tokens: Option<u32>,
56 pub estimated_input_tokens: u32,
58 pub sections: Vec<ContextReportSection>,
60 pub contributions: Vec<ContextReportContribution>,
62 #[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}