Skip to content
This repository was archived by the owner on Jan 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,26 @@ model.onnx
tokenizer.json

# Config file (use eidos.toml.example as template)
eidos.toml
eidos.toml

# IDE files
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
Thumbs.db

# Sensitive files
.env
.env.local
credentials.json
*_secret*

# Logs
*.log
logs/

# Generated docs
/target/doc/
29 changes: 22 additions & 7 deletions lib_chat/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ use serde::{Deserialize, Serialize};
use std::env;
use std::time::Duration;

// Default timeouts (can be overridden via environment variables)
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;

#[derive(Debug, Clone)]
pub enum ApiProvider {
OpenAI {
Expand Down Expand Up @@ -108,20 +112,31 @@ pub struct ApiClient {
}

impl ApiClient {
pub fn new(provider: ApiProvider) -> Self {
// Create HTTP client with timeout to prevent hanging requests
pub fn new(provider: ApiProvider) -> Result<Self> {
// Get timeout values from environment variables or use defaults
let request_timeout = env::var("HTTP_REQUEST_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS);

let connect_timeout = env::var("HTTP_CONNECT_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_CONNECT_TIMEOUT_SECS);

// Create HTTP client with configurable timeouts to prevent hanging requests
let client = Client::builder()
.timeout(Duration::from_secs(30)) // 30 second timeout
.connect_timeout(Duration::from_secs(10)) // 10 second connection timeout
.timeout(Duration::from_secs(request_timeout))
.connect_timeout(Duration::from_secs(connect_timeout))
.build()
.expect("Failed to build HTTP client");
.map_err(|e| ChatError::ApiError(format!("Failed to build HTTP client: {}", e)))?;

Self { provider, client }
Ok(Self { provider, client })
}

pub fn from_env() -> Result<Self> {
let provider = ApiProvider::from_env()?;
Ok(Self::new(provider))
Self::new(provider)
}

pub async fn send_message(
Expand Down
3 changes: 3 additions & 0 deletions lib_chat/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ pub enum ChatError {

#[error("Environment variable not set: {0}")]
EnvError(String),

#[error("Invalid input: {0}")]
InvalidInput(String),
}

pub type Result<T> = std::result::Result<T, ChatError>;
93 changes: 80 additions & 13 deletions lib_chat/src/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,36 +42,77 @@ impl Message {
pub struct ConversationHistory {
messages: Vec<Message>,
max_messages: usize,
max_bytes_total: usize, // Max total memory for all messages
max_bytes_per_message: usize, // Max size for a single message
}

impl ConversationHistory {
pub fn new(max_messages: usize) -> Self {
Self::new_with_limits(
max_messages,
10 * 1024 * 1024, // 10MB total by default
1 * 1024 * 1024, // 1MB per message by default
)
}

pub fn new_with_limits(
max_messages: usize,
max_bytes_total: usize,
max_bytes_per_message: usize,
) -> Self {
Self {
messages: Vec::new(),
max_messages,
max_bytes_total,
max_bytes_per_message,
}
}

pub fn add_message(&mut self, message: Message) {
/// Calculate total byte size of all messages
fn total_bytes(&self) -> usize {
self.messages
.iter()
.map(|m| m.content.len())
.sum()
}

pub fn add_message(&mut self, message: Message) -> Result<(), String> {
// Check individual message size
let message_bytes = message.content.len();
if message_bytes > self.max_bytes_per_message {
return Err(format!(
"Message too large: {} bytes (max {} bytes)",
message_bytes, self.max_bytes_per_message
));
}

self.messages.push(message);

// Keep only the most recent messages
// Keep only the most recent messages by count
if self.messages.len() > self.max_messages {
let start = self.messages.len() - self.max_messages;
self.messages.drain(0..start);
}

// Keep only the most recent messages by total size
while self.total_bytes() > self.max_bytes_total && self.messages.len() > 1 {
// Remove oldest message
self.messages.remove(0);
}

Ok(())
}

pub fn add_user_message(&mut self, content: impl Into<String>) {
self.add_message(Message::user(content));
pub fn add_user_message(&mut self, content: impl Into<String>) -> Result<(), String> {
self.add_message(Message::user(content))
}

pub fn add_assistant_message(&mut self, content: impl Into<String>) {
self.add_message(Message::assistant(content));
pub fn add_assistant_message(&mut self, content: impl Into<String>) -> Result<(), String> {
self.add_message(Message::assistant(content))
}

pub fn add_system_message(&mut self, content: impl Into<String>) {
self.add_message(Message::system(content));
pub fn add_system_message(&mut self, content: impl Into<String>) -> Result<(), String> {
self.add_message(Message::system(content))
}

pub fn messages(&self) -> &[Message] {
Expand Down Expand Up @@ -116,25 +157,51 @@ mod tests {
fn test_conversation_history() {
let mut history = ConversationHistory::new(3);

history.add_user_message("Message 1");
history.add_assistant_message("Response 1");
history.add_user_message("Message 2");
history.add_user_message("Message 1").unwrap();
history.add_assistant_message("Response 1").unwrap();
history.add_user_message("Message 2").unwrap();

assert_eq!(history.len(), 3);

// Adding more messages should drop oldest
history.add_assistant_message("Response 2");
history.add_assistant_message("Response 2").unwrap();
assert_eq!(history.len(), 3);
assert_eq!(history.messages()[0].content, "Response 1");
}

#[test]
fn test_clear_history() {
let mut history = ConversationHistory::new(10);
history.add_user_message("Test");
history.add_user_message("Test").unwrap();
assert!(!history.is_empty());

history.clear();
assert!(history.is_empty());
}

#[test]
fn test_message_size_limit() {
let mut history = ConversationHistory::new_with_limits(10, 1000, 100);

// Message within limit should succeed
assert!(history.add_user_message("x".repeat(50)).is_ok());

// Message exceeding limit should fail
let result = history.add_user_message("x".repeat(150));
assert!(result.is_err());
}

#[test]
fn test_total_size_limit() {
let mut history = ConversationHistory::new_with_limits(10, 200, 100);

// Add messages that together exceed total limit
history.add_user_message("x".repeat(80)).unwrap();
history.add_user_message("x".repeat(80)).unwrap();
history.add_user_message("x".repeat(80)).unwrap();

// Should have dropped old messages to stay under total limit
assert!(history.total_bytes() <= 200);
assert!(history.len() < 3);
}
}
35 changes: 25 additions & 10 deletions lib_chat/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,17 @@ use tokio::runtime::Runtime;
///
/// Creating a new Runtime on every request is expensive (~10-50ms overhead).
/// This static runtime is created once and reused for all chat operations.
static RUNTIME: Lazy<Runtime> =
Lazy::new(|| Runtime::new().expect("Failed to create tokio runtime"));
///
/// # Panics
/// Will panic if the tokio runtime cannot be created. This is a critical failure
/// that indicates system resource exhaustion or misconfiguration.
static RUNTIME: Lazy<Runtime> = Lazy::new(|| {
Runtime::new().expect(
"FATAL: Failed to create tokio runtime. \
This likely indicates system resource exhaustion. \
Check available memory and file descriptors.",
)
});

pub struct Chat {
client: Option<ApiClient>,
Expand All @@ -34,11 +43,11 @@ impl Chat {
}

/// Create a Chat instance with a specific provider
pub fn with_provider(provider: ApiProvider) -> Self {
Self {
client: Some(ApiClient::new(provider)),
pub fn with_provider(provider: ApiProvider) -> Result<Self> {
Ok(Self {
client: Some(ApiClient::new(provider)?),
history: ConversationHistory::default(),
}
})
}

/// Send a message and get a response (async)
Expand All @@ -49,15 +58,19 @@ impl Chat {
.ok_or_else(|| error::ChatError::NoProviderError)?;

// Add user message to history
self.history.add_user_message(message);
self.history
.add_user_message(message)
.map_err(|e| error::ChatError::InvalidInput(e))?;

// Send to API with full conversation history
let response = client
.send_message(self.history.messages(), Some(0.7), Some(1000))
.await?;

// Add assistant response to history
self.history.add_assistant_message(&response);
self.history
.add_assistant_message(&response)
.map_err(|e| error::ChatError::InvalidInput(e))?;

Ok(response)
}
Expand All @@ -73,8 +86,10 @@ impl Chat {
}

/// Add a system message to guide the conversation
pub fn set_system_prompt(&mut self, prompt: &str) {
self.history.add_system_message(prompt);
pub fn set_system_prompt(&mut self, prompt: &str) -> Result<()> {
self.history
.add_system_message(prompt)
.map_err(|e| error::ChatError::InvalidInput(e))
}

/// Clear conversation history
Expand Down
40 changes: 39 additions & 1 deletion lib_core/src/tract_llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,51 @@ impl Core {
pub fn is_safe_command(&self, command: &str) -> bool {
is_safe_command(command)
}

/// Generates an explanation for what a command does
///
/// This helps users understand generated commands before executing them.
/// The explanation describes the command's purpose, flags used, and potential side effects.
///
/// # Example
/// ```ignore
/// let explanation = core.explain_command("ls -la")?;
/// // Returns: "Lists all files in long format, including hidden files"
/// ```
pub fn explain_command(&self, command: &str) -> TractResult<String> {
let prompt = format!("Explain what this command does: {}", command);

let encoding = self.tokenizer.encode(prompt.as_str(), true).map_err(|e| anyhow!(e))?;
let input_ids: Vec<i64> = encoding.get_ids().iter().map(|&id| id as i64).collect();
let input_tensor = arr1(&input_ids).into_dyn().into_tensor();

let result = self.model.run(tvec!(input_tensor.into()))?;

let output_tensor = result[0].to_array_view::<i64>()?;
let output_ids: Vec<u32> = output_tensor.iter().map(|&id| id as u32).collect();

let explanation = self
.tokenizer
.decode(&output_ids, true)
.map_err(|e| anyhow!(e))?;

Ok(explanation)
}
}

impl Default for Core {
/// Create Core with default paths
///
/// # Panics
/// Panics if the default model files ("model.onnx", "tokenizer.json") cannot be loaded.
/// This is intentional for Default trait - use Core::new() directly for error handling.
fn default() -> Self {
let model_path = "model.onnx";
let tokenizer_path = "tokenizer.json";

Core::new(model_path, tokenizer_path).expect("Failed to create Core instance")
Core::new(model_path, tokenizer_path).expect(
"FATAL: Failed to load default Core model. \
Ensure 'model.onnx' and 'tokenizer.json' exist in the current directory.",
)
}
}
8 changes: 4 additions & 4 deletions lib_core/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@
/// - `tests/` for comprehensive security test suite
pub fn is_safe_command(command: &str) -> bool {
// Whitelist of safe base commands that are read-only and don't modify system state.
// DO NOT add write commands. See SAFETY.md for rationale.
// DO NOT add write commands (including touch/mkdir). See SAFETY.md for rationale.
// Even "safe" write operations are excluded to maintain strict read-only policy.
let allowed_commands = [
"ls", "pwd", "echo", "cat", "head", "tail", "grep", "find", "wc", "date", "whoami",
"hostname", "uname", "df", "du", "free", "top", "ps", "which", "whereis", "file", "stat",
"touch", "mkdir",
];

// Dangerous patterns that should never be allowed
Expand Down Expand Up @@ -116,8 +116,8 @@ pub fn is_safe_command(command: &str) -> bool {
return false;
}

// Check if command starts with an allowed command
let first_word = cmd_trimmed.split_whitespace().next().unwrap_or("");
// Check if command starts with an allowed command (case-insensitive)
let first_word = cmd_lower.split_whitespace().next().unwrap_or("");
if !allowed_commands.contains(&first_word) {
return false;
}
Expand Down
3 changes: 3 additions & 0 deletions lib_translate/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ pub enum TranslateError {

#[error("No translator configured")]
NoTranslatorError,

#[error("Configuration error: {0}")]
ConfigError(String),
}

pub type Result<T> = std::result::Result<T, TranslateError>;
Loading
Loading