agentic_core/
orchestrator.rs1use 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 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 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 std::fs::write("/tmp/debug_response.txt", &response_str).ok();
74
75 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 let clean_response = extract_json_from_markdown(response_str);
86
87 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 if let Some(proposals) = try_parse_proposals_array(clean_response) {
107 return Ok(proposals);
108 }
109
110 if let Some(proposals) = try_extract_text_proposals(response_str) {
112 return Ok(proposals);
113 }
114
115 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 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 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 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 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 if (trimmed.contains(" - I wonder") || trimmed.contains(" - I'm wondering"))
198 && !trimmed.starts_with("//")
199 && !trimmed.starts_with("#")
200 {
201 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 proposals.push(cleaned);
212 }
213 }
214 }
215
216 if proposals.len() >= 2 {
217 Some(proposals)
218 } else {
219 None
220 }
221}