diff --git a/docs/context_engine.md b/docs/context_engine.md index 3a88338..53557d5 100644 --- a/docs/context_engine.md +++ b/docs/context_engine.md @@ -85,6 +85,19 @@ Before a provider request, Exoshell checks enabled context against the configure Pruning is deterministic and non-mutating. Low-priority unpinned entries are selected for removal first. Pinned entries and critical-priority entries are preserved as long as possible. +## Provider Waiting and Timeouts + +Provider requests are bounded by: + +```toml +[provider] +request_timeout_seconds = 120 +``` + +The default is 120 seconds. Exoshell prints a waiting message before sending a provider request so slow local model inference is visible. If the timeout is exceeded, Exoshell records the timeout in the transcript and returns to the REPL. + +Keyboard interrupt behavior is still terminal-level: interrupting the process stops the active request, and normal transcript writing happens when the REPL exits cleanly. + ## Transcripts Context lifecycle events are recorded as metadata: diff --git a/src/app.rs b/src/app.rs index 2f1ce1c..66e5aa2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ use std::path::PathBuf; +use std::time::Duration; use crate::config::Config; use crate::context::{ @@ -57,10 +58,19 @@ impl App { stream: false, }; - let response = match self.provider.chat(request).await { - Ok(ChatResponse::Complete(response)) => response, - Ok(ChatResponse::Stream(chunks)) => chunks.concat(), - Err(error) => { + let timeout = Duration::from_secs(self.config.provider.request_timeout_seconds); + let response = match tokio::time::timeout(timeout, self.provider.chat(request)).await { + Err(_) => { + let message = format!( + "provider request timed out after {} seconds", + self.config.provider.request_timeout_seconds + ); + self.transcript.record_error(&message); + return Err(AppError::Provider(ProviderError::Network(message))); + } + Ok(Ok(ChatResponse::Complete(response))) => response, + Ok(Ok(ChatResponse::Stream(chunks))) => chunks.concat(), + Ok(Err(error)) => { self.transcript.record_error(&error.to_string()); return Err(error.into()); } @@ -612,6 +622,20 @@ mod tests { assert!(seen.lock().expect("seen lock").is_empty()); } + #[tokio::test] + async fn provider_request_times_out() { + let mut config = test_config(); + config.provider.request_timeout_seconds = 0; + let mut app = App::new(config, Box::new(SlowProvider)); + + let error = app + .send("hello".into()) + .await + .expect_err("request should time out"); + + assert!(error.to_string().contains("timed out")); + } + #[test] fn stdin_context_uses_default_provider_path() { let mut app = App::new(test_config(), Box::new(NoopProvider)); @@ -647,6 +671,16 @@ mod tests { } } + struct SlowProvider; + + #[async_trait::async_trait] + impl Provider for SlowProvider { + async fn chat(&self, _request: ChatRequest) -> Result { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + Ok(ChatResponse::Complete("late".into())) + } + } + fn test_config() -> Config { Config { provider: ProviderConfig { diff --git a/src/repl.rs b/src/repl.rs index 2124e71..5c4013d 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -66,6 +66,7 @@ impl Repl { continue; } + println!("waiting for provider response..."); match self.app.send(input).await { Ok(response) => println!("\n{}\n", render_assistant_output(&response)), Err(error) => eprintln!("request failed: {error}"),