Skip to content

Commit 746ca40

Browse files
committed
Merge PR #251: fix: batch fixes for issues #2380-#2395
2 parents eec77bc + af1ed65 commit 746ca40

9 files changed

Lines changed: 436 additions & 17 deletions

File tree

cortex-cli/src/mcp_cmd.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,14 @@ async fn run_list(args: ListArgs) -> Result<()> {
503503
let servers = get_mcp_servers()?;
504504

505505
if args.json {
506-
let json = serde_json::to_string_pretty(&servers)?;
506+
// Sort servers by name for consistent JSON output
507+
let mut sorted_servers: Vec<_> = servers.iter().collect();
508+
sorted_servers.sort_by(|a, b| a.0.cmp(b.0));
509+
let sorted_map: toml::map::Map<String, toml::Value> = sorted_servers
510+
.into_iter()
511+
.map(|(k, v)| (k.clone(), v.clone()))
512+
.collect();
513+
let json = serde_json::to_string_pretty(&sorted_map)?;
507514
println!("{json}");
508515
return Ok(());
509516
}
@@ -522,7 +529,11 @@ async fn run_list(args: ListArgs) -> Result<()> {
522529
);
523530
println!("{}", "-".repeat(90));
524531

525-
for (name, server) in &servers {
532+
// Sort servers alphabetically by name for deterministic output
533+
let mut sorted_servers: Vec<_> = servers.iter().collect();
534+
sorted_servers.sort_by(|a, b| a.0.cmp(b.0));
535+
536+
for (name, server) in sorted_servers {
526537
let enabled = server
527538
.get("enabled")
528539
.and_then(toml::Value::as_bool)

cortex-cli/src/scrape_cmd.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,31 @@ impl std::str::FromStr for OutputFormat {
3737
}
3838
}
3939

40+
/// Image resolution selection for srcset handling.
41+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42+
pub enum ImageResolution {
43+
/// Get the smallest image variant (default browser behavior).
44+
Smallest,
45+
/// Get the highest resolution image available.
46+
#[default]
47+
Highest,
48+
/// Get all image variants.
49+
All,
50+
}
51+
52+
impl std::str::FromStr for ImageResolution {
53+
type Err = anyhow::Error;
54+
55+
fn from_str(s: &str) -> Result<Self, Self::Err> {
56+
match s.to_lowercase().as_str() {
57+
"smallest" | "small" | "low" => Ok(Self::Smallest),
58+
"highest" | "high" | "large" | "best" => Ok(Self::Highest),
59+
"all" => Ok(Self::All),
60+
_ => bail!("Invalid image resolution: {s}. Use smallest, highest, or all"),
61+
}
62+
}
63+
}
64+
4065
/// Scrape web content and convert to clean formats.
4166
#[derive(Debug, Parser)]
4267
pub struct ScrapeCommand {
@@ -91,6 +116,14 @@ pub struct ScrapeCommand {
91116
#[arg(long, value_name = "SELECTOR")]
92117
pub selector: Option<String>,
93118

119+
/// Image resolution selection for srcset attributes.
120+
/// Options: smallest, highest (default), all
121+
/// - smallest: Get the lowest resolution variant
122+
/// - highest: Get the highest resolution variant (best for AI vision)
123+
/// - all: Include all variants
124+
#[arg(long, default_value = "highest", value_name = "RESOLUTION")]
125+
pub image_resolution: String,
126+
94127
/// Show verbose output (includes fetching info).
95128
#[arg(short, long)]
96129
pub verbose: bool,
@@ -257,6 +290,62 @@ fn parse_headers(headers: &[String]) -> Result<HashMap<String, String>> {
257290
Ok(result)
258291
}
259292

293+
/// Parse srcset attribute and extract image URL based on resolution preference.
294+
/// srcset format: "small.jpg 480w, medium.jpg 800w, large.jpg 1200w"
295+
fn select_image_from_srcset(srcset: &str, resolution: ImageResolution) -> Vec<String> {
296+
if srcset.trim().is_empty() {
297+
return Vec::new();
298+
}
299+
300+
// Parse srcset entries
301+
let mut entries: Vec<(String, u32)> = Vec::new();
302+
303+
for entry in srcset.split(',') {
304+
let parts: Vec<&str> = entry.trim().split_whitespace().collect();
305+
if !parts.is_empty() {
306+
let url = parts[0].to_string();
307+
// Parse width descriptor (e.g., "800w") or pixel density (e.g., "2x")
308+
let width = if parts.len() > 1 {
309+
let descriptor = parts[1];
310+
if descriptor.ends_with('w') {
311+
descriptor.trim_end_matches('w').parse::<u32>().unwrap_or(0)
312+
} else if descriptor.ends_with('x') {
313+
// Convert pixel density to approximate width
314+
let density = descriptor
315+
.trim_end_matches('x')
316+
.parse::<f32>()
317+
.unwrap_or(1.0);
318+
(density * 1000.0) as u32
319+
} else {
320+
0
321+
}
322+
} else {
323+
0
324+
};
325+
entries.push((url, width));
326+
}
327+
}
328+
329+
if entries.is_empty() {
330+
return Vec::new();
331+
}
332+
333+
// Sort by width
334+
entries.sort_by(|a, b| a.1.cmp(&b.1));
335+
336+
match resolution {
337+
ImageResolution::Smallest => entries
338+
.first()
339+
.map(|(url, _)| vec![url.clone()])
340+
.unwrap_or_default(),
341+
ImageResolution::Highest => entries
342+
.last()
343+
.map(|(url, _)| vec![url.clone()])
344+
.unwrap_or_default(),
345+
ImageResolution::All => entries.into_iter().map(|(url, _)| url).collect(),
346+
}
347+
}
348+
260349
/// Extract main content from HTML, skipping navigation, footers, ads, etc.
261350
fn extract_main_content(document: &Html) -> String {
262351
// Try to find main content areas

cortex-engine/src/agent/profile.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,52 @@ pub struct AgentProfile {
4848
pub system_prompt: Option<String>,
4949
}
5050

51+
/// State that can accumulate during a session and needs to be reset on profile switch.
52+
#[derive(Debug, Clone, Default)]
53+
pub struct ProfileSessionState {
54+
/// Cached tool registrations from previous profile.
55+
pub tool_cache_dirty: bool,
56+
/// Indicates MCP connections need to be refreshed.
57+
pub mcp_connections_dirty: bool,
58+
/// Previous profile name (for detecting switches).
59+
pub previous_profile: Option<String>,
60+
}
61+
62+
impl ProfileSessionState {
63+
/// Create new session state.
64+
pub fn new() -> Self {
65+
Self::default()
66+
}
67+
68+
/// Check if a profile switch occurred and mark state dirty.
69+
pub fn on_profile_switch(&mut self, new_profile: &str) -> bool {
70+
let switched = self
71+
.previous_profile
72+
.as_ref()
73+
.map(|p| p != new_profile)
74+
.unwrap_or(true);
75+
76+
if switched {
77+
self.tool_cache_dirty = true;
78+
self.mcp_connections_dirty = true;
79+
self.previous_profile = Some(new_profile.to_string());
80+
}
81+
82+
switched
83+
}
84+
85+
/// Clear all dirty flags after reset is complete.
86+
pub fn mark_clean(&mut self) {
87+
self.tool_cache_dirty = false;
88+
self.mcp_connections_dirty = false;
89+
}
90+
91+
/// Check if any state needs to be refreshed.
92+
pub fn needs_refresh(&self) -> bool {
93+
self.tool_cache_dirty || self.mcp_connections_dirty
94+
}
95+
}
96+
5197
impl AgentProfile {
5298
/// Load all profiles from the project and user configuration.
5399
pub fn load_all() -> Result<HashMap<String, AgentProfile>> {

cortex-engine/src/api_client.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ pub const STREAMING_TIMEOUT: Duration = Duration::from_secs(300);
3636
/// Short timeout for health checks (5 seconds)
3737
pub const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
3838

39+
/// Default connection timeout (10 seconds)
40+
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
41+
3942
// ============================================================================
4043
// Global HTTP Client Factory Functions
4144
// ============================================================================
@@ -81,6 +84,28 @@ pub fn create_client_with_timeout(timeout: Duration) -> Result<Client> {
8184
Client::builder()
8285
.user_agent(USER_AGENT)
8386
.timeout(timeout)
87+
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
88+
.tcp_nodelay(true)
89+
.build()
90+
.map_err(|e| CortexError::Internal(format!("Failed to build HTTP client: {e}")))
91+
}
92+
93+
/// Creates an HTTP client with separate connect and response timeouts.
94+
///
95+
/// This allows distinguishing between connection establishment and response waiting.
96+
/// Use this when you need different timeouts for initial connection vs response.
97+
///
98+
/// # Arguments
99+
/// * `connect_timeout` - Timeout for establishing TCP connection
100+
/// * `response_timeout` - Total timeout for the entire request/response cycle
101+
pub fn create_client_with_timeouts(
102+
connect_timeout: Duration,
103+
response_timeout: Duration,
104+
) -> Result<Client> {
105+
Client::builder()
106+
.user_agent(USER_AGENT)
107+
.connect_timeout(connect_timeout)
108+
.timeout(response_timeout)
84109
.tcp_nodelay(true)
85110
.build()
86111
.map_err(|e| CortexError::Internal(format!("Failed to build HTTP client: {e}")))

cortex-engine/src/error.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ pub enum CortexError {
6666
#[error("Model not found: {model}")]
6767
ModelNotFound { model: String },
6868

69+
#[error("Model deprecated: {model}. {suggestion}")]
70+
ModelDeprecated { model: String, suggestion: String },
71+
6972
#[error("Provider not found: {provider}")]
7073
ProviderNotFound { provider: String },
7174

@@ -105,6 +108,9 @@ pub enum CortexError {
105108
#[error("Permission denied: {path}")]
106109
PermissionDenied { path: PathBuf },
107110

111+
#[error("Permission denied (possible SELinux denial): {path}. {hint}")]
112+
PermissionDeniedSelinux { path: PathBuf, hint: String },
113+
108114
// Serialization errors
109115
#[error("JSON error: {0}")]
110116
Json(#[from] serde_json::Error),
@@ -210,6 +216,13 @@ impl CortexError {
210216
Self::MdnsError(message.into())
211217
}
212218

219+
/// Create a deprecated model error with a helpful suggestion.
220+
pub fn model_deprecated(model: impl Into<String>, raw_error: &str) -> Self {
221+
let model = model.into();
222+
let suggestion = suggest_model_replacement(&model, raw_error);
223+
Self::ModelDeprecated { model, suggestion }
224+
}
225+
213226
/// Check if this error is retriable.
214227
pub fn is_retriable(&self) -> bool {
215228
matches!(
@@ -232,6 +245,80 @@ impl CortexError {
232245
}
233246
}
234247

248+
/// Suggest a replacement model for deprecated models.
249+
fn suggest_model_replacement(model: &str, _raw_error: &str) -> String {
250+
// Common deprecated model replacements
251+
let suggestion = match model {
252+
// OpenAI deprecated models
253+
m if m.contains("gpt-3.5-turbo-0301") => "Try 'gpt-3.5-turbo' or 'gpt-4o-mini' instead.",
254+
m if m.contains("gpt-3.5-turbo-0613") => "Try 'gpt-3.5-turbo' or 'gpt-4o-mini' instead.",
255+
m if m.contains("gpt-4-0314") => "Try 'gpt-4' or 'gpt-4-turbo' instead.",
256+
m if m.contains("gpt-4-0613") => "Try 'gpt-4' or 'gpt-4-turbo' instead.",
257+
m if m.contains("gpt-4-32k") => "Try 'gpt-4-turbo' (128K context) instead.",
258+
m if m.contains("text-davinci") => "Try 'gpt-3.5-turbo' or 'gpt-4o-mini' instead.",
259+
m if m.contains("code-davinci") => "Try 'gpt-4' or 'gpt-4-turbo' instead.",
260+
// Anthropic deprecated models
261+
m if m.contains("claude-instant") => "Try 'claude-3-haiku' instead.",
262+
m if m.contains("claude-2.0") => "Try 'claude-3-sonnet' or 'claude-3-opus' instead.",
263+
m if m.contains("claude-2.1") => "Try 'claude-3-sonnet' or 'claude-3-opus' instead.",
264+
// Generic suggestion
265+
_ => "Run 'cortex models list' to see available models.",
266+
};
267+
268+
format!(
269+
"{} {}",
270+
suggestion, "Run 'cortex models list' for all available models."
271+
)
272+
}
273+
274+
/// Check if an API error message indicates a deprecated model.
275+
pub fn is_deprecated_model_error(error_message: &str) -> bool {
276+
let lower = error_message.to_lowercase();
277+
lower.contains("deprecated")
278+
|| lower.contains("decommissioned")
279+
|| lower.contains("no longer available")
280+
|| lower.contains("model has been removed")
281+
|| lower.contains("model is retired")
282+
|| (lower.contains("model") && lower.contains("not found") && lower.contains("has been"))
283+
}
284+
285+
/// Check if SELinux is enabled and enforcing on the system.
286+
#[cfg(target_os = "linux")]
287+
pub fn is_selinux_enforcing() -> bool {
288+
// Check /sys/fs/selinux/enforce (reads "1" if enforcing)
289+
if let Ok(content) = std::fs::read_to_string("/sys/fs/selinux/enforce") {
290+
return content.trim() == "1";
291+
}
292+
// Alternative: check getenforce command
293+
if let Ok(output) = std::process::Command::new("getenforce").output() {
294+
if let Ok(status) = String::from_utf8(output.stdout) {
295+
return status.trim().eq_ignore_ascii_case("enforcing");
296+
}
297+
}
298+
false
299+
}
300+
301+
#[cfg(not(target_os = "linux"))]
302+
pub fn is_selinux_enforcing() -> bool {
303+
false
304+
}
305+
306+
/// Create a permission denied error with SELinux hint if applicable.
307+
pub fn permission_denied_with_selinux_check(path: PathBuf) -> CortexError {
308+
if is_selinux_enforcing() {
309+
CortexError::PermissionDeniedSelinux {
310+
path: path.clone(),
311+
hint: format!(
312+
"SELinux may be blocking access. Check 'ausearch -m avc -ts recent' for denials. \
313+
Try 'restorecon -Rv {}' to fix SELinux contexts.",
314+
path.display()
315+
),
316+
}
317+
} else {
318+
CortexError::PermissionDenied { path }
319+
}
320+
}
321+
235322
/// Convert protocol error info to CortexError.
236323
impl From<cortex_protocol::CortexErrorInfo> for CortexError {
237324
fn from(info: cortex_protocol::CortexErrorInfo) -> Self {

0 commit comments

Comments
 (0)