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#[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_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}