Skip to content

Commit ef5ccce

Browse files
committed
feat: implement in-flight error handling
1 parent df81f74 commit ef5ccce

File tree

13 files changed

+170
-54
lines changed

13 files changed

+170
-54
lines changed

Cargo.lock

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/agentic-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ reqwest = { workspace = true }
1212
tokio = { workspace = true }
1313
anyhow = { workspace = true }
1414
serde_json = { workspace = true }
15+
thiserror.workspace = true

crates/agentic-core/src/cloud.rs

Lines changed: 79 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,64 @@
1+
use crate::models::AtomicNote;
12
use reqwest::Client;
23
use serde::{Deserialize, Serialize};
34
use std::time::Duration;
5+
use thiserror::Error;
6+
7+
#[derive(Debug, Error)]
8+
pub enum CloudError {
9+
#[error("The cloud provider rejected the API key. It might have expired or been disabled.")]
10+
ApiKey,
11+
12+
#[error("The cloud model returned a response that could not be understood.")]
13+
ParseError,
14+
15+
#[error("The cloud provider returned an unexpected error: {status}: {text}")]
16+
ApiError {
17+
status: u16,
18+
text: String,
19+
},
20+
21+
#[error(transparent)]
22+
RequestError(#[from] reqwest::Error),
23+
}
24+
25+
const SYNTHESIZER_PROMPT: &str = r#"You are an expert-level AI Synthesizer. Your task is to answer the user's prompt by generating a concise, "atomic note" of knowledge.
26+
27+
CRITICAL OUTPUT CONSTRAINTS:
28+
29+
Header (Metadata): You MUST generate a set of 3-5 semantic keywords or tags that capture the absolute essence of the topic. These tags are for a knowledge graph.
30+
31+
Body (Content): The main response MUST be a maximum of four (4) sentences. It must be a dense, self-contained summary of the most critical information.
32+
33+
OUTPUT FORMAT (JSON):
34+
Your final output MUST be a single, valid JSON object with two keys: header_tags and body_text.
35+
36+
{
37+
"header_tags": ["keyword1", "keyword2", "keyword3"],
38+
"body_text": "Your concise, 3-4 sentence summary goes here."
39+
}
40+
41+
USER PROMPT:
42+
{prompt}
43+
"#;
44+
45+
#[derive(Serialize)]
46+
struct ResponseFormat {
47+
r#type: String,
48+
}
449

550
#[derive(Serialize)]
6-
struct OpenRouterRequest {
51+
struct OpenRouterRequest<'a> {
752
model: String,
8-
messages: Vec<ChatMessage>,
53+
messages: Vec<ChatMessage<'a>>,
954
max_tokens: u32,
55+
response_format: ResponseFormat,
1056
}
1157

1258
#[derive(Serialize)]
13-
struct ChatMessage {
59+
struct ChatMessage<'a> {
1460
role: String,
15-
content: String,
61+
content: &'a str,
1662
}
1763

1864
#[derive(Deserialize)]
@@ -34,22 +80,21 @@ pub async fn call_cloud_model(
3480
api_key: &str,
3581
model: &str,
3682
prompt: &str,
37-
) -> Result<String, anyhow::Error> {
83+
) -> Result<AtomicNote, CloudError> {
3884
let client = Client::builder().timeout(Duration::from_secs(30)).build()?;
3985

40-
// Optimize prompt for concise responses
41-
let optimized_prompt = format!(
42-
"Please provide a concise, well-structured response to this inquiry. Keep it informative but focused:\n\n{}",
43-
prompt
44-
);
86+
let synthesizer_prompt = SYNTHESIZER_PROMPT.replace("{prompt}", prompt);
4587

4688
let request_body = OpenRouterRequest {
4789
model: model.to_string(),
4890
messages: vec![ChatMessage {
4991
role: "user".to_string(),
50-
content: optimized_prompt,
92+
content: &synthesizer_prompt,
5193
}],
52-
max_tokens: 1024, // Reduced from 2048 for more concise responses
94+
max_tokens: 1024,
95+
response_format: ResponseFormat {
96+
r#type: "json_object".to_string(),
97+
},
5398
};
5499

55100
let response = client
@@ -62,19 +107,29 @@ pub async fn call_cloud_model(
62107

63108
if !response.status().is_success() {
64109
let status = response.status();
110+
if status == 401 {
111+
return Err(CloudError::ApiKey);
112+
}
65113
let error_text = response.text().await.unwrap_or_default();
66-
return Err(anyhow::anyhow!(
67-
"OpenRouter API error {}: {}",
68-
status,
69-
error_text
70-
));
114+
return Err(CloudError::ApiError {
115+
status: status.as_u16(),
116+
text: error_text,
117+
});
71118
}
72119

73-
let openrouter_response: OpenRouterResponse = response.json().await?;
120+
let openrouter_response: OpenRouterResponse = match response.json().await {
121+
Ok(res) => res,
122+
Err(_) => return Err(CloudError::ParseError),
123+
};
74124

75-
if let Some(choice) = openrouter_response.choices.first() {
76-
Ok(choice.message.content.clone())
77-
} else {
78-
Err(anyhow::anyhow!("No response choices from OpenRouter API"))
79-
}
80-
}
125+
let message_content = openrouter_response
126+
.choices
127+
.first()
128+
.map(|choice| &choice.message.content)
129+
.ok_or(CloudError::ParseError)?;
130+
131+
let atomic_note: AtomicNote =
132+
serde_json::from_str(message_content).map_err(|_| CloudError::ParseError)?;
133+
134+
Ok(atomic_note)
135+
}

crates/agentic-core/src/models.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ use serde::{Deserialize, Serialize};
44
use serde_json::Value;
55
use std::time::Duration;
66

7+
#[derive(Debug, Clone, Serialize, Deserialize)]
8+
pub struct AtomicNote {
9+
pub header_tags: Vec<String>,
10+
pub body_text: String,
11+
}
12+
713
#[derive(Debug, Clone, Serialize, Deserialize)]
814
pub struct OllamaModel {
915
pub name: String,

crates/agentic-tui/src/ui/app.rs

Lines changed: 62 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ use super::{
66
settings_modal::render_settings_modal,
77
};
88
use agentic_core::{
9-
cloud,
10-
models::{ModelValidator, OllamaModel, OpenRouterModel},
9+
cloud::{self, CloudError},
10+
models::{AtomicNote, ModelValidator, OllamaModel, OpenRouterModel},
1111
orchestrator,
1212
settings::{Settings, ValidationError},
1313
theme::{Element, Theme},
@@ -66,7 +66,7 @@ pub enum ValidationMessage {
6666
pub enum AgentMessage {
6767
ProposalsGenerated(Result<Vec<String>, anyhow::Error>),
6868
RevisedProposalGenerated(Result<String, anyhow::Error>),
69-
CloudSynthesisComplete(Result<String, anyhow::Error>),
69+
CloudSynthesisComplete(Result<AtomicNote, CloudError>),
7070
}
7171

7272
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -123,8 +123,9 @@ pub struct App {
123123
proposals: Vec<String>,
124124
current_proposal_index: usize,
125125
final_prompt: String,
126-
cloud_response: String,
126+
cloud_response: Option<AtomicNote>,
127127
synthesis_scroll: u16,
128+
coaching_tip: (String, String),
128129
}
129130

130131
impl App {
@@ -150,8 +151,9 @@ impl App {
150151
proposals: Vec::new(),
151152
current_proposal_index: 0,
152153
final_prompt: String::new(),
153-
cloud_response: String::new(),
154+
cloud_response: None,
154155
synthesis_scroll: 0,
156+
coaching_tip: (String::new(), String::new()),
155157
}
156158
}
157159

@@ -248,10 +250,12 @@ impl App {
248250
}
249251

250252
fn render_coaching_tip_modal(&self, frame: &mut ratatui::Frame, area: Rect) {
251-
use ratatui::{prelude::Alignment, text::Line, widgets::Paragraph};
253+
use ratatui::{prelude::Alignment, widgets::Paragraph};
254+
255+
let (title, message) = &self.coaching_tip;
252256

253257
let block = Block::default()
254-
.title(" Coaching Tip ")
258+
.title(format!(" {} ", title))
255259
.borders(Borders::ALL)
256260
.style(self.theme.ratatui_style(Element::Active));
257261

@@ -267,19 +271,7 @@ impl App {
267271
])
268272
.split(inner_area);
269273

270-
// Main coaching message with tips
271-
let message_text = vec![
272-
Line::from(""),
273-
Line::from("Ruixen is having a tough time with this abstract query."),
274-
Line::from(""),
275-
Line::from(":: Smaller local models work best with clear and concrete questions."),
276-
Line::from(""),
277-
Line::from(":: Try a more direct question, add specific context, or break"),
278-
Line::from(" the query down into smaller steps."),
279-
Line::from(""),
280-
];
281-
282-
let message = Paragraph::new(message_text)
274+
let message = Paragraph::new(message.as_str())
283275
.alignment(Alignment::Center)
284276
.style(self.theme.ratatui_style(Element::Text))
285277
.wrap(Wrap { trim: true });
@@ -467,13 +459,33 @@ impl App {
467459
frame.render_widget(Clear, modal_area);
468460
self.render_coaching_tip_modal(frame, modal_area);
469461
} else if self.mode == AppMode::Complete {
462+
let content = if let Some(note) = &self.cloud_response {
463+
use ratatui::text::{Line, Span};
464+
let title = note.header_tags.join(" • ");
465+
let title_style = self.theme.ratatui_style(Element::Accent);
466+
let body_style = self.theme.ratatui_style(Element::Text);
467+
468+
let text = vec![
469+
Line::from(Span::styled(title, title_style)),
470+
Line::from(""), // Spacer
471+
Line::from(Span::styled(&note.body_text, body_style)),
472+
];
473+
Paragraph::new(text)
474+
} else {
475+
// This case should ideally not be reached if mode is Complete
476+
Paragraph::new("Waiting for synthesis...")
477+
};
478+
470479
let block = Block::default()
471-
.title("Synthesis Complete")
472-
.borders(Borders::ALL);
473-
let paragraph = Paragraph::new(self.cloud_response.as_str())
480+
.title(" Synthesis Complete ")
481+
.borders(Borders::ALL)
482+
.style(self.theme.ratatui_style(Element::Active));
483+
484+
let paragraph = content
474485
.block(block)
475486
.wrap(Wrap { trim: true })
476487
.scroll((self.synthesis_scroll, 0));
488+
477489
frame.render_widget(paragraph, app_chunks[1]);
478490
} else {
479491
render_chat(
@@ -594,7 +606,10 @@ impl App {
594606
self.agent_status = AgentStatus::Ready;
595607
}
596608
AgentMessage::ProposalsGenerated(Err(_e)) => {
597-
// Show coaching tip instead of just failing silently
609+
self.coaching_tip = (
610+
"Local Model Error".to_string(),
611+
"The local model failed to generate proposals. Check if it is running and configured correctly.".to_string(),
612+
);
598613
self.mode = AppMode::CoachingTip;
599614
self.agent_status = AgentStatus::Ready;
600615
}
@@ -604,16 +619,34 @@ impl App {
604619
self.agent_status = AgentStatus::Ready;
605620
}
606621
AgentMessage::RevisedProposalGenerated(Err(_e)) => {
607-
// TODO: Set error state and display to user
622+
self.coaching_tip = (
623+
"Local Model Error".to_string(),
624+
"The local model failed to revise the proposal. Check if it is running and configured correctly.".to_string(),
625+
);
626+
self.mode = AppMode::CoachingTip;
608627
self.agent_status = AgentStatus::Ready;
609628
}
610629
AgentMessage::CloudSynthesisComplete(Ok(response)) => {
611-
self.cloud_response = response;
630+
self.cloud_response = Some(response);
612631
self.mode = AppMode::Complete;
613632
self.agent_status = AgentStatus::Complete;
614633
}
615-
AgentMessage::CloudSynthesisComplete(Err(_e)) => {
616-
// Show coaching tip for cloud API failures
634+
AgentMessage::CloudSynthesisComplete(Err(e)) => {
635+
let (title, message) = match e {
636+
CloudError::ApiKey => (
637+
"API Key Error".to_string(),
638+
"The cloud provider rejected the API key. It might have expired or been disabled. Please verify your key in the settings menu.".to_string(),
639+
),
640+
CloudError::ParseError => (
641+
"Cloud Model Error".to_string(),
642+
"Ruixen was unable to parse the response from the cloud model. This can sometimes happen with very complex or ambiguous queries. Try rephrasing your prompt, or attempt the synthesis again.".to_string(),
643+
),
644+
_ => (
645+
"Cloud API Error".to_string(),
646+
format!("An unexpected error occurred with the cloud provider: {}.", e),
647+
),
648+
};
649+
self.coaching_tip = (title, message);
617650
self.mode = AppMode::CoachingTip;
618651
self.agent_status = AgentStatus::Ready;
619652
}
@@ -907,7 +940,7 @@ impl App {
907940
self.final_prompt.clear();
908941
self.proposals.clear();
909942
self.current_proposal_index = 0;
910-
self.cloud_response.clear();
943+
self.cloud_response = None;
911944
self.synthesis_scroll = 0;
912945
self.agent_status = AgentStatus::Ready;
913946
}

src/layout.rs

Whitespace-only changes.

src/lib.rs

Whitespace-only changes.

src/main.rs

Whitespace-only changes.

src/settings.rs

Whitespace-only changes.

src/theme.rs

Whitespace-only changes.

0 commit comments

Comments
 (0)