Skip to main content

agentic_core/
orchestrator.rs

1use crate::models::call_local_model;
2use serde::Deserialize;
3
4const ORCHESTRATOR_PROMPT: &str = r#"You are Ruixen, an inquisitive AI partner. 
5
6**CRITICAL INSTRUCTION:**
7You MUST generate EXACTLY 3 proposals about this query: "{query}"
8
9**MANDATORY FORMAT FOR EACH PROPOSAL:**
10[Context statement] - I wonder [question]?
11
12**RULES - NO EXCEPTIONS:**
131. EVERY proposal MUST have a brief context (1-2 sentences) followed by " - I wonder" 
142. EVERY proposal MUST end with a question starting with "I wonder" or "I'm wondering"
153. NO proposals should be just statements or just questions
164. ALWAYS use the exact format: "Context - I wonder/I'm wondering [question]?"
17
18**EXAMPLE OF CORRECT FORMAT:**
19"Philosophy has debated this for centuries - I wonder what new perspectives we might discover?"
20
21**Your EXACT output must be valid JSON:**
22{
23  "proposals": [
24    "Brief context statement - I wonder about this specific aspect?",
25    "Another context statement - I'm wondering if this could be true?", 
26    "Third context statement - I wonder about this different angle?"
27  ]
28}
29"#;
30
31#[derive(Deserialize, Debug)]
32struct ProposalObject {
33    context: String,
34    question: String,
35}
36
37#[derive(Deserialize, Debug)]
38#[serde(untagged)]
39enum ProposalItem {
40    StringFormat(String),
41    ObjectFormat(ProposalObject),
42}
43
44#[derive(Deserialize, Debug)]
45struct ProposalsResponse {
46    proposals: Vec<ProposalItem>,
47}
48
49pub async fn generate_proposals(
50    query: &str,
51    endpoint: &str,
52    model: &str,
53) -> Result<Vec<String>, anyhow::Error> {
54    let prompt = ORCHESTRATOR_PROMPT.replace("{query}", query);
55
56    // Debug: Write the prompt to a file so we can see what's being sent
57    std::fs::write("/tmp/debug_prompt.txt", &prompt).ok();
58
59    let response_str = match call_local_model(endpoint, model, &prompt).await {
60        Ok(response) => response,
61        Err(e) => {
62            // Enhanced error with more context
63            let error_msg = format!(
64                "Local model API call failed for endpoint '{}' with model '{}': {}",
65                endpoint, model, e
66            );
67            std::fs::write("/tmp/debug_error.txt", &error_msg).ok();
68            return Err(anyhow::anyhow!(error_msg));
69        }
70    };
71
72    // Debug: Write the response to a file so we can see what came back
73    std::fs::write("/tmp/debug_response.txt", &response_str).ok();
74
75    // Try multiple JSON extraction strategies
76    parse_proposals_with_fallbacks(&response_str, endpoint, model)
77}
78
79fn parse_proposals_with_fallbacks(
80    response_str: &str,
81    endpoint: &str,
82    model: &str,
83) -> Result<Vec<String>, anyhow::Error> {
84    // Strategy 1: Try to extract JSON from markdown code blocks
85    let clean_response = extract_json_from_markdown(response_str);
86
87    // Strategy 2: Try to find and parse the JSON object
88    if let Some(json_start) = clean_response.find("{") {
89        let json_str = &clean_response[json_start..];
90        if let Ok(response) = serde_json::from_str::<ProposalsResponse>(json_str) {
91            let proposals = response
92                .proposals
93                .into_iter()
94                .map(|item| match item {
95                    ProposalItem::StringFormat(s) => s,
96                    ProposalItem::ObjectFormat(obj) => {
97                        format!("{} - {}", obj.context, obj.question)
98                    }
99                })
100                .collect();
101            return Ok(proposals);
102        }
103    }
104
105    // Strategy 3: Try to parse just the proposals array
106    if let Some(proposals) = try_parse_proposals_array(clean_response) {
107        return Ok(proposals);
108    }
109
110    // Strategy 4: Try to extract proposals from text patterns
111    if let Some(proposals) = try_extract_text_proposals(response_str) {
112        return Ok(proposals);
113    }
114
115    // All strategies failed - write debug info and return error
116    let debug_info = format!(
117        "All JSON parsing strategies failed\nEndpoint: {}\nModel: {}\nFull Response: {}\nCleaned Response: {}", 
118        endpoint, model, response_str, clean_response
119    );
120    std::fs::write("/tmp/debug_parse_failure.txt", &debug_info).ok();
121
122    Err(anyhow::anyhow!(
123        "Local model '{}' at '{}' did not return parseable proposals. Response was: '{}'",
124        model,
125        endpoint,
126        response_str.chars().take(200).collect::<String>()
127    ))
128}
129
130fn extract_json_from_markdown(response: &str) -> &str {
131    // Try different markdown formats
132    if response.contains("```json") {
133        if let Some(json_start) = response.find("```json") {
134            let after_start = &response[json_start + 7..];
135            if let Some(json_end) = after_start.find("```") {
136                return after_start[..json_end].trim();
137            }
138        }
139    }
140
141    // Try just ```
142    if response.contains("```") {
143        if let Some(first_tick) = response.find("```") {
144            let after_first = &response[first_tick + 3..];
145            if let Some(second_tick) = after_first.find("```") {
146                let content = after_first[..second_tick].trim();
147                // Skip the language identifier line if present
148                if let Some(newline) = content.find('\n') {
149                    let potential_json = content[newline..].trim();
150                    if potential_json.starts_with('{') {
151                        return potential_json;
152                    }
153                }
154                return content;
155            }
156        }
157    }
158
159    response
160}
161
162fn try_parse_proposals_array(response: &str) -> Option<Vec<String>> {
163    // Look for a proposals array directly
164    if let Some(proposals_start) = response.find("\"proposals\"") {
165        let after_proposals = &response[proposals_start..];
166        if let Some(array_start) = after_proposals.find('[') {
167            if let Some(array_end) = after_proposals.find(']') {
168                let array_content = &after_proposals[array_start..=array_end];
169                let full_json = format!("{{\"proposals\":{}}}", array_content);
170                if let Ok(parsed) = serde_json::from_str::<ProposalsResponse>(&full_json) {
171                    return Some(
172                        parsed
173                            .proposals
174                            .into_iter()
175                            .map(|item| match item {
176                                ProposalItem::StringFormat(s) => s,
177                                ProposalItem::ObjectFormat(obj) => {
178                                    format!("{} - {}", obj.context, obj.question)
179                                }
180                            })
181                            .collect(),
182                    );
183                }
184            }
185        }
186    }
187    None
188}
189
190fn try_extract_text_proposals(response: &str) -> Option<Vec<String>> {
191    let mut proposals = Vec::new();
192
193    for line in response.lines() {
194        let trimmed = line.trim();
195
196        // Look for lines that match our expected proposal format
197        if (trimmed.contains(" - I wonder") || trimmed.contains(" - I'm wondering"))
198            && !trimmed.starts_with("//")
199            && !trimmed.starts_with("#")
200        {
201            // Clean up common prefixes/suffixes
202            let cleaned = trimmed
203                .trim_start_matches(|c: char| c.is_numeric() || c == '.' || c == ' ')
204                .trim_start_matches('"')
205                .trim_end_matches('"')
206                .trim_end_matches(',')
207                .to_string();
208
209            if !cleaned.is_empty() && cleaned.len() > 20 {
210                // Reasonable minimum length
211                proposals.push(cleaned);
212            }
213        }
214    }
215
216    if proposals.len() >= 2 {
217        Some(proposals)
218    } else {
219        None
220    }
221}