1use chrono::{DateTime, Utc};
5use serde::{Deserialize, Deserializer, Serialize};
6use sha2::{Digest, Sha256};
7use uuid::Uuid;
8
9use crate::Session;
10
11pub const LAST_RUN_LLM_DIAGNOSTIC_KEY: &str = "runtime.llm_request_diagnostic";
12const MAX_DIAGNOSTIC_BYTES: usize = 4096;
13const DIAGNOSTIC_RUN_FINGERPRINT_KEY: &str = "runtime.llm_request_diagnostic_run";
14
15#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum LlmRequestStopReason {
18 AttemptsExhausted,
19 TimeBudgetExhausted,
20 RetryDelayExceedsBudget,
21 NonRetryableTransport,
22 UnclassifiedErrorBody,
23 CancelledOrDropped,
24}
25
26impl LlmRequestStopReason {
27 pub fn guidance(self) -> &'static str {
28 match self {
29 Self::AttemptsExhausted => "Automatic retry attempt limit reached",
30 Self::TimeBudgetExhausted => "Automatic retry time budget exhausted",
31 Self::RetryDelayExceedsBudget => {
32 "Required retry delay exceeds the remaining time budget"
33 }
34 Self::NonRetryableTransport => "Transport failure is not automatically retryable",
35 Self::UnclassifiedErrorBody => "Upstream error response could not be safely classified",
36 Self::CancelledOrDropped => "Request cancelled or its caller stopped waiting",
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45struct DiagnosticFields {
46 schema_version: u8,
47 diagnostic_id: Uuid,
48 request_id: Uuid,
49 error_id: Option<Uuid>,
50 observed_at: DateTime<Utc>,
51 provider: String,
52 session_hash: Option<String>,
53 run_id: Option<Uuid>,
54 attempt: u32,
55 attempt_limit: u32,
56 event: String,
57 phase: String,
58 failure: String,
59 status: Option<u16>,
60 attempt_timeout_ms: u64,
61 total_budget_ms: u64,
62 attempt_elapsed_ms: u64,
63 total_elapsed_ms: u64,
64 remaining_budget_ms: u64,
65 retry_delay_ms: Option<u64>,
66 stop_reason: LlmRequestStopReason,
67 upstream_outcome: String,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73#[serde(transparent)]
74pub struct LlmRequestDiagnostic(DiagnosticFields);
75
76impl<'de> Deserialize<'de> for LlmRequestDiagnostic {
77 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
78 let fields = DiagnosticFields::deserialize(deserializer)?;
79 if !fields.valid() {
80 return Err(serde::de::Error::custom("invalid LLM failure diagnostic"));
81 }
82 Ok(Self(fields))
83 }
84}
85
86impl DiagnosticFields {
87 fn valid(&self) -> bool {
88 self.schema_version == 1
89 && self.provider == "anthropic"
90 && self.event == "stop"
91 && self.request_id == self.diagnostic_id
92 && self.error_id == Some(self.diagnostic_id)
93 && self.session_hash.as_deref().is_some_and(|hash| {
94 hash.len() == 16
95 && hash
96 .bytes()
97 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
98 })
99 && (1..=4).contains(&self.attempt_limit)
100 && self.attempt <= self.attempt_limit
101 && self
102 .status
103 .is_none_or(|status| (100..=599).contains(&status))
104 && self.attempt_timeout_ms <= 60_000
105 && self.total_budget_ms <= 60_000
106 && self.remaining_budget_ms <= self.total_budget_ms
107 && ["send_headers", "error_body", "response_status", "backoff"]
108 .contains(&self.phase.as_str())
109 && ["unknown", "response_received"].contains(&self.upstream_outcome.as_str())
110 && [
111 "none",
112 "request deadline exceeded",
113 "request attempt limit reached",
114 "initial response headers timed out",
115 "timeout",
116 "connect",
117 "request",
118 "body",
119 "decode",
120 "status",
121 "transport",
122 "upstream error body deadline exceeded",
123 "upstream error body read failed",
124 "connector database is temporarily recovering",
125 "temporary upstream failure",
126 ]
127 .contains(&self.failure.as_str())
128 }
129}
130
131pub fn llm_diagnostic_session_hash(session_id: &str) -> String {
134 let mut digest = Sha256::new();
135 digest.update(b"bamboo-provider-operational-log-v1\0");
136 digest.update(session_id.as_bytes());
137 hex::encode(&digest.finalize()[..8])
138}
139
140impl LlmRequestDiagnostic {
141 pub fn from_json(raw: &str) -> Option<Self> {
142 (raw.len() <= MAX_DIAGNOSTIC_BYTES)
143 .then(|| serde_json::from_str(raw).ok())
144 .flatten()
145 }
146
147 pub fn diagnostic_id(&self) -> Uuid {
148 self.0.diagnostic_id
149 }
150
151 pub fn matches_session(&self, session_id: &str) -> bool {
152 self.0.session_hash.as_deref() == Some(llm_diagnostic_session_hash(session_id).as_str())
153 }
154
155 pub fn matches_error_message(&self, message: &str) -> bool {
158 message.strip_prefix("LLM error: ").unwrap_or(message) == self.error_message()
159 }
160
161 pub fn error_message(&self) -> String {
162 let f = &self.0;
163 let status = f
164 .status
165 .map(|s| format!("HTTP {s}"))
166 .unwrap_or_else(|| "no HTTP response".into());
167 format!(
168 "Anthropic upstream request failed ({status}): {}. {} after {} request(s) (limit {}). Diagnostic ID: {}. Try again later or contact the upstream gateway administrator.",
169 f.failure, f.stop_reason.guidance(), f.attempt, f.attempt_limit, f.diagnostic_id,
170 )
171 }
172}
173
174impl Session {
175 fn llm_diagnostic_run_fingerprint(&self) -> Option<String> {
176 self.agent_runtime_state.as_ref().map(|state| {
177 let mut digest = Sha256::new();
178 digest.update(b"bamboo-llm-diagnostic-run-v1\0");
179 digest.update((state.run_id.len() as u64).to_be_bytes());
180 digest.update(state.run_id.as_bytes());
181 if let Some(execution) = &state.execution_id {
182 digest.update([1]);
183 digest.update((execution.len() as u64).to_be_bytes());
184 digest.update(execution.as_bytes());
185 } else {
186 digest.update([0]);
187 }
188 hex::encode(&digest.finalize()[..16])
189 })
190 }
191
192 pub(crate) fn clear_llm_request_diagnostic(&mut self) {
193 self.metadata.remove(LAST_RUN_LLM_DIAGNOSTIC_KEY);
194 self.metadata.remove(DIAGNOSTIC_RUN_FINGERPRINT_KEY);
195 }
196
197 fn stored_llm_request_diagnostic(&self) -> Option<LlmRequestDiagnostic> {
198 let diagnostic =
199 LlmRequestDiagnostic::from_json(self.metadata.get(LAST_RUN_LLM_DIAGNOSTIC_KEY)?)?;
200 let run_id = self
201 .agent_runtime_state
202 .as_ref()
203 .and_then(|state| Uuid::parse_str(&state.run_id).ok());
204 let expected_run = self.llm_diagnostic_run_fingerprint();
205 (diagnostic.matches_session(&self.id)
206 && diagnostic.0.run_id == run_id
207 && self.metadata.get(DIAGNOSTIC_RUN_FINGERPRINT_KEY) == expected_run.as_ref())
208 .then_some(diagnostic)
209 }
210
211 pub fn record_llm_request_diagnostic(&mut self, mut diagnostic: LlmRequestDiagnostic) -> bool {
214 if !diagnostic.matches_session(&self.id) {
215 return false;
216 }
217 diagnostic.0.run_id = self
218 .agent_runtime_state
219 .as_ref()
220 .and_then(|state| Uuid::parse_str(&state.run_id).ok());
221 let Ok(raw) = serde_json::to_string(&diagnostic) else {
222 return false;
223 };
224 if raw.len() > MAX_DIAGNOSTIC_BYTES {
225 return false;
226 }
227 self.metadata
228 .insert(LAST_RUN_LLM_DIAGNOSTIC_KEY.into(), raw);
229 match self.llm_diagnostic_run_fingerprint() {
230 Some(fingerprint) => {
231 self.metadata
232 .insert(DIAGNOSTIC_RUN_FINGERPRINT_KEY.into(), fingerprint);
233 }
234 None => {
235 self.metadata.remove(DIAGNOSTIC_RUN_FINGERPRINT_KEY);
236 }
237 }
238 true
239 }
240
241 pub(crate) fn retain_llm_request_diagnostic_for_error(&mut self, error: &str) {
242 if !self
243 .stored_llm_request_diagnostic()
244 .is_some_and(|d| d.matches_error_message(error))
245 {
246 self.clear_llm_request_diagnostic();
247 }
248 }
249
250 pub fn last_run_diagnostic(&self) -> Option<LlmRequestDiagnostic> {
253 let error = self.last_run_error()?;
254 self.stored_llm_request_diagnostic()
255 .filter(|d| d.matches_error_message(&error))
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn sample(session: &Session) -> LlmRequestDiagnostic {
264 let id = Uuid::new_v4();
265 LlmRequestDiagnostic::from_json(
266 &serde_json::json!({
267 "schema_version":1,"diagnostic_id":id,"request_id":id,"error_id":id,
268 "observed_at":Utc::now(),"provider":"anthropic",
269 "session_hash":llm_diagnostic_session_hash(&session.id),"run_id":null,
270 "attempt":1,"attempt_limit":4,"event":"stop","phase":"send_headers",
271 "failure":"initial response headers timed out","status":null,
272 "attempt_timeout_ms":20000,"total_budget_ms":60000,"attempt_elapsed_ms":20000,
273 "total_elapsed_ms":60000,"remaining_budget_ms":0,"retry_delay_ms":null,
274 "stop_reason":"time_budget_exhausted","upstream_outcome":"unknown",
275 })
276 .to_string(),
277 )
278 .unwrap()
279 }
280
281 #[test]
282 fn failure_receipt_survives_cold_session_round_trip_and_is_bound_to_error_and_session() {
283 let mut session = Session::new("diagnostic-current", "model");
284 let diagnostic = sample(&session);
285 assert!(session.record_llm_request_diagnostic(diagnostic.clone()));
286 assert!(session.last_run_diagnostic().is_none());
287 session.set_last_run_error(format!("LLM error: {}", diagnostic.error_message()));
288 let cold: Session =
289 serde_json::from_str(&serde_json::to_string(&session).unwrap()).unwrap();
290 assert_eq!(cold.last_run_diagnostic(), Some(diagnostic.clone()));
291 let mut foreign = cold.clone();
292 foreign.id = "other".into();
293 assert!(foreign.last_run_diagnostic().is_none());
294 let mut changed = cold.clone();
295 changed.set_last_run_error("another provider failure");
296 assert!(changed.last_run_diagnostic().is_none());
297 assert!(!changed.metadata.contains_key(LAST_RUN_LLM_DIAGNOSTIC_KEY));
298 let mut cleared = cold;
299 cleared.clear_last_run_error();
300 assert!(!cleared.metadata.contains_key(LAST_RUN_LLM_DIAGNOSTIC_KEY));
301 }
302
303 #[test]
304 fn invalid_free_text_unknown_fields_and_stale_run_fail_closed() {
305 let mut session = Session::new("diagnostic-current", "model");
306 let diagnostic = sample(&session);
307 let value = serde_json::to_value(&diagnostic).unwrap();
308 for field in [
309 "provider",
310 "phase",
311 "failure",
312 "upstream_outcome",
313 "session_hash",
314 "future_secret",
315 ] {
316 let mut invalid = value.clone();
317 invalid[field] = serde_json::json!("PRIVATE_CANARY_body_key_path");
318 assert!(LlmRequestDiagnostic::from_json(&invalid.to_string()).is_none());
319 }
320 assert!(!diagnostic.matches_error_message(&format!(
321 "arbitrary upstream body: {}",
322 diagnostic.error_message()
323 )));
324 session.record_llm_request_diagnostic(diagnostic.clone());
325 session.set_last_run_error(diagnostic.error_message());
326 session.agent_runtime_state =
327 Some(crate::AgentRuntimeState::new(Uuid::new_v4().to_string()));
328 assert!(session.last_run_diagnostic().is_none());
329 }
330
331 #[test]
332 fn non_uuid_run_and_execution_replacement_cannot_reuse_the_old_receipt() {
333 let mut session = Session::new("diagnostic-current", "model");
334 let mut runtime = crate::AgentRuntimeState::new("private-non-uuid-run-a");
335 runtime.execution_id = Some("private-execution-a".into());
336 session.agent_runtime_state = Some(runtime);
337 let diagnostic = sample(&session);
338 session.record_llm_request_diagnostic(diagnostic.clone());
339 session.set_last_run_error(diagnostic.error_message());
340 let encoded = serde_json::to_string(&session).unwrap();
341 let mut cold: Session = serde_json::from_str(&encoded).unwrap();
342 assert!(cold.last_run_diagnostic().is_some());
343 let public = serde_json::to_string(&cold.last_run_diagnostic().unwrap()).unwrap();
344 assert!(!public.contains("private-non-uuid-run-a"));
345 assert!(!public.contains("private-execution-a"));
346 cold.agent_runtime_state.as_mut().unwrap().run_id = "private-non-uuid-run-b".into();
347 assert!(cold.last_run_diagnostic().is_none());
348 let mut cold: Session = serde_json::from_str(&encoded).unwrap();
349 cold.agent_runtime_state.as_mut().unwrap().execution_id =
350 Some("private-execution-b".into());
351 assert!(cold.last_run_diagnostic().is_none());
352 }
353}