Skip to main content

thndrs_lib/core/session/
contracts.rs

1//! Stable, content-free metadata contracts for append-only coding-agent sessions.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::context::ContextSource;
8use thndrs_agent::context::{ContextItem, ContextItemKind, ContextLedger, ContextVisibility};
9
10/// Metadata for a loaded context source, without its model-visible content.
11#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
12pub struct ContextSourceMeta {
13    /// Absolute path to the source file.
14    pub path: String,
15    /// Scope label — `"."` for root, or a relative subtree path.
16    pub scope: String,
17    /// Stable hash of the full original content before truncation.
18    pub content_hash: u64,
19    /// Whether the source was truncated before projection.
20    pub truncated: bool,
21    /// Original byte count of the source file.
22    pub byte_count: usize,
23}
24
25impl From<&ContextSource> for ContextSourceMeta {
26    fn from(source: &ContextSource) -> Self {
27        Self {
28            path: source.path.display().to_string(),
29            scope: source.scope.clone(),
30            content_hash: source.content_hash,
31            truncated: source.truncated,
32            byte_count: source.byte_count,
33        }
34    }
35}
36
37impl ContextSourceMeta {
38    /// Extract audit metadata from a context source while omitting its content.
39    pub fn from_source(source: &ContextSource) -> Self {
40        Self::from(source)
41    }
42}
43
44/// Content-free audit metadata for one context-ledger item.
45#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
46pub struct ContextItemMeta {
47    /// Stable context item id.
48    pub id: String,
49    /// Domain kind used by context selection.
50    pub kind: ContextItemKind,
51    /// File-backed source path, when available.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub source_path: Option<String>,
54    /// Context scope, when available.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub scope: Option<String>,
57    /// Hash of the source content, when available.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub content_hash: Option<u64>,
60    /// Stable handle for bounded redacted recovery, when available.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub artifact_handle: Option<String>,
63    /// Source byte size.
64    pub byte_count: usize,
65    /// Conservative token estimate used by selection.
66    pub token_estimate: usize,
67    /// Inclusion state for this snapshot or action.
68    pub visibility: ContextVisibility,
69    /// Stable policy code for this state.
70    #[serde(default)]
71    pub reason_code: String,
72    /// Redacted explanation of the assigned visibility.
73    pub reason: String,
74}
75
76impl From<&ContextItem> for ContextItemMeta {
77    fn from(item: &ContextItem) -> Self {
78        Self {
79            id: item.id.clone(),
80            kind: item.kind.clone(),
81            source_path: item.source_path.as_ref().map(|path| path.display().to_string()),
82            scope: Some(item.scope.clone()),
83            content_hash: item.content_hash,
84            artifact_handle: item.artifact_handle.clone(),
85            byte_count: item.byte_count,
86            token_estimate: item.token_estimate,
87            visibility: item.visibility.clone(),
88            reason_code: item.reason_code.clone(),
89            reason: redact_audit_text(&item.reason),
90        }
91    }
92}
93
94/// Content-free context ledger snapshot persisted for a prompt turn.
95#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
96pub struct ContextLedgerMeta {
97    /// All candidate and selected item metadata for this turn.
98    pub items: Vec<ContextItemMeta>,
99    /// Available input tokens after completion reservation and provider overhead.
100    pub available_input: u64,
101    /// Target token budget used for normal selection.
102    pub target: u64,
103    /// Token threshold that may trigger automatic compaction.
104    pub auto_compaction_threshold: u64,
105    /// Estimated tokens of rendered items.
106    pub used: u64,
107    /// Content-free diagnostic summaries emitted by context selection.
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub diagnostics: Vec<ContextDiagnosticMeta>,
110}
111
112impl From<&ContextLedger> for ContextLedgerMeta {
113    fn from(ledger: &ContextLedger) -> Self {
114        Self {
115            items: ledger.items.iter().map(ContextItemMeta::from).collect(),
116            available_input: ledger.budget.available_input,
117            target: ledger.budget.target,
118            auto_compaction_threshold: ledger.budget.auto_compaction_threshold,
119            used: ledger.budget.used,
120            diagnostics: ledger
121                .diagnostics
122                .iter()
123                .map(|diagnostic| ContextDiagnosticMeta {
124                    severity: diagnostic.severity.label().to_string(),
125                    code: diagnostic.code.clone(),
126                    message: redact_audit_text(&diagnostic.message),
127                })
128                .collect(),
129        }
130    }
131}
132
133/// Content-free diagnostic emitted while selecting context.
134#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
135pub struct ContextDiagnosticMeta {
136    /// Diagnostic severity label.
137    pub severity: String,
138    /// Stable diagnostic code.
139    pub code: String,
140    /// Redacted human-readable message.
141    pub message: String,
142}
143
144/// Effective configuration provenance persisted with a session.
145///
146/// The structure contains paths, hashes, origins, and diagnostics, never raw
147/// configuration secrets or prompt content.
148#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
149pub struct SessionConfigMeta {
150    /// Effective session directory used for append-only JSONL files.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub session_dir: Option<String>,
153    /// Loaded config files with source, display path, and SHA-256 hash.
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub files: Vec<SessionConfigFile>,
156    /// Per-key origin labels, such as `"env:THNDRS_MODEL"`.
157    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
158    pub origins: BTreeMap<String, String>,
159    /// Non-fatal config diagnostics.
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub diagnostics: Vec<String>,
162    /// Loaded MCP config files with source, display path, and SHA-256 hash.
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub mcp_files: Vec<SessionConfigFile>,
165    /// Non-fatal MCP config diagnostics.
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub mcp_diagnostics: Vec<String>,
168}
169
170/// A loaded config file recorded in session metadata.
171#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
172pub struct SessionConfigFile {
173    /// Display path: workspace-relative, `~`-relative, or absolute.
174    pub path: String,
175    /// Source label: `"global"` or `"project"`.
176    pub source: String,
177    /// Lowercase hex SHA-256 of file bytes.
178    pub sha256: String,
179}
180
181/// MCP-specific identity attached to an external tool call.
182#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
183pub struct McpToolSessionMeta {
184    /// Configured MCP server name.
185    pub server_name: String,
186    /// Original MCP tool name before provider-visible namespacing.
187    pub original_tool_name: String,
188}
189
190/// A permission option recorded without protocol payloads.
191#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
192pub struct AcpPermissionOptionRecord {
193    /// ACP option id.
194    pub id: String,
195    /// Human-readable option label.
196    pub name: String,
197    /// Lowercase option kind.
198    pub kind: String,
199}
200
201/// External-agent session identity persisted once per run.
202#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
203pub struct AcpSessionMetadata {
204    /// Configured ACP agent name.
205    pub agent_name: String,
206    /// Opaque external ACP session id returned by the agent.
207    pub acp_session_id: String,
208    /// Redacted command display used to start the agent.
209    pub command: String,
210    /// Selected ACP protocol version.
211    pub protocol_version: String,
212    /// Optional ACP agent info name.
213    pub agent_info_name: Option<String>,
214    /// Optional ACP agent info version.
215    pub agent_info_version: Option<String>,
216    /// Optional ACP client info name.
217    pub client_info_name: Option<String>,
218    /// Optional ACP client info version.
219    pub client_info_version: Option<String>,
220}
221
222fn redact_audit_text(value: &str) -> String {
223    let tokens: Vec<&str> = value.split_whitespace().collect();
224    let mut redacted = Vec::with_capacity(tokens.len());
225    let mut index = 0;
226
227    while let Some(token) = tokens.get(index) {
228        if token.eq_ignore_ascii_case("bearer")
229            && tokens.get(index + 1).is_some_and(|next| {
230                next.trim_matches(|character: char| character.is_ascii_punctuation())
231                    .len()
232                    >= 10
233            })
234        {
235            redacted.push("Bearer [REDACTED]".to_string());
236            index += 2;
237        } else {
238            redacted.push(redact_audit_token(token));
239            index += 1;
240        }
241    }
242
243    redacted.join(" ")
244}
245
246fn redact_audit_token(token: &str) -> String {
247    let trimmed = token.trim_matches(|character: char| matches!(character, ',' | ';' | '(' | ')' | '[' | ']'));
248    let lower = trimmed.to_ascii_lowercase();
249
250    if trimmed.starts_with("sk-") && trimmed.len() >= 11 {
251        return token.replacen(trimmed, "sk-[REDACTED]", 1);
252    }
253    if lower.starts_with("bearer") && trimmed.len() >= 17 {
254        return "Bearer [REDACTED]".to_string();
255    }
256    if let Some((key, value)) = trimmed.split_once(['=', ':'])
257        && matches!(
258            key.to_ascii_lowercase().as_str(),
259            "password" | "passwd" | "api_key" | "apikey" | "access_token" | "secret"
260        )
261        && value.len() >= 4
262    {
263        return token.replacen(trimmed, &format!("{key}=[REDACTED]"), 1);
264    }
265
266    token.to_string()
267}