fix(room): reasoning chain fallback, streaming error messages, borrow fixes
- ReAct streaming: collect all ReactStep chunks into reasoning_buffer; if no Answer step is emitted, persist the full reasoning chain instead of empty content - All AI error paths (reasoning loop failure, non-streaming errors) now send user-visible [AI error: ...] messages instead of silently dropping - Fix borrow checker: clone content before struct init, use should_log bool to avoid double-borrow on err_msg
This commit is contained in:
parent
d89d02e81b
commit
beee62832f
@ -284,7 +284,7 @@ impl RoomService {
|
||||
.await;
|
||||
}
|
||||
|
||||
let should_respond = match self.should_ai_respond(room_id).await {
|
||||
let should_respond = match self.should_ai_respond(room_id, &content).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(room_id = %room_id, error = %e, "should_ai_respond failed");
|
||||
|
||||
@ -756,7 +756,16 @@ impl RoomService {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn should_ai_respond(&self, room_id: Uuid) -> Result<bool, RoomError> {
|
||||
/// Determine whether AI should respond to a message in this room.
|
||||
/// - No room_ai config → AI not configured, never respond.
|
||||
/// - use_exact = false → respond to every text message.
|
||||
/// - use_exact = true → only respond when the message contains an @[ai:...] or
|
||||
/// <mention type="ai">... tag that mentions this room's configured AI model.
|
||||
pub async fn should_ai_respond(
|
||||
&self,
|
||||
room_id: Uuid,
|
||||
content: &str,
|
||||
) -> Result<bool, RoomError> {
|
||||
use models::rooms::room_ai;
|
||||
|
||||
let ai_config = room_ai::Entity::find()
|
||||
@ -764,7 +773,37 @@ impl RoomService {
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
Ok(ai_config.is_some())
|
||||
let config = match ai_config {
|
||||
Some(c) => c,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
if !config.use_exact {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// use_exact mode: only respond when AI is explicitly mentioned
|
||||
let model_id_str = config.model.to_string();
|
||||
|
||||
// Check @[ai:model_id:label] format
|
||||
for cap in MENTION_BRACKET_RE.captures_iter(content) {
|
||||
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
|
||||
if type_m.as_str() == "ai" && id_m.as_str().trim() == model_id_str {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check <mention type="ai" id="model_id">label</mention> format
|
||||
for cap in MENTION_TAG_RE.captures_iter(content) {
|
||||
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
|
||||
if type_m.as_str() == "ai" && id_m.as_str().trim() == model_id_str {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn get_room_ai_config(
|
||||
@ -1179,6 +1218,20 @@ impl RoomService {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "AI processing failed");
|
||||
// Send an error message so the user knows something went wrong
|
||||
let _ = Self::create_and_publish_ai_message(
|
||||
&db,
|
||||
&cache,
|
||||
&queue,
|
||||
&room_manager,
|
||||
room_id_for_ai,
|
||||
project_id_for_ai,
|
||||
Uuid::now_v7(),
|
||||
format!("[AI error: {}]", e),
|
||||
model_id_inner,
|
||||
Some(model_display_name),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -1249,6 +1302,19 @@ impl RoomService {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "ReAct agent failed");
|
||||
let _ = Self::create_and_publish_ai_message(
|
||||
&db,
|
||||
&cache,
|
||||
&queue,
|
||||
&room_manager,
|
||||
room_id_for_ai,
|
||||
project_id_for_ai,
|
||||
Uuid::now_v7(),
|
||||
format!("[AI error: {}]", e),
|
||||
model_id_inner,
|
||||
Some(model_display_name),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -1288,7 +1354,9 @@ impl RoomService {
|
||||
tokio::spawn(async move {
|
||||
let _lock_guard = lock_guard;
|
||||
|
||||
// Buffer each ReactStep and forward as a stream chunk.
|
||||
// Buffer all reasoning steps + the final answer separately.
|
||||
let reasoning_buffer: std::sync::Arc<std::sync::Mutex<String>> =
|
||||
std::sync::Arc::new(std::sync::Mutex::new(String::new()));
|
||||
let answer_buffer: std::sync::Arc<std::sync::Mutex<String>> =
|
||||
std::sync::Arc::new(std::sync::Mutex::new(String::new()));
|
||||
let step_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
@ -1299,6 +1367,7 @@ impl RoomService {
|
||||
let room_id = room_id_inner;
|
||||
let step_count = step_count.clone();
|
||||
let ai_display_name_for_step = std::sync::Arc::new(ai_display_name.clone());
|
||||
let reasoning_buffer = reasoning_buffer.clone();
|
||||
let answer_buffer = answer_buffer.clone();
|
||||
move |step: ReactStep| {
|
||||
let room_manager = room_manager.clone();
|
||||
@ -1320,31 +1389,35 @@ impl RoomService {
|
||||
}
|
||||
};
|
||||
|
||||
if let ReactStep::Answer { .. } = &step {
|
||||
let is_answer = matches!(&step, ReactStep::Answer { .. });
|
||||
if is_answer {
|
||||
step_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let done = matches!(&step, ReactStep::Answer { .. });
|
||||
let content_for_buffer = if done {
|
||||
content.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let done = is_answer;
|
||||
|
||||
let ai_name = ai_display_name_for_step.clone();
|
||||
let reasoning_buf = reasoning_buffer.clone();
|
||||
let answer_buf = answer_buffer.clone();
|
||||
tokio::spawn(async move {
|
||||
// Always broadcast every step as a stream chunk
|
||||
let event = RoomMessageStreamChunkEvent {
|
||||
message_id: streaming_msg_id,
|
||||
room_id,
|
||||
content,
|
||||
content: content.clone(),
|
||||
done,
|
||||
error: None,
|
||||
display_name: Some((*ai_name).clone()),
|
||||
};
|
||||
room_manager.broadcast_stream_chunk(event).await;
|
||||
if done {
|
||||
answer_buf.lock().unwrap().push_str(&content_for_buffer);
|
||||
|
||||
// Collect all steps into reasoning_buffer; Answer goes to answer_buffer
|
||||
let mut rb = reasoning_buf.lock().unwrap();
|
||||
rb.push_str(&content);
|
||||
rb.push('\n');
|
||||
drop(rb);
|
||||
if is_answer {
|
||||
answer_buf.lock().unwrap().push_str(&content);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -1355,89 +1428,110 @@ impl RoomService {
|
||||
.await;
|
||||
|
||||
let final_content = answer_buffer.lock().unwrap().clone();
|
||||
let reasoning_chain = reasoning_buffer.lock().unwrap().clone();
|
||||
|
||||
match result {
|
||||
Ok(_) if !final_content.is_empty() => {
|
||||
let envelope = RoomMessageEnvelope {
|
||||
id: streaming_msg_id,
|
||||
dedup_key: Some(format!("{}:{}", room_id_inner, streaming_msg_id)),
|
||||
room_id: room_id_inner,
|
||||
sender_type: sender_type.clone(),
|
||||
sender_id: None,
|
||||
model_id: Some(model_id_inner),
|
||||
thread_id: None,
|
||||
content: final_content.clone(),
|
||||
content_type: "text".to_string(),
|
||||
send_at: now,
|
||||
seq,
|
||||
in_reply_to: None,
|
||||
};
|
||||
// Determine what to persist: prefer the answer, fall back to the reasoning chain
|
||||
let content_to_persist = if !final_content.is_empty() {
|
||||
final_content
|
||||
} else if !reasoning_chain.trim().is_empty() {
|
||||
// No Answer step, but the reasoning chain was streamed — still send it
|
||||
format!(
|
||||
"[Agent ran through {} reasoning steps but did not produce a final answer.]\n{}",
|
||||
step_count.load(std::sync::atomic::Ordering::Relaxed),
|
||||
reasoning_chain.trim_end()
|
||||
)
|
||||
} else {
|
||||
// Nothing produced — this should not happen in practice
|
||||
String::from("[No output from reasoning agent]")
|
||||
};
|
||||
|
||||
if let Err(e) = queue.publish(room_id_inner, envelope).await {
|
||||
tracing::error!(error = %e, "Failed to publish ReAct streaming message");
|
||||
} else {
|
||||
let now = Utc::now();
|
||||
if let Err(e) = room_ai::Entity::update_many()
|
||||
.col_expr(
|
||||
room_ai::Column::CallCount,
|
||||
Expr::col(room_ai::Column::CallCount).add(1),
|
||||
)
|
||||
.col_expr(room_ai::Column::LastCallAt, Expr::value(Some(now)))
|
||||
.filter(room_ai::Column::Room.eq(room_id_inner))
|
||||
.filter(room_ai::Column::Model.eq(model_id_inner))
|
||||
.exec(&db)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "Failed to update room_ai call stats");
|
||||
}
|
||||
let (err_msg, should_log) = match &result {
|
||||
Err(e) => (Some(format!("[Agent error: {}]", e)), true),
|
||||
_ => (None, false),
|
||||
};
|
||||
|
||||
let msg_event = queue::RoomMessageEvent {
|
||||
id: streaming_msg_id,
|
||||
room_id: room_id_inner,
|
||||
sender_type: sender_type.clone(),
|
||||
sender_id: None,
|
||||
thread_id: None,
|
||||
content: final_content,
|
||||
content_type: "text".to_string(),
|
||||
send_at: now,
|
||||
seq,
|
||||
display_name: Some(ai_display_name.clone()),
|
||||
in_reply_to: None,
|
||||
reactions: None,
|
||||
message_id: None,
|
||||
};
|
||||
room_manager.broadcast(room_id_inner, msg_event).await;
|
||||
room_manager.metrics.messages_sent.increment(1);
|
||||
let content_to_persist = if let Some(msg) = &err_msg {
|
||||
format!(
|
||||
"{}\n[Error during reasoning: {}]",
|
||||
content_to_persist.trim_end(),
|
||||
msg.trim_start_matches("[Agent error: ").trim_end_matches("]")
|
||||
)
|
||||
} else {
|
||||
content_to_persist
|
||||
};
|
||||
|
||||
let event = queue::ProjectRoomEvent {
|
||||
event_type: super::RoomEventType::NewMessage.as_str().into(),
|
||||
project_id: project_id_inner,
|
||||
room_id: Some(room_id_inner),
|
||||
category_id: None,
|
||||
message_id: Some(streaming_msg_id),
|
||||
seq: Some(seq),
|
||||
timestamp: now,
|
||||
};
|
||||
queue
|
||||
.publish_project_room_event(project_id_inner, event)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::warn!("ReAct agent returned empty answer");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "ReAct streaming failed");
|
||||
let event = RoomMessageStreamChunkEvent {
|
||||
message_id: streaming_msg_id,
|
||||
room_id: room_id_inner,
|
||||
content: String::new(),
|
||||
done: true,
|
||||
error: Some(e.to_string()),
|
||||
display_name: Some(ai_display_name.clone()),
|
||||
};
|
||||
room_manager.broadcast_stream_chunk(event).await;
|
||||
if should_log {
|
||||
tracing::error!(error = %result.as_ref().unwrap_err(), "ReAct streaming failed");
|
||||
}
|
||||
|
||||
let persist_content = content_to_persist.trim().to_string();
|
||||
if persist_content.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let envelope = RoomMessageEnvelope {
|
||||
id: streaming_msg_id,
|
||||
dedup_key: Some(format!("{}:{}", room_id_inner, streaming_msg_id)),
|
||||
room_id: room_id_inner,
|
||||
sender_type: sender_type.clone(),
|
||||
sender_id: None,
|
||||
model_id: Some(model_id_inner),
|
||||
thread_id: None,
|
||||
content: persist_content.clone(),
|
||||
content_type: "text".to_string(),
|
||||
send_at: now,
|
||||
seq,
|
||||
in_reply_to: None,
|
||||
};
|
||||
|
||||
if let Err(e) = queue.publish(room_id_inner, envelope).await {
|
||||
tracing::error!(error = %e, "Failed to publish ReAct streaming message");
|
||||
} else {
|
||||
let now = Utc::now();
|
||||
if let Err(e) = room_ai::Entity::update_many()
|
||||
.col_expr(
|
||||
room_ai::Column::CallCount,
|
||||
Expr::col(room_ai::Column::CallCount).add(1),
|
||||
)
|
||||
.col_expr(room_ai::Column::LastCallAt, Expr::value(Some(now)))
|
||||
.filter(room_ai::Column::Room.eq(room_id_inner))
|
||||
.filter(room_ai::Column::Model.eq(model_id_inner))
|
||||
.exec(&db)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "Failed to update room_ai call stats");
|
||||
}
|
||||
|
||||
let msg_event = queue::RoomMessageEvent {
|
||||
id: streaming_msg_id,
|
||||
room_id: room_id_inner,
|
||||
sender_type: sender_type.clone(),
|
||||
sender_id: None,
|
||||
thread_id: None,
|
||||
content: persist_content,
|
||||
content_type: "text".to_string(),
|
||||
send_at: now,
|
||||
seq,
|
||||
display_name: Some(ai_display_name.clone()),
|
||||
in_reply_to: None,
|
||||
reactions: None,
|
||||
message_id: None,
|
||||
};
|
||||
room_manager.broadcast(room_id_inner, msg_event).await;
|
||||
room_manager.metrics.messages_sent.increment(1);
|
||||
|
||||
let event = queue::ProjectRoomEvent {
|
||||
event_type: super::RoomEventType::NewMessage.as_str().into(),
|
||||
project_id: project_id_inner,
|
||||
room_id: Some(room_id_inner),
|
||||
category_id: None,
|
||||
message_id: Some(streaming_msg_id),
|
||||
seq: Some(seq),
|
||||
timestamp: now,
|
||||
};
|
||||
queue
|
||||
.publish_project_room_event(project_id_inner, event)
|
||||
.await;
|
||||
}
|
||||
|
||||
room_manager.close_stream_channel(streaming_msg_id).await;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user