From cc3dace174c70f17381b4c8665405e62b29ddd3f Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Mon, 22 Jun 2026 12:19:16 -0500 Subject: [PATCH] Adding reasoning stream and config --- README.md | 6 +- docs/configuration.md | 15 +++- src/agent.rs | 14 ++- src/app.rs | 50 +++++++++-- src/config.rs | 131 +++++++++++++++++++++++++++++ src/providers/openai_compatible.rs | 38 ++++++++- src/ui/render.rs | 5 +- tests/agent_tests.rs | 110 +++++++++++++++++++++++- tests/config_tests.rs | 34 +++++++- 9 files changed, 389 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a3d0fe4..3ff9a58 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ cass check - Type `/`: show command autocomplete, including command arguments like `/model ` and `/new` - `Up`/`Down`: move through an autocomplete menu -- `Tab`/`Enter`: fill autocomplete selection -- `Enter`: send message / run command when no autocomplete menu is open +- `Enter`: fill autocomplete selection when a menu is open; otherwise send message / run command +- `Tab`: cycle reasoning effort (`off` โ†’ `low` โ†’ `medium` โ†’ `high`; required-reasoning models skip `off`) - `Ctrl-J`: insert newline - `Shift-Tab`: toggle read-only/full-access mode while idle - `Ctrl-O`: toggle compact/full tool output display @@ -94,7 +94,7 @@ Resume this chat with: cass --resume Tool calls are shown compactly by default; press `Ctrl-O` to expand full tool output. -Reasoning is hidden by default unless `show_reasoning` is enabled; press `Ctrl-Shift-R` to toggle it. When providers stream reasoning fields, Cass persists that reasoning and sends it back in future model context using the provider's reasoning field, such as `reasoning_content` or `reasoning`. +Reasoning is hidden by default unless `show_reasoning` is enabled; press `Ctrl-Shift-R` to toggle it. Press `Tab` to choose the reasoning effort for future turns. Model metadata controls whether reasoning is supported or required and how the effort is sent to the provider. When providers stream reasoning fields, Cass persists that reasoning and sends it back in future model context using the provider's reasoning field, such as `reasoning_content` or `reasoning`. Read-only mode allows `ls`, `read`, and `grep` within the launch cwd/`--cwd` and the bundled docs directory at `~/.cass/docs`. diff --git a/docs/configuration.md b/docs/configuration.md index cf35b0f..f38cf1b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -85,7 +85,13 @@ Example: "context_length": 262144, "max_output_tokens": 32768, "supports_tools": true, - "supports_streaming": true + "supports_streaming": true, + "reasoning": { + "supported": true, + "required": false, + "default_effort": "medium", + "request_format": "reasoning_effort" + } } ] } @@ -100,6 +106,13 @@ Fields: - `max_output_tokens`: optional positive integer. - `supports_tools`: optional boolean, defaults to `true`. - `supports_streaming`: optional boolean, defaults to `true`. +- `reasoning`: optional object. Defaults to reasoning support enabled with medium effort for model entries. + - `supported`: optional boolean, defaults to `true`. Set to `false` for models that do not accept reasoning controls. + - `required`: optional boolean, defaults to `false`. If `true`, Cass will not cycle reasoning effort to `off`. + - `default_effort`: optional `off`, `low`, `medium`, or `high`; defaults to `medium`. Cannot be `off` when `required` is `true`. + - `request_format`: optional `reasoning_effort` or `reasoning_object`; defaults to `reasoning_effort`. `reasoning_effort` sends a top-level `"reasoning_effort": "medium"`; `reasoning_object` sends `"reasoning": { "effort": "medium" }`. + +Reasoning effort is a runtime per-turn setting. Press `Tab` to cycle it while idle. For models with reasoning metadata, the default effort is `medium` unless overridden by `default_effort`; for models without metadata, reasoning starts `off`. ## Check configuration diff --git a/src/agent.rs b/src/agent.rs index b192a65..8ca9be0 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -1,5 +1,5 @@ use crate::access::AccessMode; -use crate::config::Config; +use crate::config::{Config, ReasoningEffort}; use crate::conversation::{now_ts, Conversation, Record, StoredToolCall}; use crate::prompt; use crate::providers::openai_compatible::{OpenAiCompatibleProvider, OpenAiCompatibleSettings}; @@ -34,6 +34,7 @@ pub struct AgentSettings { pub config: Config, pub cwd: PathBuf, pub mode: AccessMode, + pub reasoning_effort: ReasoningEffort, } const EMPTY_FINAL_RETRY_PROMPT: &str = "The previous response contained no user-facing text. Provide a concise final user-facing response summarizing the outcome. Do not call tools unless absolutely necessary."; @@ -61,10 +62,21 @@ pub async fn run_turn( return Ok(conversation); } }; + let reasoning_request_format = settings + .config + .model_metadata + .as_ref() + .map(|model| model.reasoning.request_format) + .unwrap_or_default(); + let reasoning_effort = settings + .reasoning_effort + .clamp_for_model(settings.config.model_metadata.as_ref()); let provider = OpenAiCompatibleProvider::new(OpenAiCompatibleSettings { model: settings.config.model.clone(), base_url: settings.config.active_provider.base_url.clone(), api_key, + reasoning_effort, + reasoning_request_format, }); let docs_dir = settings.config.docs_dir(); diff --git a/src/app.rs b/src/app.rs index 3c1b2df..80fe7db 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,6 @@ use crate::agent::{self, AgentEvent, AgentSettings}; use crate::cli::{self, Command}; -use crate::config::{Config, ModelDefinition}; +use crate::config::{Config, ModelDefinition, ReasoningEffort}; use crate::conversation::{self, Conversation}; use crate::prompt; use crate::ui::autofill::{AutoFillItem, AutoFillMenu}; @@ -96,6 +96,7 @@ async fn run_tui( let mut status = String::new(); let mut show_full_tools = false; let mut show_reasoning = config.show_reasoning; + let mut reasoning_effort = ReasoningEffort::default_for_model(config.model_metadata.as_ref()); let mut scroll: u16 = 0; let mut last_ctrl_c: Option = None; let mut handle: Option>> = None; @@ -197,6 +198,7 @@ async fn run_tui( busy: handle.is_some(), show_full_tools, show_reasoning, + reasoning_effort, scroll, autofill: autofill.as_ref(), }, @@ -232,10 +234,22 @@ async fn run_tui( } } (KeyCode::Tab, _) => { - if let Some(menu) = &autofill { - if let Some(next_input) = menu.apply(&input) { - input = next_input; - autofill_selected = 0; + if busy { + status = "reasoning effort can be changed when idle".into(); + } else { + let next = + reasoning_effort.next_for_model(config.model_metadata.as_ref()); + if next == reasoning_effort + && next == ReasoningEffort::Off + && !config + .model_metadata + .as_ref() + .is_some_and(|model| model.reasoning.supported) + { + status = "reasoning unsupported for this model".into(); + } else { + reasoning_effort = next; + status.clear(); } } } @@ -374,6 +388,11 @@ async fn run_tui( status = "model can be changed when idle".into(); } else { config.model = model.clone(); + config.model_metadata = + model_metadata_for(&config, &model)?; + reasoning_effort = ReasoningEffort::default_for_model( + config.model_metadata.as_ref(), + ); input.clear(); autofill_selected = 0; transcript.push(TranscriptBlock { @@ -507,6 +526,7 @@ async fn run_tui( config: config.clone(), cwd: cwd.clone(), mode, + reasoning_effort, }; let convo = conversation.clone(); let tx2 = tx.clone(); @@ -912,6 +932,16 @@ fn model_autofill(input: &str, selected: usize, config: &Config) -> Result Result> { + let models = crate::config::load_or_create_default_model_registry(&config.root)?; + Ok(models + .models + .iter() + .find(|model| model.id == model_id && model.provider == config.provider_id) + .cloned() + .or_else(|| models.models.into_iter().find(|model| model.id == model_id))) +} + fn model_matches(model: &ModelDefinition, query: &str) -> bool { if query.is_empty() { return true; @@ -947,6 +977,16 @@ fn model_detail(model: &ModelDefinition, current_model: &str) -> String { if !model.supports_streaming { parts.push("no streaming".to_string()); } + if model.reasoning.supported { + let label = if model.reasoning.required { + format!("reasoning {} required", model.reasoning.default_effort) + } else { + format!("reasoning {}", model.reasoning.default_effort) + }; + parts.push(label); + } else { + parts.push("no reasoning".to_string()); + } parts.join(" ยท ") } diff --git a/src/config.rs b/src/config.rs index eeb37d6..7f36490 100644 --- a/src/config.rs +++ b/src/config.rs @@ -85,6 +85,37 @@ pub struct ModelDefinition { pub supports_tools: bool, #[serde(default = "default_true")] pub supports_streaming: bool, + #[serde(default)] + pub reasoning: ReasoningMetadata, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReasoningMetadata { + #[serde(default = "default_true")] + pub supported: bool, + #[serde(default)] + pub required: bool, + #[serde(default = "default_reasoning_effort")] + pub default_effort: ReasoningEffort, + #[serde(default)] + pub request_format: ReasoningRequestFormat, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ReasoningEffort { + Off, + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningRequestFormat { + ReasoningEffort, + ReasoningObject, } #[derive(Debug, Clone)] @@ -120,6 +151,93 @@ pub enum ApiKeyReference { Literal, } +impl Default for ReasoningMetadata { + fn default() -> Self { + Self { + supported: true, + required: false, + default_effort: ReasoningEffort::Medium, + request_format: ReasoningRequestFormat::ReasoningEffort, + } + } +} + +impl Default for ReasoningRequestFormat { + fn default() -> Self { + Self::ReasoningEffort + } +} + +impl std::fmt::Display for ReasoningEffort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + ReasoningEffort::Off => "off", + ReasoningEffort::Low => "low", + ReasoningEffort::Medium => "medium", + ReasoningEffort::High => "high", + }) + } +} + +impl ReasoningEffort { + pub fn default_for_model(model: Option<&ModelDefinition>) -> Self { + let Some(model) = model else { + return Self::Off; + }; + if !model.reasoning.supported { + return Self::Off; + } + if model.reasoning.required && model.reasoning.default_effort == Self::Off { + Self::Medium + } else { + model.reasoning.default_effort + } + } + + pub fn next_for_model(self, model: Option<&ModelDefinition>) -> Self { + let Some(model) = model else { + return Self::Off; + }; + if !model.reasoning.supported { + return Self::Off; + } + let required = model.reasoning.required; + match (self, required) { + (Self::Off, _) => Self::Low, + (Self::Low, _) => Self::Medium, + (Self::Medium, _) => Self::High, + (Self::High, true) => Self::Low, + (Self::High, false) => Self::Off, + } + } + + pub fn clamp_for_model(self, model: Option<&ModelDefinition>) -> Self { + let Some(model) = model else { + return Self::Off; + }; + if !model.reasoning.supported { + return Self::Off; + } + if model.reasoning.required && self == Self::Off { + return Self::default_for_model(Some(model)); + } + self + } + + pub fn request_value(self) -> Option<&'static str> { + match self { + Self::Off => None, + Self::Low => Some("low"), + Self::Medium => Some("medium"), + Self::High => Some("high"), + } + } +} + +fn default_reasoning_effort() -> ReasoningEffort { + ReasoningEffort::Medium +} + impl Default for Config { fn default() -> Self { let root = cass_root(); @@ -335,6 +453,7 @@ pub fn default_model_definition() -> ModelDefinition { max_output_tokens: Some(32_768), supports_tools: true, supports_streaming: true, + reasoning: ReasoningMetadata::default(), } } @@ -472,6 +591,18 @@ pub fn validate_registries( model.id )); } + if model.reasoning.required && !model.reasoning.supported { + out.errors.push(format!( + "models.json: model `{}` cannot require reasoning when reasoning is unsupported", + model.id + )); + } + if model.reasoning.required && model.reasoning.default_effort == ReasoningEffort::Off { + out.errors.push(format!( + "models.json: model `{}` reasoning default_effort cannot be `off` when reasoning is required", + model.id + )); + } } for ((provider, id), count) in model_counts { if count > 1 { diff --git a/src/providers/openai_compatible.rs b/src/providers/openai_compatible.rs index 812af00..9d8341f 100644 --- a/src/providers/openai_compatible.rs +++ b/src/providers/openai_compatible.rs @@ -1,5 +1,6 @@ use super::types::{CompletionResult, ModelMessage}; use crate::agent::AgentEvent; +use crate::config::{ReasoningEffort, ReasoningRequestFormat}; use crate::conversation::StoredToolCall; use crate::tools::ToolSpec; use anyhow::{bail, Result}; @@ -15,6 +16,8 @@ pub struct OpenAiCompatibleProvider { model: String, base_url: String, api_key: String, + reasoning_effort: ReasoningEffort, + reasoning_request_format: ReasoningRequestFormat, } #[derive(Debug, Clone)] @@ -22,6 +25,8 @@ pub struct OpenAiCompatibleSettings { pub model: String, pub base_url: String, pub api_key: String, + pub reasoning_effort: ReasoningEffort, + pub reasoning_request_format: ReasoningRequestFormat, } #[derive(Debug, Default)] @@ -38,6 +43,8 @@ impl OpenAiCompatibleProvider { model: settings.model, base_url: normalize_base_url(&settings.base_url), api_key: settings.api_key, + reasoning_effort: settings.reasoning_effort, + reasoning_request_format: settings.reasoning_request_format, } } @@ -48,12 +55,17 @@ impl OpenAiCompatibleProvider { tx: &mpsc::UnboundedSender, ) -> Result { let url = chat_url(&self.base_url); - let body = json!({ + let mut body = json!({ "model": self.model, "messages": messages_to_openai(messages), "tools": tools_to_openai(tools), "stream": true }); + apply_reasoning_request( + &mut body, + self.reasoning_effort, + self.reasoning_request_format, + ); let resp = self .client .post(url) @@ -203,6 +215,30 @@ fn reasoning_delta(delta: &Value) -> Option<(&'static str, &str)> { }) } +fn apply_reasoning_request( + body: &mut Value, + effort: ReasoningEffort, + format: ReasoningRequestFormat, +) { + let Some(effort) = effort.request_value() else { + return; + }; + let Value::Object(obj) = body else { + return; + }; + match format { + ReasoningRequestFormat::ReasoningEffort => { + obj.insert( + "reasoning_effort".to_string(), + Value::String(effort.to_string()), + ); + } + ReasoningRequestFormat::ReasoningObject => { + obj.insert("reasoning".to_string(), json!({ "effort": effort })); + } + } +} + fn assistant_message_to_openai( content: String, reasoning: String, diff --git a/src/ui/render.rs b/src/ui/render.rs index 5cb7edb..e6f454d 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -1,4 +1,5 @@ use crate::access::AccessMode; +use crate::config::ReasoningEffort; use crate::ui::autofill::AutoFillMenu; use crate::ui::theme; use ratatui::layout::{Constraint, Direction, Layout}; @@ -37,6 +38,7 @@ pub struct RenderState<'a> { pub busy: bool, pub show_full_tools: bool, pub show_reasoning: bool, + pub reasoning_effort: ReasoningEffort, pub scroll: u16, pub autofill: Option<&'a AutoFillMenu>, } @@ -354,8 +356,9 @@ fn footer_text(state: &RenderState<'_>) -> String { if state.show_full_tools { parts.push("tools:full".into()); } + parts.push(format!("reasoning:{}", state.reasoning_effort)); if state.show_reasoning { - parts.push("reasoning:on".into()); + parts.push("reasoning:visible".into()); } if !state.status.trim().is_empty() { parts.push(state.status.trim().to_string()); diff --git a/tests/agent_tests.rs b/tests/agent_tests.rs index 9bf41ab..83f4d84 100644 --- a/tests/agent_tests.rs +++ b/tests/agent_tests.rs @@ -1,6 +1,6 @@ use cassady::access::AccessMode; use cassady::agent::{run_turn, AgentEvent, AgentSettings}; -use cassady::config::Config; +use cassady::config::{Config, ReasoningEffort, ReasoningRequestFormat}; use cassady::conversation::{Conversation, Record}; use tempfile::tempdir; use tokio::sync::mpsc; @@ -11,6 +11,111 @@ fn sse(body: &str) -> ResponseTemplate { ResponseTemplate::new(200).set_body_raw(body.as_bytes().to_vec(), "text/event-stream") } +#[tokio::test] +async fn reasoning_effort_is_sent_as_top_level_field() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .and(body_string_contains("\"reasoning_effort\":\"high\"")) + .respond_with(sse( + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Done.\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n", + )) + .expect(1) + .mount(&server) + .await; + + let root = tempdir().unwrap(); + let cwd = tempdir().unwrap(); + let docs = tempdir().unwrap(); + let config = Config { + root: root.path().to_path_buf(), + docs_dir: docs.path().to_path_buf(), + model: "test-model".into(), + active_provider: cassady::config::ResolvedProviderConfig { + base_url: server.uri(), + api_key: "test-key".into(), + ..Config::default().active_provider + }, + ..Config::default() + }; + let conversation = Conversation::create( + &config.conversations_dir(), + &config.model, + cwd.path(), + "base prompt".into(), + ) + .unwrap(); + let (tx, _rx) = mpsc::unbounded_channel::(); + + run_turn( + conversation, + "use high reasoning".into(), + AgentSettings { + config, + cwd: cwd.path().to_path_buf(), + mode: AccessMode::ReadOnly, + reasoning_effort: ReasoningEffort::High, + }, + tx, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn reasoning_effort_supports_reasoning_object_format() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .and(body_string_contains("\"reasoning\":{\"effort\":\"low\"}")) + .respond_with(sse( + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Done.\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n", + )) + .expect(1) + .mount(&server) + .await; + + let root = tempdir().unwrap(); + let cwd = tempdir().unwrap(); + let docs = tempdir().unwrap(); + let mut model_metadata = cassady::config::default_model_definition(); + model_metadata.reasoning.request_format = ReasoningRequestFormat::ReasoningObject; + let config = Config { + root: root.path().to_path_buf(), + docs_dir: docs.path().to_path_buf(), + model: "test-model".into(), + model_metadata: Some(model_metadata), + active_provider: cassady::config::ResolvedProviderConfig { + base_url: server.uri(), + api_key: "test-key".into(), + ..Config::default().active_provider + }, + ..Config::default() + }; + let conversation = Conversation::create( + &config.conversations_dir(), + &config.model, + cwd.path(), + "base prompt".into(), + ) + .unwrap(); + let (tx, _rx) = mpsc::unbounded_channel::(); + + run_turn( + conversation, + "use object reasoning".into(), + AgentSettings { + config, + cwd: cwd.path().to_path_buf(), + mode: AccessMode::ReadOnly, + reasoning_effort: ReasoningEffort::Low, + }, + tx, + ) + .await + .unwrap(); +} + #[tokio::test] async fn reasoning_is_streamed_persisted_and_sent_back() { let server = MockServer::start().await; @@ -70,6 +175,7 @@ async fn reasoning_is_streamed_persisted_and_sent_back() { config: config.clone(), cwd: cwd.path().to_path_buf(), mode: AccessMode::ReadOnly, + reasoning_effort: ReasoningEffort::Off, }, tx, ) @@ -103,6 +209,7 @@ async fn reasoning_is_streamed_persisted_and_sent_back() { config, cwd: cwd.path().to_path_buf(), mode: AccessMode::ReadOnly, + reasoning_effort: ReasoningEffort::Off, }, tx, ) @@ -166,6 +273,7 @@ async fn empty_final_response_is_reprompted_and_persisted() { config, cwd: cwd.path().to_path_buf(), mode: AccessMode::ReadOnly, + reasoning_effort: ReasoningEffort::Off, }, tx, ) diff --git a/tests/config_tests.rs b/tests/config_tests.rs index eab4882..12a7984 100644 --- a/tests/config_tests.rs +++ b/tests/config_tests.rs @@ -1,6 +1,9 @@ use cassady::check; use cassady::cli::Cli; -use cassady::config::{self, Config, ModelsFile, ProviderDefinition, ProvidersFile}; +use cassady::config::{ + self, Config, ModelsFile, ProviderDefinition, ProvidersFile, ReasoningEffort, + ReasoningRequestFormat, +}; use tempfile::tempdir; fn cli() -> Cli { @@ -42,6 +45,15 @@ fn default_provider_and_model_files_are_created() { serde_json::from_str(&std::fs::read_to_string(root.path().join("models.json")).unwrap()) .unwrap(); assert_eq!(models.models[0].provider, "fireworks"); + assert!(models.models[0].reasoning.supported); + assert_eq!( + models.models[0].reasoning.default_effort, + ReasoningEffort::Medium + ); + assert_eq!( + models.models[0].reasoning.request_format, + ReasoningRequestFormat::ReasoningEffort + ); } #[test] @@ -94,6 +106,26 @@ fn api_key_resolution_supports_env_refs_and_literals() { } } +#[test] +fn reasoning_defaults_to_supported_medium_for_model_metadata() { + let model: config::ModelDefinition = serde_json::from_str( + r#"{ + "id": "test-model", + "provider": "test-provider" +} +"#, + ) + .unwrap(); + + assert!(model.reasoning.supported); + assert!(!model.reasoning.required); + assert_eq!(model.reasoning.default_effort, ReasoningEffort::Medium); + assert_eq!( + model.reasoning.request_format, + ReasoningRequestFormat::ReasoningEffort + ); +} + #[test] fn validation_rejects_duplicate_provider_ids() { let providers = ProvidersFile {