Skip to main content

thndrs_lib/core/context/
export.rs

1//! Versioned, bounded context inspection and export projections.
2//!
3//! The export model is application-owned because artifact storage and
4//! redaction belong to the host. It contains context metadata and the selected
5//! model projection, never raw provider payloads or unbounded artifact bodies.
6
7use 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
18/// Version of the user-facing context export contract.
19pub const CONTEXT_EXPORT_SCHEMA_VERSION: &str = "context-export-v1";
20/// Version of the bounded export redaction/cap policy.
21pub const CONTEXT_EXPORT_POLICY_VERSION: &str = "redacted-bounded-v1";
22/// Maximum bytes in one exported text field after redaction.
23pub const EXPORT_FIELD_MAX_BYTES: usize = 16 * 1024;
24/// Maximum bytes in the rendered model projection.
25pub const EXPORT_PROJECTION_MAX_BYTES: usize = 128 * 1024;
26
27/// Output format for a context export.
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum ContextExportFormat {
30    /// Deterministic versioned JSON.
31    Json,
32    /// Deterministic human-readable Markdown.
33    Markdown,
34}
35
36impl ContextExportFormat {
37    /// Parse a user-facing format label.
38    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    /// Stable format label.
47    pub const fn label(self) -> &'static str {
48        match self {
49            Self::Json => "json",
50            Self::Markdown => "markdown",
51        }
52    }
53}
54
55/// One model-visible message included in an export.
56#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
57pub struct ExportProjectionMessage {
58    /// Provider-neutral message role.
59    pub role: String,
60    /// Redacted and bounded rendered content.
61    pub content: String,
62}
63
64/// Content-free context item details shown by `/context` and export.
65#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
66pub struct ExportContextItem {
67    /// Stable context item id.
68    pub id: String,
69    /// Context item kind.
70    pub kind: ContextItemKind,
71    /// Item visibility at inspection time.
72    pub state: ContextVisibility,
73    /// Stable policy reason code.
74    pub reason_code: String,
75    /// Redacted policy explanation.
76    pub reason: String,
77    /// Replacement context id, when one exists.
78    pub replacement: Option<String>,
79    /// Conservative protection projection until explicit lifecycle state is available.
80    pub protected: bool,
81    /// Verification relation, when one exists.
82    pub verification: Option<String>,
83    /// Whether bounded redacted evidence can be recovered.
84    pub recovery_available: bool,
85    /// Recovery handle, when available.
86    pub recovery_handle: Option<String>,
87    /// Original item byte count.
88    pub byte_count: usize,
89    /// Selection token estimate.
90    pub token_estimate: usize,
91    /// Redacted display label.
92    pub label: String,
93}
94
95/// Export-side artifact metadata and optional bounded body.
96#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
97pub struct ExportArtifact {
98    /// Stable artifact handle.
99    pub handle: String,
100    /// Artifact metadata, if its sidecar was readable.
101    pub metadata: Option<ArtifactMetadata>,
102    /// Bounded redacted body; absent unless explicitly requested.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub body: Option<String>,
105    /// Safe recovery diagnostic, if the artifact is unavailable.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub diagnostic: Option<String>,
108}
109
110/// Versioned export of one selected request and its context ledger.
111#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
112pub struct ContextExport {
113    /// Export schema version.
114    pub schema_version: String,
115    /// Redaction and bounding policy version.
116    pub policy_version: String,
117    /// Session identity.
118    pub session_id: String,
119    /// Selected request accounting, when a provider request has completed.
120    pub accounting: Option<ProviderRequestAccounting>,
121    /// Context budget at inspection time.
122    pub budget: ExportBudget,
123    /// Ordered context candidate metadata.
124    pub items: Vec<ExportContextItem>,
125    /// Bounded model-facing projection for the selected request.
126    pub model_projection: Vec<ExportProjectionMessage>,
127    /// Shadow/applied reduction receipts.
128    pub receipts: Vec<ContextReductionReceipt>,
129    /// Export-safe diagnostics.
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub diagnostics: Vec<String>,
132    /// Artifact metadata and optional explicitly requested bodies.
133    #[serde(default, skip_serializing_if = "Vec::is_empty")]
134    pub artifacts: Vec<ExportArtifact>,
135}
136
137/// Context budget metadata included in an export.
138#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
139pub struct ExportBudget {
140    /// Estimated rendered tokens.
141    pub used: u64,
142    /// Selection target.
143    pub target: u64,
144    /// Available input budget.
145    pub available_input: u64,
146    /// Automatic compaction threshold.
147    pub auto_compaction_threshold: u64,
148    /// Provider/model limit provenance.
149    pub limits_source: String,
150    /// Provider/model limit confidence.
151    pub limits_confidence: String,
152}
153
154impl ContextExport {
155    /// Build a redacted export from one ledger and the selected request.
156    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    /// Serialize this export as deterministic pretty JSON.
193    pub fn to_json(&self) -> Result<String, serde_json::Error> {
194        serde_json::to_string_pretty(self)
195    }
196
197    /// Render this same typed export as deterministic Markdown.
198    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
312/// Convert an artifact recovery result to export metadata.
313pub 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
323/// Return the inspection details used by both the table and export.
324pub 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}