Handle empty final assistant responses

This commit is contained in:
2026-06-21 14:47:08 -05:00
parent 9c1a1ed855
commit b2513895d2
4 changed files with 170 additions and 16 deletions
+49 -3
View File
@@ -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<AgentEvent>,
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<ModelMessage> {
let mut non_system = Vec::new();
for r in records {
+1 -1
View File
@@ -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
}
+26 -12
View File
@@ -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<usize, PartialToolCall>,
tx: &mpsc::UnboundedSender<AgentEvent>,
) -> 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,
+94
View File
@@ -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::<AgentEvent>();
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()
));
}