use thiserror::Error; /// Parsed gate response from the model. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GateResponse { pub think: String, pub update_gate: bool, pub candidate: String, pub exit_gate: bool, } /// Parse error with context. #[derive(Error, Debug, Clone)] #[error("Parse error in {tag}: {message}\nRaw: {raw}")] pub struct ParseError { pub tag: String, pub message: String, pub raw: String, } /// Parse a gate response from model output. pub fn parse_gate_response(response: &str) -> Result { // Extract ... — last one before first let think = extract_think(response)?; // Extract yes|no let check_value = extract_tag_value(response, "check")?; let update_gate = match check_value.trim().to_lowercase().as_str() { "yes" => true, "no" => false, _ => { return Err(ParseError { tag: "check".to_string(), message: format!("must be 'yes' or 'no', got '{}'", check_value), raw: truncate(response, 200), }); } }; // Extract ... let candidate = extract_tag_value(response, "update")?; // Extract continue|end let next_value = extract_tag_value(response, "next")?; let exit_gate = match next_value.trim().to_lowercase().as_str() { "continue" => false, "end" => true, _ => { return Err(ParseError { tag: "next".to_string(), message: format!("must be 'continue' or 'end', got '{}'", next_value), raw: truncate(response, 200), }); } }; Ok(GateResponse { think, update_gate, candidate, exit_gate, }) } /// Extract the last ... before first . fn extract_think(response: &str) -> Result { let check_pos = response.find("").ok_or_else(|| ParseError { tag: "check".to_string(), message: "tag not found".to_string(), raw: truncate(response, 200), })?; // Look for the last before the let before_check = &response[..check_pos]; if let Some(end_pos) = before_check.rfind("") { // Look for the last before this if let Some(start_pos) = before_check[..end_pos].rfind("") { let think_content = &before_check[start_pos + 7..end_pos]; // 7 = "".len() return Ok(think_content.to_string()); } } Err(ParseError { tag: "think".to_string(), message: "tag not found or not properly closed".to_string(), raw: truncate(response, 200), }) } /// Extract content between ..., ensuring it appears exactly once. fn extract_tag_value(response: &str, tag: &str) -> Result { let open_tag = format!("<{}>", tag); let close_tag = format!("", tag); // Check if tag appears at all if !response.contains(&open_tag) { return Err(ParseError { tag: tag.to_string(), message: "tag not found".to_string(), raw: truncate(response, 200), }); } // Check for duplicates let open_count = response.matches(&open_tag).count(); let close_count = response.matches(&close_tag).count(); if open_count > 1 || close_count > 1 { return Err(ParseError { tag: tag.to_string(), message: format!( "tag appears {} times (expected exactly 1)", open_count.max(close_count) ), raw: truncate(response, 200), }); } if close_count == 0 { return Err(ParseError { tag: tag.to_string(), message: "tag not properly closed".to_string(), raw: truncate(response, 200), }); } // Extract content let start_idx = response.find(&open_tag).unwrap() + open_tag.len(); let end_idx = response.find(&close_tag).unwrap(); if start_idx > end_idx { return Err(ParseError { tag: tag.to_string(), message: "malformed tag structure".to_string(), raw: truncate(response, 200), }); } Ok(response[start_idx..end_idx].to_string()) } /// Truncate a string for display. fn truncate(s: &str, max_len: usize) -> String { if s.len() > max_len { format!("{}...", &s[..max_len]) } else { s.to_string() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_wellformed_yes_continue() { let response = r#" This is reasoning yes Memory update text continue "#; let result = parse_gate_response(response).unwrap(); assert_eq!(result.think, "This is reasoning"); assert!(result.update_gate); assert_eq!(result.candidate, "Memory update text"); assert!(!result.exit_gate); } #[test] fn test_missing_tag() { let response = r#" This is reasoning yes continue "#; let result = parse_gate_response(response); assert!(result.is_err()); assert_eq!(result.unwrap_err().tag, "update"); } }