Skip to main content

opendev_memory/
roles.rs

1//! ACE role data models (Reflector, Curator outputs).
2//!
3//! Mirrors `opendev/core/context_engineering/memory/roles.py`.
4//!
5//! Note: The actual LLM-calling Reflector and Curator classes from the Python
6//! code depend on the LLM client and prompt system. This module provides the
7//! data models and JSON parsing utilities used by those roles.
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12use crate::delta::DeltaBatch;
13
14/// Safely parse JSON from LLM output, handling markdown code fences.
15pub fn safe_json_loads(text: &str) -> Result<serde_json::Value, String> {
16    let mut text = text.trim().to_string();
17
18    // Strip markdown code blocks
19    if text.starts_with("```json") {
20        text = text[7..].trim().to_string();
21    } else if text.starts_with("```") {
22        text = text[3..].trim().to_string();
23    }
24    if text.ends_with("```") {
25        text = text[..text.len() - 3].trim().to_string();
26    }
27
28    match serde_json::from_str::<serde_json::Value>(&text) {
29        Ok(val) => {
30            if val.is_object() {
31                Ok(val)
32            } else {
33                Err("Expected a JSON object from LLM.".to_string())
34            }
35        }
36        Err(e) => {
37            // Check for truncation
38            let open_braces = text.chars().filter(|&c| c == '{').count();
39            let close_braces = text.chars().filter(|&c| c == '}').count();
40            if open_braces > close_braces || text.trim_end().ends_with('"') {
41                Err(format!(
42                    "LLM response appears to be truncated JSON. Original error: {e}"
43                ))
44            } else {
45                Err(format!("LLM response is not valid JSON: {e}"))
46            }
47        }
48    }
49}
50
51/// Main agent response for ACE analysis.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct AgentResponse {
54    pub content: String,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub reasoning: Option<String>,
57    #[serde(default)]
58    pub tool_calls: Vec<serde_json::Value>,
59}
60
61impl AgentResponse {
62    /// Create a new agent response.
63    pub fn new(content: &str) -> Self {
64        Self {
65            content: content.to_string(),
66            reasoning: None,
67            tool_calls: Vec::new(),
68        }
69    }
70}
71
72/// Bullet tagging information from Reflector.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct BulletTag {
75    pub id: String,
76    pub tag: String,
77}
78
79/// Output from the Reflector role.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ReflectorOutput {
82    pub reasoning: String,
83    pub error_identification: String,
84    pub root_cause_analysis: String,
85    pub correct_approach: String,
86    pub key_insight: String,
87    pub bullet_tags: Vec<BulletTag>,
88    #[serde(default)]
89    pub raw: HashMap<String, serde_json::Value>,
90}
91
92impl ReflectorOutput {
93    /// Parse reflector output from LLM JSON response.
94    pub fn from_json(data: &serde_json::Value) -> Self {
95        let mut bullet_tags = Vec::new();
96        if let Some(tags_arr) = data.get("bullet_tags").and_then(|v| v.as_array()) {
97            for item in tags_arr {
98                if let (Some(id), Some(tag)) = (
99                    item.get("id").and_then(|v| v.as_str()),
100                    item.get("tag").and_then(|v| v.as_str()),
101                ) {
102                    bullet_tags.push(BulletTag {
103                        id: id.to_string(),
104                        tag: tag.to_lowercase(),
105                    });
106                }
107            }
108        }
109
110        let raw = data
111            .as_object()
112            .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
113            .unwrap_or_default();
114
115        Self {
116            reasoning: data
117                .get("reasoning")
118                .and_then(|v| v.as_str())
119                .unwrap_or("")
120                .to_string(),
121            error_identification: data
122                .get("error_identification")
123                .and_then(|v| v.as_str())
124                .unwrap_or("")
125                .to_string(),
126            root_cause_analysis: data
127                .get("root_cause_analysis")
128                .and_then(|v| v.as_str())
129                .unwrap_or("")
130                .to_string(),
131            correct_approach: data
132                .get("correct_approach")
133                .and_then(|v| v.as_str())
134                .unwrap_or("")
135                .to_string(),
136            key_insight: data
137                .get("key_insight")
138                .and_then(|v| v.as_str())
139                .unwrap_or("")
140                .to_string(),
141            bullet_tags,
142            raw,
143        }
144    }
145}
146
147/// Output from the Curator role.
148#[derive(Debug, Clone)]
149pub struct CuratorOutput {
150    pub delta: DeltaBatch,
151    pub raw: HashMap<String, serde_json::Value>,
152}
153
154impl CuratorOutput {
155    /// Parse curator output from LLM JSON response.
156    pub fn from_json(data: &serde_json::Value) -> Self {
157        let delta = DeltaBatch::from_json(data);
158        let raw = data
159            .as_object()
160            .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
161            .unwrap_or_default();
162
163        Self { delta, raw }
164    }
165}
166
167#[cfg(test)]
168#[path = "roles_tests.rs"]
169mod tests;