1use std::fmt::Write as _;
8
9use serde::{Deserialize, Serialize};
10use thndrs_agent::accounting::{
11 ContextReductionReceipt, MeasurementProvenance, ModelProjectionMessage, ProviderRequestAccounting,
12};
13use thndrs_agent::context::{ContextItem, ContextItemKind, ContextLedger, ContextVisibility};
14
15use crate::artifacts::{ArtifactMetadata, ArtifactRecovery};
16use crate::tools::shell::redact_secrets;
17
18pub const CONTEXT_EXPORT_SCHEMA_VERSION: &str = "context-export-v1";
20pub const CONTEXT_EXPORT_POLICY_VERSION: &str = "redacted-bounded-v1";
22pub const EXPORT_FIELD_MAX_BYTES: usize = 16 * 1024;
24pub const EXPORT_PROJECTION_MAX_BYTES: usize = 128 * 1024;
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum ContextExportFormat {
30 Json,
32 Markdown,
34}
35
36impl ContextExportFormat {
37 pub fn parse(value: &str) -> Option<Self> {
39 match value.to_ascii_lowercase().as_str() {
40 "json" => Some(Self::Json),
41 "markdown" | "md" => Some(Self::Markdown),
42 _ => None,
43 }
44 }
45
46 pub const fn label(self) -> &'static str {
48 match self {
49 Self::Json => "json",
50 Self::Markdown => "markdown",
51 }
52 }
53}
54
55#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
57pub struct ExportProjectionMessage {
58 pub role: String,
60 pub content: String,
62}
63
64#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
66pub struct ExportContextItem {
67 pub id: String,
69 pub kind: ContextItemKind,
71 pub state: ContextVisibility,
73 pub reason_code: String,
75 pub reason: String,
77 pub replacement: Option<String>,
79 pub protected: bool,
81 pub verification: Option<String>,
83 pub recovery_available: bool,
85 pub recovery_handle: Option<String>,
87 pub byte_count: usize,
89 pub token_estimate: usize,
91 pub label: String,
93}
94
95#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
97pub struct ExportArtifact {
98 pub handle: String,
100 pub metadata: Option<ArtifactMetadata>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub body: Option<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub diagnostic: Option<String>,
108}
109
110#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
112pub struct ContextExport {
113 pub schema_version: String,
115 pub policy_version: String,
117 pub session_id: String,
119 pub accounting: Option<ProviderRequestAccounting>,
121 pub budget: ExportBudget,
123 pub items: Vec<ExportContextItem>,
125 pub model_projection: Vec<ExportProjectionMessage>,
127 pub receipts: Vec<ContextReductionReceipt>,
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub diagnostics: Vec<String>,
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
134 pub artifacts: Vec<ExportArtifact>,
135}
136
137#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
139pub struct ExportBudget {
140 pub used: u64,
142 pub target: u64,
144 pub available_input: u64,
146 pub auto_compaction_threshold: u64,
148 pub limits_source: String,
150 pub limits_confidence: String,
152}
153
154impl ContextExport {
155 pub fn from_parts(
157 session_id: impl Into<String>, ledger: &ContextLedger, accounting: Option<ProviderRequestAccounting>,
158 artifacts: Vec<ExportArtifact>, diagnostics: Vec<String>,
159 ) -> Self {
160 let model_projection = accounting
161 .as_ref()
162 .map(|accounting| accounting.model_projection.iter().map(redact_projection).collect())
163 .unwrap_or_default();
164 let receipts = accounting
165 .as_ref()
166 .map(|accounting| accounting.shadow_receipts.clone())
167 .unwrap_or_default();
168 Self {
169 schema_version: CONTEXT_EXPORT_SCHEMA_VERSION.to_string(),
170 policy_version: CONTEXT_EXPORT_POLICY_VERSION.to_string(),
171 session_id: session_id.into(),
172 accounting,
173 budget: ExportBudget {
174 used: ledger.budget.used,
175 target: ledger.budget.target,
176 available_input: ledger.budget.available_input,
177 auto_compaction_threshold: ledger.budget.auto_compaction_threshold,
178 limits_source: ledger.budget.limits.source.label().to_string(),
179 limits_confidence: ledger.budget.limits.confidence.label().to_string(),
180 },
181 items: ledger.items.iter().map(export_item).collect(),
182 model_projection: cap_projection(model_projection),
183 receipts,
184 diagnostics: diagnostics
185 .into_iter()
186 .map(|diagnostic| cap_text(&diagnostic))
187 .collect(),
188 artifacts: artifacts.into_iter().map(bound_artifact).collect(),
189 }
190 }
191
192 pub fn to_json(&self) -> Result<String, serde_json::Error> {
194 serde_json::to_string_pretty(self)
195 }
196
197 pub fn to_markdown(&self) -> String {
199 let mut output = String::new();
200 let _ = writeln!(
201 output,
202 "# Context export\n\n- Schema: `{}`\n- Policy: `{}`\n- Session: `{}`",
203 self.schema_version, self.policy_version, self.session_id
204 );
205 let _ = writeln!(
206 output,
207 "\n## Budget\n\n- Used: {} estimated tokens\n- Target: {} estimated tokens\n- Available input: {} estimated tokens\n- Auto-compaction threshold: {} estimated tokens\n- Limits: {} ({})",
208 self.budget.used,
209 self.budget.target,
210 self.budget.available_input,
211 self.budget.auto_compaction_threshold,
212 self.budget.limits_source,
213 self.budget.limits_confidence
214 );
215 if let Some(accounting) = &self.accounting {
216 let _ = writeln!(
217 output,
218 "\n## Request\n\n- Request: `{}`\n- Turn: `{}`\n- Attempt: {}\n- Provider/model: `{}` / `{}`\n- Serialized bytes: {}\n- Estimated input tokens: {}",
219 accounting.request_id,
220 accounting.turn_id,
221 accounting.attempt,
222 accounting.provider,
223 accounting.model,
224 accounting.serialized_bytes.value,
225 display_measurement(
226 &accounting.estimated_input_tokens.value,
227 &accounting.estimated_input_tokens.provenance
228 )
229 );
230 if let Some(usage) = &accounting.provider_usage {
231 let _ = writeln!(
232 output,
233 "- Provider input/output: {} / {}\n- Cache read/create: {} / {}\n- Reasoning: {}\n- Inclusive input: {} ({})",
234 display_optional(usage.components.input_tokens),
235 display_optional(usage.components.output_tokens),
236 display_optional(usage.components.cache_read_input_tokens),
237 display_optional(usage.components.cache_creation_input_tokens),
238 display_optional(usage.components.reasoning_tokens),
239 display_optional(usage.inclusive_input_tokens.value),
240 usage.rule.label()
241 );
242 if let (Some(estimate), Some(provider)) = (
243 accounting.estimated_input_tokens.value,
244 usage.inclusive_input_tokens.value,
245 ) {
246 let _ = writeln!(
247 output,
248 "- Estimate error: {} tokens",
249 provider as i128 - estimate as i128
250 );
251 }
252 } else {
253 let _ = writeln!(output, "- Provider usage: unknown");
254 }
255 let _ = writeln!(output, "- Shadow receipts: {}", self.receipts.len());
256 } else {
257 let _ = writeln!(output, "\n## Request\n\nNo completed provider request is selected.");
258 }
259 let _ = writeln!(
260 output,
261 "\n## Context items\n\n| ID | Kind | State | Reason | Protection | Recovery | Replacement |\n| --- | --- | --- | --- | --- | --- | --- |"
262 );
263 for item in &self.items {
264 let _ = writeln!(
265 output,
266 "| {} | {} | {} | {} | {} | {} | {} |",
267 markdown_cell(&item.id),
268 item.kind.label(),
269 item.state.label(),
270 markdown_cell(&item.reason_code),
271 if item.protected { "yes" } else { "no" },
272 if item.recovery_available { "yes" } else { "no" },
273 markdown_cell(item.replacement.as_deref().unwrap_or("none"))
274 );
275 }
276 let _ = writeln!(output, "\n## Model projection\n");
277 for message in &self.model_projection {
278 let _ = writeln!(output, "### {}\n", message.role);
279 for line in message.content.lines() {
280 let _ = writeln!(output, " {line}");
281 }
282 }
283 if !self.artifacts.is_empty() {
284 let _ = writeln!(output, "\n## Artifacts\n");
285 for artifact in &self.artifacts {
286 let state = artifact
287 .metadata
288 .as_ref()
289 .map(|metadata| format!("{:?}", metadata.retention).to_ascii_lowercase())
290 .unwrap_or_else(|| "unavailable".to_string());
291 let _ = writeln!(output, "- `{}`: {}", artifact.handle, state);
292 if let Some(diagnostic) = &artifact.diagnostic {
293 let _ = writeln!(output, " - diagnostic: {diagnostic}");
294 }
295 if let Some(body) = &artifact.body {
296 for line in body.lines() {
297 let _ = writeln!(output, " {line}");
298 }
299 }
300 }
301 }
302 if !self.diagnostics.is_empty() {
303 let _ = writeln!(output, "\n## Diagnostics\n");
304 for diagnostic in &self.diagnostics {
305 let _ = writeln!(output, "- {diagnostic}");
306 }
307 }
308 output
309 }
310}
311
312pub fn artifact_from_recovery(recovery: ArtifactRecovery, include_body: bool) -> ExportArtifact {
314 let body = include_body.then_some(recovery.content).flatten();
315 ExportArtifact {
316 handle: recovery.metadata.handle.clone(),
317 metadata: Some(recovery.metadata),
318 body,
319 diagnostic: recovery.diagnostic.map(|diagnostic| diagnostic.message),
320 }
321}
322
323pub fn export_item(item: &ContextItem) -> ExportContextItem {
325 let protected = matches!(
326 item.kind,
327 ContextItemKind::Harness
328 | ContextItemKind::ProjectInstruction
329 | ContextItemKind::PinnedFile
330 | ContextItemKind::ToolArchive
331 | ContextItemKind::Summary
332 ) || matches!(item.visibility, ContextVisibility::Pinned | ContextVisibility::Blocked);
333 ExportContextItem {
334 id: cap_text(&item.id),
335 kind: item.kind.clone(),
336 state: item.visibility.clone(),
337 reason_code: cap_text(&item.reason_code),
338 reason: cap_text(&item.reason),
339 replacement: None,
340 protected,
341 verification: None,
342 recovery_available: item.artifact_handle.is_some() || !item.visibility.is_rendered(),
343 recovery_handle: item.artifact_handle.as_ref().map(|handle| cap_text(handle)),
344 byte_count: item.byte_count,
345 token_estimate: item.token_estimate,
346 label: cap_text(&item.label),
347 }
348}
349
350fn redact_projection(message: &ModelProjectionMessage) -> ExportProjectionMessage {
351 ExportProjectionMessage { role: cap_text(&message.role), content: cap_text(&redact_secrets(&message.content)) }
352}
353
354fn cap_projection(messages: Vec<ExportProjectionMessage>) -> Vec<ExportProjectionMessage> {
355 let mut remaining = EXPORT_PROJECTION_MAX_BYTES;
356 messages
357 .into_iter()
358 .filter_map(|mut message| {
359 if remaining == 0 {
360 return None;
361 }
362 message.content = truncate_utf8(&cap_text(&message.content), remaining);
363 remaining = remaining.saturating_sub(message.content.len());
364 Some(message)
365 })
366 .collect()
367}
368
369fn bound_artifact(mut artifact: ExportArtifact) -> ExportArtifact {
370 artifact.handle = cap_text(&artifact.handle);
371 artifact.diagnostic = artifact.diagnostic.map(|diagnostic| cap_text(&diagnostic));
372 artifact.body = artifact.body.map(|body| cap_text(&redact_secrets(&body)));
373 artifact
374}
375
376fn cap_text(value: &str) -> String {
377 let redacted = redact_secrets(value);
378 truncate_utf8(&redacted, EXPORT_FIELD_MAX_BYTES)
379}
380
381fn truncate_utf8(value: &str, max_bytes: usize) -> String {
382 if value.len() <= max_bytes {
383 return value.to_string();
384 }
385 let mut end = max_bytes;
386 while end > 0 && !value.is_char_boundary(end) {
387 end -= 1;
388 }
389 value[..end].to_string()
390}
391
392fn display_measurement(value: &Option<u64>, provenance: &MeasurementProvenance) -> String {
393 format!("{} ({})", display_optional(*value), provenance_label(provenance))
394}
395
396fn provenance_label(provenance: &MeasurementProvenance) -> &'static str {
397 match provenance {
398 MeasurementProvenance::ExactSerialized { .. } => "exact",
399 MeasurementProvenance::Estimated { .. } => "estimated",
400 MeasurementProvenance::ProviderReported { .. } => "provider-reported",
401 MeasurementProvenance::Derived { .. } => "derived",
402 MeasurementProvenance::Unknown => "unknown",
403 }
404}
405
406fn display_optional(value: Option<u64>) -> String {
407 value.map_or_else(|| "unknown".to_string(), |value| value.to_string())
408}
409
410fn markdown_cell(value: &str) -> String {
411 value.replace('|', "\\|").replace('\n', " ")
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use std::path::PathBuf;
418
419 use thndrs_agent::accounting::{ModelProjectionMessage, ProviderUsageComponents, ProviderUsageRule};
420 use thndrs_agent::context::{
421 ContextBudget, DiagnosticSeverity, ModelContextLimits, ModelLimitConfidence, ModelLimitSource,
422 };
423
424 fn ledger() -> ContextLedger {
425 let limits = ModelContextLimits {
426 provider: "fixture".to_string(),
427 model: "fixture-model".to_string(),
428 context_window: 8_192,
429 max_completion_tokens: 1_024,
430 recommended_completion_tokens: 512,
431 source: ModelLimitSource::Static,
432 confidence: ModelLimitConfidence::ProviderReported,
433 };
434 let item = ContextItem {
435 id: "ctx_tool_1".to_string(),
436 kind: ContextItemKind::ToolArchive,
437 label: "tool output api_key=source-secret-that-must-not-be-rendered".to_string(),
438 source_path: Some(PathBuf::from("/workspace/out.txt")),
439 scope: ".".to_string(),
440 content_hash: Some(42),
441 artifact_handle: Some("artifact_v1_safe".to_string()),
442 byte_count: 1_024,
443 content: None,
444 token_estimate: 358,
445 visibility: ContextVisibility::Archived,
446 reason_code: "budget_eviction".to_string(),
447 reason: "archived after the request".to_string(),
448 };
449 ContextLedger {
450 budget: ContextBudget::from_limits(limits, std::slice::from_ref(&item)),
451 items: vec![item],
452 diagnostics: vec![thndrs_agent::context::ContextDiagnostic {
453 severity: DiagnosticSeverity::Info,
454 code: "fixture".to_string(),
455 message: "safe diagnostic".to_string(),
456 }],
457 }
458 }
459
460 fn accounting() -> ProviderRequestAccounting {
461 let accounting = ProviderRequestAccounting::from_serialized_request(
462 "turn_1",
463 "turn_1:request:1",
464 1,
465 "fixture",
466 "fixture-model",
467 b"serialized request",
468 Vec::new(),
469 )
470 .with_model_projection(vec![ModelProjectionMessage {
471 role: "user".to_string(),
472 content: "visible api_key=source-secret-that-must-not-be-rendered".to_string(),
473 }]);
474 let mut accounting = accounting;
475 accounting.provider_usage = Some(
476 ProviderUsageComponents {
477 input_tokens: Some(100),
478 output_tokens: Some(12),
479 cache_read_input_tokens: Some(4),
480 cache_creation_input_tokens: Some(2),
481 reasoning_tokens: None,
482 }
483 .normalize("fixture", ProviderUsageRule::AnthropicMessages),
484 );
485 accounting
486 }
487
488 #[test]
489 fn json_and_markdown_share_bounded_redacted_facts() {
490 let export = ContextExport::from_parts("session-1", &ledger(), Some(accounting()), Vec::new(), Vec::new());
491 let json = export.to_json().expect("json");
492 let markdown = export.to_markdown();
493
494 assert!(!json.contains("source-secret-that-must-not-be-rendered"));
495 assert!(!markdown.contains("source-secret-that-must-not-be-rendered"));
496 assert!(json.contains(CONTEXT_EXPORT_SCHEMA_VERSION));
497 assert!(json.contains("budget_eviction"));
498 assert!(markdown.contains("Inclusive input: 106"));
499 assert!(markdown.contains("Recovery"));
500 let round_trip: ContextExport = serde_json::from_str(&json).expect("round trip");
501 assert!(round_trip.items[0].recovery_available);
502 assert_eq!(
503 round_trip.accounting.as_ref().expect("accounting").model_projection,
504 Vec::new()
505 );
506 }
507
508 #[test]
509 fn export_rendering_is_deterministic_and_artifact_bodies_are_opt_in() {
510 let body = ArtifactRecovery {
511 metadata: serde_json::from_str(
512 r#"{"schema_version":1,"identity":"tool","kind":"tool_evidence","handle":"artifact_v1_safe","content_hash":"hash","original_byte_count":10,"bounded_byte_count":10,"truncated":false,"redacted":true,"created_at":"now","created_at_unix":1,"expires_at":null,"expires_at_unix":null,"retention":"active"}"#,
513 )
514 .expect("metadata"),
515 content: Some("bounded api_key=source-secret-that-must-not-be-rendered".to_string()),
516 diagnostic: None,
517 };
518 let artifact_without_body = artifact_from_recovery(body.clone(), false);
519 let artifact_with_body = artifact_from_recovery(body, true);
520 let first = ContextExport::from_parts(
521 "session-1",
522 &ledger(),
523 Some(accounting()),
524 vec![artifact_without_body],
525 Vec::new(),
526 );
527 let second = ContextExport::from_parts(
528 "session-1",
529 &ledger(),
530 Some(accounting()),
531 vec![artifact_with_body],
532 Vec::new(),
533 );
534 assert_eq!(first.to_json().expect("json"), first.to_json().expect("json"));
535 assert!(first.artifacts[0].body.is_none());
536 assert_eq!(second.artifacts[0].body.as_deref(), Some("bounded api_key=[REDACTED]"));
537 assert!(!second.to_markdown().contains("source-secret-that-must-not-be-rendered"));
538 }
539}