From b2513895d2dc6be3374cbce9f816d12d465a1cd3 Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Sun, 21 Jun 2026 14:47:08 -0500 Subject: [PATCH] Handle empty final assistant responses --- src/agent.rs | 52 ++++++++++++++++- src/prompt.rs | 2 +- src/providers/openai_compatible.rs | 38 ++++++++---- tests/agent_tests.rs | 94 ++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 tests/agent_tests.rs diff --git a/src/agent.rs b/src/agent.rs index 306f562..3f0b618 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -35,6 +35,8 @@ pub struct AgentSettings { pub mode: AccessMode, } +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."; + pub async fn run_turn( mut conversation: Conversation, user_message: String, @@ -49,7 +51,11 @@ pub async fn run_turn( let api_key = match settings.config.resolved_api_key() { Ok(api_key) => api_key, Err(err) => { - let _ = tx.send(AgentEvent::Status(err.to_string())); + append_visible_assistant( + &mut conversation, + &tx, + format!("I couldn't start the turn because the API key is not available: {err}"), + )?; let _ = tx.send(AgentEvent::TurnFinished); return Ok(conversation); } @@ -69,6 +75,7 @@ pub async fn run_turn( model_result_limit: settings.config.model_tool_result_limit, }; + let mut retrying_empty_final = false; loop { let allowed = tools::available_tool_names(settings.mode); let system = prompt::build_effective_system_prompt( @@ -79,23 +86,49 @@ pub async fn run_turn( &settings.config.model, &allowed, ); - let messages = build_messages( + let mut messages = build_messages( &conversation.records, system, settings.config.context_message_limit, ); + if retrying_empty_final { + messages.push(ModelMessage::User { + content: EMPTY_FINAL_RETRY_PROMPT.to_string(), + }); + } let completion = match provider .complete(messages, tools::specs(settings.mode), &tx) .await { Ok(c) => c, Err(err) => { - let _ = tx.send(AgentEvent::Status(format!("provider error: {err}"))); + append_visible_assistant( + &mut conversation, + &tx, + format!("I couldn't complete the turn because the provider returned an error: {err}"), + )?; break; } }; let tool_calls = completion.tool_calls.clone(); + if tool_calls.is_empty() && completion.content.trim().is_empty() { + if !retrying_empty_final { + retrying_empty_final = true; + let _ = tx.send(AgentEvent::Status( + "model returned an empty final response; requesting a final message".into(), + )); + continue; + } + append_visible_assistant( + &mut conversation, + &tx, + "The model finished without a final response.".into(), + )?; + break; + } + retrying_empty_final = false; + conversation.append(Record::Assistant { content: completion.content, tool_calls: tool_calls.clone(), @@ -131,6 +164,19 @@ pub async fn run_turn( Ok(conversation) } +fn append_visible_assistant( + conversation: &mut Conversation, + tx: &mpsc::UnboundedSender, + content: String, +) -> Result<()> { + let _ = tx.send(AgentEvent::AssistantChunk(content.clone())); + conversation.append(Record::Assistant { + content, + tool_calls: Vec::new(), + ts: now_ts(), + }) +} + fn build_messages(records: &[Record], system: String, limit: usize) -> Vec { let mut non_system = Vec::new(); for r in records { diff --git a/src/prompt.rs b/src/prompt.rs index 420c887..a95cdfd 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -46,6 +46,6 @@ pub fn build_effective_system_prompt( AccessMode::ReadOnly => prompt.push_str("In read-only mode, you may inspect files with ls, read, and grep only inside the launch working directory or bundled Cass docs directory. Do not request write or edit. If a task requires modification, explain what needs full-access mode.\n\n"), AccessMode::FullAccess => prompt.push_str("In full-access mode, you may request ls, read, grep, write, and edit when needed. Cass does not restrict read paths to the launch directory, but normal operating-system permissions still apply. write and edit are still blocked under the bundled Cass docs directory.\n\n"), } - prompt.push_str("6. Response behavior\n\nAssistant output is streamed to the user. Keep user-facing text direct and useful. Tool calls and results are visible to the user, so avoid claiming work happened until the relevant tool result confirms it.\n"); + prompt.push_str("6. Response behavior\n\nAssistant output is streamed to the user. Keep user-facing text direct and useful. Tool calls and results are visible to the user, so avoid claiming work happened until the relevant tool result confirms it. After using tools or completing requested work, always end the turn with a concise final user-facing response. Do not finish a turn with only tool calls.\n"); prompt } diff --git a/src/providers/openai_compatible.rs b/src/providers/openai_compatible.rs index a90da47..70f4a7b 100644 --- a/src/providers/openai_compatible.rs +++ b/src/providers/openai_compatible.rs @@ -73,23 +73,17 @@ impl OpenAiCompatibleProvider { let mut stream = resp.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk?; - buf.push_str(&String::from_utf8_lossy(&chunk)); + let chunk_text = String::from_utf8_lossy(&chunk).replace("\r\n", "\n"); + buf.push_str(&chunk_text); while let Some(pos) = buf.find("\n\n") { let frame = buf[..pos].to_string(); buf = buf[pos + 2..].to_string(); - for line in frame.lines() { - let line = line.trim(); - if !line.starts_with("data:") { - continue; - } - let data = line.trim_start_matches("data:").trim(); - if data == "[DONE]" { - continue; - } - handle_chunk(data, &mut content, &mut partials, tx)?; - } + process_frame(&frame, &mut content, &mut partials, tx)?; } } + if !buf.trim().is_empty() { + process_frame(&buf, &mut content, &mut partials, tx)?; + } let tool_calls = partials .into_iter() @@ -112,6 +106,26 @@ impl OpenAiCompatibleProvider { } } +fn process_frame( + frame: &str, + content: &mut String, + partials: &mut BTreeMap, + tx: &mpsc::UnboundedSender, +) -> Result<()> { + for line in frame.lines() { + let line = line.trim(); + if !line.starts_with("data:") { + continue; + } + let data = line.trim_start_matches("data:").trim(); + if data == "[DONE]" { + continue; + } + handle_chunk(data, content, partials, tx)?; + } + Ok(()) +} + fn handle_chunk( data: &str, content: &mut String, diff --git a/tests/agent_tests.rs b/tests/agent_tests.rs new file mode 100644 index 0000000..fbd8247 --- /dev/null +++ b/tests/agent_tests.rs @@ -0,0 +1,94 @@ +use cassady::access::AccessMode; +use cassady::agent::{run_turn, AgentEvent, AgentSettings}; +use cassady::config::Config; +use cassady::conversation::{Conversation, Record}; +use tempfile::tempdir; +use tokio::sync::mpsc; +use wiremock::matchers::{body_string_contains, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn sse(body: &str) -> ResponseTemplate { + ResponseTemplate::new(200).set_body_raw(body.as_bytes().to_vec(), "text/event-stream") +} + +#[tokio::test] +async fn empty_final_response_is_reprompted_and_persisted() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/chat/completions")) + .and(body_string_contains("previous response contained no user-facing text")) + .respond_with(sse( + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Done.\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n", + )) + .with_priority(1) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(sse( + "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\r\n\r\ndata: [DONE]\r\n\r\n", + )) + .with_priority(10) + .expect(1) + .mount(&server) + .await; + + let root = tempdir().unwrap(); + let cwd = tempdir().unwrap(); + let docs = tempdir().unwrap(); + let mut config = Config::default(); + config.root = root.path().to_path_buf(); + config.docs_dir = docs.path().to_path_buf(); + config.model = "test-model".into(); + config.active_provider.base_url = server.uri(); + config.active_provider.api_key = "test-key".into(); + + let conversation = Conversation::create( + &config.conversations_dir(), + &config.model, + cwd.path(), + "base prompt".into(), + ) + .unwrap(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + + let updated = run_turn( + conversation, + "finish empty once".into(), + AgentSettings { + config, + cwd: cwd.path().to_path_buf(), + mode: AccessMode::ReadOnly, + }, + tx, + ) + .await + .unwrap(); + + let mut streamed = String::new(); + let mut saw_retry_status = false; + let mut saw_finished = false; + while let Ok(event) = rx.try_recv() { + match event { + AgentEvent::AssistantChunk(chunk) => streamed.push_str(&chunk), + AgentEvent::Status(status) => { + saw_retry_status |= status.contains("empty final response"); + } + AgentEvent::TurnFinished => saw_finished = true, + _ => {} + } + } + + assert!(saw_retry_status); + assert!(saw_finished); + assert_eq!(streamed, "Done."); + let last = updated.records.last().unwrap(); + assert!(matches!( + last, + Record::Assistant { content, tool_calls, .. } + if content == "Done." && tool_calls.is_empty() + )); +}