重构: AI流式断线保文StreamResult三分支+重试对齐决策a1,推进链落df-nodes

This commit is contained in:
2026-06-16 19:11:19 +08:00
parent 7d5cd4c89a
commit ba7f35552b
30 changed files with 1325 additions and 283 deletions

View File

@@ -9,6 +9,9 @@ use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry;
use df_ai::context::TokenEstimator;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
use df_ai::retry;
use df_storage::db::Database;
use df_storage::models::AiProviderRecord;
@@ -18,7 +21,7 @@ use crate::state::{AppState, LlmConcurrency};
use super::conversation::{save_conversation, TokenAccumulator};
use super::knowledge_inject::maybe_spawn_extraction;
use super::prompt::{build_system_prompt, get_active_provider};
use super::stream_recv::stream_llm;
use super::stream_recv::{stream_llm, StreamResult};
use super::title::{ensure_conversation_title, spawn_ensure_title};
use super::audit::process_tool_calls;
@@ -32,6 +35,14 @@ use super::{AiChatEvent, AiSession, ErrorType};
/// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)。
pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
/// 流式对话失败自动重试默认次数F-260616-07 / 决策 a1
///
/// 默认 3 次(初次 + 2 次重试)。复用 retry::backoff_delay 退避(1s→2s→4s+jitter) +
/// retry::is_status_retryable Fatal 分类(4xx 非429 立即放弃) + 30s 总预算。
/// 只重试流前失败Init Err未输出任何 token流中途失败MidStream已输出
/// partial_text不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。
pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
// ============================================================
// B-260615-09: generating 状态 RAII guard
// ============================================================
@@ -99,6 +110,7 @@ pub(crate) async fn run_agentic_loop(
knowledge_config: crate::state::KnowledgeConfig,
llm_concurrency: LlmConcurrency,
max_iterations: usize,
max_retries: usize,
) {
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
let mut guard = GeneratingGuard::new(session_arc.clone());
@@ -170,7 +182,7 @@ pub(crate) async fn run_agentic_loop(
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
guard.reset().await;
// generating 复位后再 emit Completed保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
return;
}
@@ -208,34 +220,187 @@ pub(crate) async fn run_agentic_loop(
};
// 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补)
// 注:F-260616-07 重试循环内每次重建 retry_request(因 provider.stream 消费 body),
// 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。
let estimated_prompt: u32 = {
let est = TokenEstimator::default();
messages.iter().map(|m| est.estimate_message(m)).sum()
};
let request = CompletionRequest {
model: provider_config.default_model.clone(),
messages,
temperature: Some(0.7),
max_tokens: Some(8192),
stream: true,
tools: if tool_defs.is_empty() { None } else { Some(tool_defs.clone()) },
tool_choice: None,
};
// LLM 并发限流(全局 + 单对话双层),仅覆盖 stream_llm 调用本身;
// 工具执行(process_tool_calls)是本地操作无 RPM 成本,permit 在 stream 后立即释放避免占槽
//
// CR-30-1 / F-260616-07 / 决策 a1: 流前失败(Init Err)重试,流中途失败(MidStream
// Partial)不重试保文。重试退避复用 retry::backoff_delay(1s→2s→4s±20% jitter) +
// retry::is_status_retryable Fatal 分类(stream_recv classify_status_or_class 镜像,
// 4xx 非429 立即放弃) + 30s 总挂钟预算。重试期间持有 permit 不释放(防新请求挤占)。
let _global_permit = llm_concurrency.acquire_global().await;
let _per_conv_permit = llm_concurrency.acquire_per_conv().await;
// 流式接收(内部处理 idle timeout / 断连检测 / 停止信号)
let (full_text, tool_calls_acc, round_usage) = match stream_llm(&*provider, request, &app_handle, &stop_flag, &notify, &conv_id).await {
Some(result) => result,
None => {
// 错误已在 stream_llm 中 emit直接结束
guard.reset().await;
return;
// 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。
// 超预算直接放弃重试交最终错误/保文路径。
let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
let (full_text, tool_calls_acc, round_usage, incomplete) = {
// outcome 累积最后一次成功/保文结果(Complete 或 Partial)。InitFailed 不写入,
// 走重试或耗尽 return。
let mut outcome: Option<(String, std::collections::HashMap<u32, super::ToolCallDraft>, df_ai::provider::TokenUsage, bool)> = None;
for retry_attempt in 0..=max_retries {
// 每次重试重建 request(CompletionRequest 无状态,但 provider.stream() 内部可能消费 body)
let retry_request = CompletionRequest {
model: provider_config.default_model.clone(),
messages: {
let session = session_arc.lock().await;
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
let (history_msgs, _trimmed) = session.messages.build_for_request(sys_tokens);
let mut msgs = vec![ChatMessage::system(&system_prompt)];
msgs.extend(history_msgs);
msgs
},
temperature: Some(0.7),
max_tokens: Some(8192),
stream: true,
tools: if tool_defs.is_empty() { None } else { Some(tool_defs.clone()) },
tool_choice: None,
};
match stream_llm(&*provider, retry_request, &app_handle, &stop_flag, &notify, &conv_id).await {
StreamResult::Complete { text, tool_calls, usage } => {
// 正常完成(流尽 + finished,或用户主动停止)。incomplete=false。
outcome = Some((text, tool_calls, usage, false));
break;
}
StreamResult::Partial { text, tool_calls, usage } => {
// CR-30-2 / UX-2025-04 / 决策 a1: 流中途失败保文,**不重试**。
// 已 emit AiTextDelta(前端 currentText 已累积),此处保文入库 +
// AiCompleted(incomplete=true) + 系统提示。currentText 不混乱(未重试无追加)。
tracing::warn!(
conv_id = %conv_id,
text_len = text.len(),
"[ai] 流中途失败,保文不重试(incomplete=true),入库 + AiCompleted + 系统提示",
);
outcome = Some((text, tool_calls, usage, true));
break;
}
StreamResult::InitFailed { retryable } => {
// stream_llm 已 emit AiError。决定是否重试(仅 Init 失败可重试,决策 a1)。
let is_last = retry_attempt >= max_retries;
let now = tokio::time::Instant::now();
let budget_exhausted = now >= retry_deadline;
// Fatal(4xx 非429/鉴权/参数错)立即放弃,不浪费预算重试
if !retryable {
tracing::warn!(
conv_id = %conv_id,
attempt = retry_attempt + 1,
"[ai] 流前失败 Fatal(4xx/鉴权),立即放弃不重试",
);
guard.reset().await;
return;
}
if is_last || budget_exhausted {
// 重试耗尽或预算耗尽:错误已 emit,结束
tracing::warn!(
conv_id = %conv_id,
attempt = retry_attempt + 1,
budget_exhausted = budget_exhausted,
"[ai] 流前失败重试{}({}次),放弃",
if budget_exhausted { "预算耗尽" } else { "耗尽" },
max_retries + 1,
);
guard.reset().await;
return;
}
// CR-30-1: 退避复用 retry::backoff_delay(attempt+1)(1s→2s→4s + ±20% jitter),
// 不再使用纯指数 `1u64 << retry_attempt`。attempt 传 retry_attempt+1
// (retry::backoff_delay 是 1-based,第 1 次重试 ~1s,第 2 次 ~2s,第 3 次 ~4s)。
let delay = retry::backoff_delay((retry_attempt + 1) as u32)
.min(retry_deadline.saturating_duration_since(now));
let total_retry = retry_attempt + 1; // 1-based 当前是第几次尝试
tracing::warn!(
conv_id = %conv_id,
attempt = total_retry,
max_attempts = max_retries + 1,
delay_ms = delay.as_millis() as u64,
"[ai] 流前失败(Retryable),{}ms 后重试 ({}/{})",
delay.as_millis(), total_retry, max_retries + 1
);
// F-260616-07(d): emit 重试提示事件,前端在错误气泡内显示「重试 n/m」
// (对齐决策"错误气泡内更新")。CR-30-2: useAiEvents.ts 补 case 处理。
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiStreamRetry {
attempt: total_retry as u32,
max_attempts: (max_retries + 1) as u32,
conversation_id: Some(conv_id.clone()),
});
tokio::time::sleep(delay).await;
continue;
}
}
}
match outcome {
Some(r) => r,
None => {
// 不应到达:InitFailed 非 Fatal/未耗尽即重试,耗尽/Fatal 已 return;
// Complete/Partial 写入 outcome 后 break。防御兜底。
guard.reset().await;
return;
}
}
};
// CR-30-2 / UX-2025-04 / 决策 a1: MidStream 保文路径——partial_text 已接收,
// 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。
// 不走 AiError(非异常中断,已有可用文本),不重试(决策 a1)。
if incomplete {
let usage = df_ai::provider::TokenUsage {
prompt_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens },
completion_tokens: round_usage.completion_tokens,
total_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt + round_usage.completion_tokens } else { round_usage.total_tokens },
};
tokens.add(usage.prompt_tokens, usage.completion_tokens);
// 追加 partial assistant 消息(若无 tool_calls 且有文本)
{
let mut session = session_arc.lock().await;
if session.active_conversation_id.as_deref() != Some(conv_id.as_str()) {
tracing::warn!(
stale_conv = %conv_id,
active_conv = ?session.active_conversation_id,
"[ai] MidStream 保文后对话已切换,丢弃本轮 push(B-260615-11)"
);
return;
}
if !full_text.is_empty() {
let mut msg = ChatMessage::assistant(&full_text);
msg.model = Some(provider_config.default_model.clone());
session.messages.push(msg);
// 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)
let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。");
notice.model = Some(provider_config.default_model.clone());
session.messages.push(notice);
}
}
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await;
// 标题生成后台化(失败有 extract_title 兜底)
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
guard.reset().await;
// generating 复位后再 emit Completed(incomplete=true):前端据此标记消息为不完整
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted {
total_tokens: usage.total_tokens,
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
conversation_id: Some(conv_id.clone()),
incomplete: Some(true),
});
return;
}
// stream 结束立即释放 permit,后续工具执行不受限流(本地操作无 RPM 成本)
drop(_global_permit);
drop(_per_conv_permit);
@@ -289,7 +454,7 @@ pub(crate) async fn run_agentic_loop(
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
guard.reset().await;
// generating 复位后再 emit Completed保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
return;
}
@@ -377,7 +542,7 @@ pub(crate) async fn run_agentic_loop(
guard.reset().await;
// generating 复位后再 emit Completed落库/标题/提炼已在后台,前端立即感知完成
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
}
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
@@ -434,6 +599,7 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: Some(conv_id),
});
}
@@ -485,6 +651,8 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
let llm_concurrency = state.llm_concurrency.clone();
// F-260616-01: loop 入口 load 快照,当前续生成 loop 锁定边界(热改下次发消息生效)
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
// F-260616-07: 流式失败重试次数快照
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
// 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本
// 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开
@@ -494,6 +662,6 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
});
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await;
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await;
});
}

View File

@@ -101,9 +101,11 @@ pub async fn ai_regenerate(
let llm_concurrency = state.llm_concurrency.clone();
// F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
// F-260616-07: 流式失败重试次数快照
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await;
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await;
});
Ok("ok".to_string())
@@ -195,9 +197,11 @@ pub async fn ai_chat_send(
let llm_concurrency = state.llm_concurrency.clone();
// F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
// F-260616-07: 流式失败重试次数快照
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await;
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await;
});
Ok("ok".to_string())
@@ -213,10 +217,20 @@ pub async fn ai_approve(
) -> Result<String, String> {
let mut session = state.ai_session.lock().await;
let approval = session
.pending_approvals
.remove(&tool_call_id)
.ok_or_else(|| format!("未找到挂起审批: {}", tool_call_id))?;
let approval = match session.pending_approvals.remove(&tool_call_id) {
Some(a) => a,
None => {
// F-260616-06: 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错)
if let Some(rec) = state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await
.unwrap_or_default()
{
if rec.status == "executed" || rec.status == "rejected" || rec.status == "failed" {
return Ok(format!("已处理({})", rec.status));
}
}
return Err(format!("未找到挂起的审批: {}", tool_call_id));
}
};
// recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。
let _recovered = approval.recovered;
@@ -424,6 +438,7 @@ pub async fn ai_chat_edit(
let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone();
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move {
run_agentic_loop(
@@ -437,6 +452,7 @@ pub async fn ai_chat_edit(
knowledge_config,
llm_concurrency,
max_iterations,
max_retries,
)
.await;
});
@@ -472,6 +488,7 @@ pub async fn ai_chat_force_send(
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: Some(cid.clone()),
});
}
@@ -499,7 +516,7 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
session.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
let conv_id = session.active_conversation_id.clone();
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, conversation_id: conv_id });
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None, conversation_id: conv_id });
return Ok(());
}
// 流式生成中:置位让 loop 自行收尾
@@ -529,6 +546,7 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: conv_id,
},
);
@@ -601,6 +619,7 @@ pub async fn ai_stop_loop(
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: Some(conv_id),
});
Ok("ok".to_string())
@@ -807,6 +826,7 @@ pub async fn ai_conversation_create(
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: Some(old_conv),
});
}
@@ -1082,3 +1102,20 @@ pub async fn ai_set_agent_max_iterations(
state.agent_max_iterations.store(clamped, Ordering::SeqCst);
Ok(())
}
/// 设置流式对话失败自动重试次数F-260616-07 / 决策 a1运行时调整下次发消息生效
///
/// 只重试流前失败Init Err未输出任何 token流中途失败MidStream Partial保文不重试。
/// 退避复用 retry::backoff_delay(1s→2s→4s+jitter) + is_status_retryable Fatal 分类 +
/// 30s 总预算(详见 agentic.rs 流前重试循环)。
/// 范围 clamp 0-100 表示不重试(直接报错),上限 10 防过度重试烧 token/拖慢体验。
/// 默认 3复用 retry.rs backoff_delay + 错误分类,详见 agentic.rs 重试循环)。
#[tauri::command]
pub async fn ai_set_agent_max_retries(
state: State<'_, AppState>,
value: u32,
) -> Result<(), String> {
let clamped = value.clamp(0, 10) as usize;
state.agent_max_retries.store(clamped, Ordering::SeqCst);
Ok(())
}

View File

@@ -100,7 +100,18 @@ pub enum AiChatEvent {
/// 审批结果
AiApprovalResult { id: String, approved: bool, conversation_id: Option<String> },
/// AI 响应完成
AiCompleted { total_tokens: u32, prompt_tokens: u32, completion_tokens: u32, conversation_id: Option<String> },
///
/// `incomplete`(可选,默认 None=false):UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1——
/// 流中途失败(MidStream)保文路径置 Some(true),标记此回复不完整。前端可据此差异化
/// 展示(如附系统提示「⚠ 响应因网络中断不完整」)。正常完成/用户停止均 None(完整)。
AiCompleted {
total_tokens: u32,
prompt_tokens: u32,
completion_tokens: u32,
/// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复
incomplete: Option<bool>,
conversation_id: Option<String>,
},
/// 错误
///
/// `error_type` 为错误源分类(B-260615-42),`None` 表示未分类(向后兼容旧 emit 点)。
@@ -120,6 +131,11 @@ pub enum AiChatEvent {
/// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮
/// - 点停止 → ai_stop_loop → 走完成流程save + AiCompleted + generating=false
AiMaxRoundsReached { conversation_id: Option<String> },
/// 流式调用自动重试提示F-260616-07流前失败重试中
///
/// 每次重试前 emit前端可在错误气泡内显示「重试 n/m」。
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1)max_attempts = max_retries + 1。
AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option<String> },
}
// ============================================================

View File

@@ -92,12 +92,42 @@ pub(crate) fn fmt_diag(provider_name: &str, kind: DiagKind, status_or_class: &st
format!("[{}] {}({}): {}", provider_name, ctx, status_or_class, raw)
}
/// 流式接收 LLM 响应,返回 (完整文本, 工具调用草稿)
/// 流式接收结果。显式区分三类出口,避免调用方对 None 的歧义推断
/// (UX-2025-04 / CR-30-1 / CR-30-2 / 决策 F-260616-07 a1)。
pub(crate) enum StreamResult {
/// 正常完成(流尽 + finished 信号或用户主动停止。incomplete=false。
/// 调用方按正常流程入库 + emit AiCompleted。
Complete {
text: String,
tool_calls: HashMap<u32, ToolCallDraft>,
usage: df_ai::provider::TokenUsage,
},
/// 流中途断MidStream chunk Err / idle timeout / provider stream-error / 有 partial_text
/// 但流尽未收到 finished。已 emit 过 AiTextDelta前端 currentText 已累积),
/// **不 emit AiError**——调用方保文入库 + emit AiCompleted(incomplete=true) + 系统提示。
/// 调用方**不重试**(决策 a1
Partial {
text: String,
tool_calls: HashMap<u32, ToolCallDraft>,
usage: df_ai::provider::TokenUsage,
},
/// Init 失败provider.stream() Err建连/鉴权/HTTP non-2xx。已 emit AiError。
/// `retryable`: 据状态码分类(retry::is_status_retryable 镜像)true=5xx/429/timeout/connect
/// 可重试false=4xx(非429)/鉴权/参数错 Fatal 立即放弃。调用方据此决定是否重试。
InitFailed { retryable: bool },
}
/// 流式接收 LLM 响应。
///
/// 三类异常处理:
/// - idle timeout120s 无 chunk判定连接静默断emit AiError 返回 None
/// - 流尽但从未收到 finished 信号判定异常中断emit AiError 返回 None丢弃残缺不当完整入库
/// - 用户停止stop_flagbreak 返回 Some(已收文本),由调用方入库展示后退出
/// 三类异常处理(返回 StreamResult 显式区分出口)
/// - Init 失败provider.stream() Erremit AiError + 返回 InitFailed{retryable}。
/// retryable 据状态码分类(retry::is_status_retryable 镜像)5xx/429/timeout/connect=true
/// 4xx(非429)/鉴权/参数错=false Fatal。调用方走流前重试(仅 retryable=true,≤max_retries 次,
/// 复用 retry::backoff_delay 退避 + 30s 总预算)。
/// - MidStream 失败(流已建立后断/timeout/err 事件/有 partial_text 但流尽未 finished
/// **不 emit AiError**,返回 Partial{...} 保文。调用方不重试,入库 + AiCompleted(incomplete)
/// + 系统提示网络中断。空文本仍 emit AiError + InitFailed{retryable=true}(无文可保,交重试)。
/// - 用户停止stop_flag返回 Completeincomplete 语义非异常中断)。
///
/// 心跳与停止响应B-260615-02 / B-260615-04单 `tokio::select!` 三分支
/// - `stream.next()`:正常 chunk 处理
@@ -111,7 +141,7 @@ pub(crate) async fn stream_llm(
stop_flag: &AtomicBool,
notify: &tokio::sync::Notify,
conv_id: &str,
) -> Option<(String, HashMap<u32, ToolCallDraft>, df_ai::provider::TokenUsage)> {
) -> StreamResult {
/// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
/// 心跳间隔静默期向前端报「LLM 仍在跑」reset watchdog
@@ -157,13 +187,28 @@ pub(crate) async fn stream_llm(
chunk_result = tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()) => {
match chunk_result {
Err(_elapsed) => {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应超时120 秒无数据,连接可能已断开)".to_string(),
// 120s 无 chunk = idle timeout,归 Timeout
error_type: Some(ErrorType::Timeout),
conversation_id: Some(conv_id.to_string()),
});
return None;
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1:
// idle timeout 属 MidStream 类失败——已有文本则保文(Partial),不重试。
// 空文本无文可保,emit AiError + InitFailed{retryable=true}(timeout 瞬态可重试)。
if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应超时120 秒无数据,连接可能已断开)".to_string(),
error_type: Some(ErrorType::Timeout),
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed { retryable: true };
}
warn!(
provider = %provider.name(),
conv_id = %conv_id,
text_len = full_text.len(),
"[ai] 流中途 idle timeout,保文不重试(incomplete)",
);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(),
};
}
Ok(None) => break, // 流正常结束
Ok(Some(chunk_result)) => match chunk_result {
@@ -187,27 +232,35 @@ pub(crate) async fn stream_llm(
final_usage = Some(u.clone());
}
// provider 流式错误事件Anthropic SSE `type=="error"` 等):
// 不走 finished 完成路径,发 AiError + 丢弃残缺,与 Err 分支对齐OpenAI 路径一致性)。
// UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial),
// 不重试。空文本无文可保,emit AiError + InitFailed(保守 retryable=true,
// err_msg 不可靠解析状态码,默认按可重试交调用方决定)。
if let Some(err_msg) = &chunk.error {
warn!(
provider = %provider.name(),
conv_id = %conv_id,
error = %err_msg,
text_len = full_text.len(),
"[ai] provider 流式错误事件",
);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: fmt_diag(
provider.name(),
DiagKind::MidStream,
"stream-error",
err_msg,
),
// provider SSE error 事件体可为 401/overloaded 等多种,
// 从文本分类不可靠,归 Unknown(前端可据 error 文本二次判断)
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.to_string()),
});
return None;
if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: fmt_diag(
provider.name(),
DiagKind::MidStream,
"stream-error",
err_msg,
),
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed { retryable: true };
}
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(),
};
}
if chunk.finished {
finished_received = true;
@@ -222,22 +275,33 @@ pub(crate) async fn stream_llm(
status = %status_or_class,
conv_id = %conv_id,
error = %raw,
text_len = full_text.len(),
"[ai] 流式接收中途错误",
);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: fmt_diag(
provider.name(),
DiagKind::MidStream,
&status_or_class,
&raw,
),
// 流已建立后 next() 返 Err = SSE 传输断/解析错,归 Network
// (extract_error_diag 已抠 status_or_class,但混合源难统一归 auth/timeout,
// 主流为传输断,前端可据 error 文本二次判断 HTTP 4xx 等)
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.to_string()),
});
return None;
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: MidStream chunk Err——
// 已有文本则保文(Partial),不重试。空文本无文可保,emit AiError +
// InitFailed{retryable=classify_status_or_class(status_or_class)}
// (4xx Fatal 立即放弃,5xx/429/timeout/connect 可重试)。
if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: fmt_diag(
provider.name(),
DiagKind::MidStream,
&status_or_class,
&raw,
),
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
};
}
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(),
};
}
}
}
@@ -262,26 +326,47 @@ pub(crate) async fn stream_llm(
}
}
// 用户停止:已生成文本(可能残缺)交调用方入库展示
// 用户停止:已生成文本(可能残缺)交调用方入库展示。Complete(incomplete=false)——
// 用户主动停止语义非异常中断,正常入库 + AiCompleted(决策 a1: 不重试路径)。
if stopped {
return Some((full_text, tool_calls_acc, final_usage.unwrap_or_default()));
return StreamResult::Complete {
text: full_text,
tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(),
};
}
// 断连检测:流尽但从未收到 finished 信号 = 异常中断,丢弃残缺不当完整入库
// B-260615-05:一律判异常(不区分内容空否)——空内容无 finished 同属异常:
// 上游 agentic.rs !has_tool_calls 早 break 会按正常路径 emit AiCompleted,
// 用户看空气泡无错误提示,转 P0 卡死入口。此处统一 emit AiError 拦截。
// 断连检测:流尽但从未收到 finished 信号 = 异常中断。
// B-260615-05:空内容无 finished emit AiError + InitFailed{retryable=true}(无文可保,交重试)。
// UX-2025-04:有 partial_text 则保文(Partial),不 emit AiError,不重试。
if !finished_received {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
// 流尽但未收到 finished = 连接异常中断,归 Network
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.to_string()),
});
return None;
if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed { retryable: true };
}
warn!(
provider = %provider.name(),
conv_id = %conv_id,
text_len = full_text.len(),
"[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)",
);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(),
};
}
Some((full_text, tool_calls_acc, final_usage.unwrap_or_default()))
// 正常完成:流尽 + finished 信号到位,Complete(incomplete=false)
StreamResult::Complete {
text: full_text,
tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(),
}
}
Err(e) => {
// 诊断:连接/鉴权/HTTP 错误,补 provider 标识 + HTTP 状态码/分类 + 原始文本,
@@ -301,18 +386,53 @@ pub(crate) async fn stream_llm(
&status_or_class,
&raw,
),
// 建连失败混合源:401(auth)/404(provider_config)/connect(network)/timeout/unknown,
// extract_error_diag 已抠 status_or_class 供前端 error 文本展示,但运行时文本分类
// 归一 error_type 不可靠(同 raw 跨多类型),按任务约定难精确分类的旧点填 None,
// 前端可据 error 文本中的 "HTTP 401" 等自行二次判断。
error_type: None,
conversation_id: Some(conv_id.to_string()),
});
None
// CR-30-1: Init 失败分类——retryable 据 status_or_class 镜像 retry::is_status_retryable:
// 5xx/429/timeout/connect=true 可重试;4xx(非429)/鉴权/参数错=false Fatal 立即放弃。
StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
}
}
}
}
/// 据 extract_error_diag 产出的 status_or_class 字符串镜像 retry::is_status_retryable 分类。
///
/// status_or_class 取值: "HTTP 5xx"/"HTTP 4xx"(白名单码) / "timeout" / "connect" / "unknown"。
/// - HTTP 429 / 5xx → retryable=true(复用 retry::is_status_retryable 逻辑)
/// - HTTP 4xx(非429) → retryable=false Fatal(401/403/404/422 等)
/// - timeout / connect → retryable=true(瞬态,可重试)
/// - unknown / 其他 → retryable=true(保守可重试,避免误判 Fatal 错杀)
///
/// CR-30-1: 抽公共分类函数(决策 F-260616-07 a1 "复用 retry.rs 错误分类逻辑或镜像"),
/// 避免 stream_recv 与 agentic 各自重写状态码解析。
fn classify_status_or_class(status_or_class: &str) -> bool {
use df_ai::retry::is_status_retryable;
// 抠 status_or_class 内三位数字状态码("HTTP 401" → 401)
let chars: Vec<char> = status_or_class.chars().collect();
let mut i = 0;
let n = chars.len();
while i + 3 <= n {
if chars[i].is_ascii_digit()
&& chars[i + 1].is_ascii_digit()
&& chars[i + 2].is_ascii_digit()
{
let code: String = chars[i..i + 3].iter().collect();
if let Ok(status) = code.parse::<u16>() {
return is_status_retryable(status);
}
i += 3;
continue;
}
i += 1;
}
// 无三位数字码:按文本分类(timeout/connect 可重试,unknown/其他保守可重试)
let lower = status_or_class.to_lowercase();
lower.contains("timeout") || lower.contains("connect") || true // unknown 保守可重试
}
// ============================================================
// 单测:诊断提取/格式化(纯函数,不发 HTTP、不依赖 app_handle
// ============================================================
@@ -320,6 +440,43 @@ pub(crate) async fn stream_llm(
mod tests {
use super::*;
// ---- classify_status_or_classCR-30-1 retryable 分类(镜像 retry::is_status_retryable) ----
/// 5xx → retryable=true
#[test]
fn classify_5xx_retryable() {
assert!(classify_status_or_class("HTTP 500"));
assert!(classify_status_or_class("HTTP 503"));
}
/// 429 → retryable=true(限流可重试)
#[test]
fn classify_429_retryable() {
assert!(classify_status_or_class("HTTP 429"));
}
/// 4xx(非429) → retryable=false Fatal(鉴权/参数错立即放弃)
#[test]
fn classify_4xx_fatal() {
assert!(!classify_status_or_class("HTTP 401"));
assert!(!classify_status_or_class("HTTP 403"));
assert!(!classify_status_or_class("HTTP 404"));
assert!(!classify_status_or_class("HTTP 422"));
}
/// timeout / connect → retryable=true(瞬态)
#[test]
fn classify_transient_retryable() {
assert!(classify_status_or_class("timeout"));
assert!(classify_status_or_class("connect"));
}
/// unknown → 保守 retryable=true(不误判 Fatal 错杀)
#[test]
fn classify_unknown_retryable() {
assert!(classify_status_or_class("unknown"));
}
// ---- extract_error_diag业务层 bailprovider 串已含状态码)----
/// provider 在 non-2xx bail 的典型串:抠出 401鉴权失败/Key 错)

View File

@@ -205,51 +205,57 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
// ── 只读 (Low) ──
registry.register(
"list_projects", "列出所有项目,返回项目列表ID、名称、状态、描述。最多返回 50 条",
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
"list_projects", "列出所有项目,支持 offset/limit 分页。返回 items项目列表、total总量、has_more是否有更多页。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::ProjectRepo::new(&db);
let mut items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
let items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
let total = items.len();
let truncated = total > MAX_LIST_RESULTS;
items.truncate(MAX_LIST_RESULTS);
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_items.len()) < total;
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
})
})},
);
registry.register(
"list_tasks", "列出任务,可按 project_id 筛选。最多返回 50 条",
df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
"list_tasks", "列出任务,可按 project_id 筛选,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("project_id", "string", false), ("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::TaskRepo::new(&db);
let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
repo.query("project_id", pid).await?
} else {
repo.list_all().await?
};
let total = tasks.len();
let truncated = total > MAX_LIST_RESULTS;
tasks.truncate(MAX_LIST_RESULTS);
Ok(serde_json::json!({ "items": tasks, "truncated": truncated }))
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
let page_items: Vec<_> = tasks.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_items.len()) < total;
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
})
})},
);
registry.register(
"list_ideas", "列出所有灵感。最多返回 50 条",
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
"list_ideas", "列出所有灵感,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::IdeaRepo::new(&db);
let mut items = repo.list_all().await?;
let items = repo.list_all().await?;
let total = items.len();
let truncated = total > MAX_LIST_RESULTS;
items.truncate(MAX_LIST_RESULTS);
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_items.len()) < total;
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
})
})},
);
@@ -456,16 +462,19 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
})},
);
registry.register(
"list_trash", "列出回收站已删除项目(最多返回 50 条)",
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
"list_trash", "列出回收站已删除项目,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::ProjectRepo::new(&db);
let mut items = repo.list_deleted().await?;
let truncated = items.len() > MAX_LIST_RESULTS;
items.truncate(MAX_LIST_RESULTS);
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
let items = repo.list_deleted().await?;
let total = items.len();
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_items.len()) < total;
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
})
})},
);
@@ -568,23 +577,27 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
}
anyhow::bail!("读取文件失败: {}", e);
}
// search 模式: 按行枚举收集含 search 子串的行,限 50 条
// search 模式: 按行枚举收集含 search 子串的行,支持 offset/limit 分页
if let Some(search) = args["search"].as_str() {
let search_max = 50usize;
let mut matches_vec: Vec<serde_json::Value> = Vec::with_capacity(search_max);
const SEARCH_MAX: usize = 50;
let search_offset = args["offset"].as_u64().unwrap_or(0) as usize;
let search_limit = args["limit"].as_u64().unwrap_or(SEARCH_MAX as u64).min(SEARCH_MAX as u64) as usize;
// 先收集全部匹配行用于 total 计数,再做 skip/take 分页
let mut all_matches: Vec<serde_json::Value> = Vec::new();
for (idx, line) in content.lines().enumerate() {
if line.contains(search) {
matches_vec.push(serde_json::json!({ "line": idx + 1, "content": line }));
if matches_vec.len() >= search_max { break; }
all_matches.push(serde_json::json!({ "line": idx + 1, "content": line }));
}
}
let total = content.lines().filter(|l| l.contains(search)).count();
let total = all_matches.len();
let page_matches: Vec<_> = all_matches.into_iter().skip(search_offset).take(search_limit).collect();
let has_more = (search_offset + page_matches.len()) < total;
return Ok(serde_json::json!({
"path": path, "size": metadata.len(),
"search": search,
"matches": matches_vec,
"matches": page_matches,
"total": total,
"has_more": total > search_max,
"has_more": has_more,
}));
}
// 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
@@ -1043,8 +1056,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
// ── 文件搜索 (Low risk) ──
registry.register(
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false)]),
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
RiskLevel::Low,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
@@ -1055,14 +1068,49 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
let recursive = args["recursive"].as_bool().unwrap_or(false);
let pattern_lower = pattern.to_lowercase();
const MAX_RESULTS: usize = 50;
let mut results = Vec::new();
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize;
// 先收集全部结果用于 total 计数,再做 skip/take 分页
let mut all_results = Vec::new();
let mut total = 0u64;
search_files_recursive(path, &pattern_lower, recursive, 0, 5, MAX_RESULTS, &mut results, &mut total).await?;
let has_more = total as usize > MAX_RESULTS;
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": results, "total": total, "has_more": has_more }))
// 用较大上限收集全量(分页由内存 skip/take 控制)
search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?;
let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_results.len()) < total as usize;
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
})),
);
// ── 探总量工具 (Low risk, F-260616-08) ──
registry.register(
"get_project_count", "获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }",
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::ProjectRepo::new(&db);
let items = repo.list_active().await?;
Ok(serde_json::json!({ "total": items.len() }))
})
})},
);
registry.register(
"get_task_count", "获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }",
df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::TaskRepo::new(&db);
let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
repo.query("project_id", pid).await?
} else {
repo.list_all().await?
};
Ok(serde_json::json!({ "total": tasks.len() }))
})
})},
);
registry
}

View File

@@ -107,7 +107,7 @@ pub async fn knowledge_search(
state: State<'_, AppState>,
input: KnowledgeSearchInput,
) -> Result<Vec<KnowledgeRecord>, String> {
let limit = input.limit.unwrap_or(3).min(3);
let limit = input.limit.unwrap_or(3).min(20);
state
.knowledge
.search(&input.query, input.kind.as_deref(), limit)

View File

@@ -35,17 +35,20 @@ struct WorkflowEventPayload {
/// 映射表(失败退一步):
/// - testing → in_review
/// - in_review → in_progress
/// - in_progress → todo
/// - in_progress → None(状态机禁止→todo,留 in_progress 等人介入)
/// - 其他(done/blocked/cancelled/todo 等) → None(无退回映射,跳过回调)
///
/// 设计选择:in_progress → todo(而非 None)。理由:in_progress 是执行态,失败退回 todo
/// 符合"执行未达预期 → 回起点重排"的直觉语义;若选 None 会丢失"执行失败"信号。
/// 设计选择:in_progress → None(而非 todo)。理由:状态机禁止 in_progress→todo
/// (task_state_machine.rs backward_to_todo_rejected),失败退回 todo 会被
/// advance_task_atomic 的 InvalidState 拦截,任务原地保留 in_progress 且无信号;
/// 改为 None 则回调跳过推进,留 in_progress 等人介入。
/// 起点状态 todo 无可退态 → None。
fn regression_target(target: &str) -> Option<&str> {
match target {
"testing" => Some("in_review"),
"in_review" => Some("in_progress"),
"in_progress" => Some("todo"),
// in_progress 失败不自动退回(状态机不允许→todo),留 in_progress 等人介入
"in_progress" => None,
_ => None,
}
}

View File

@@ -53,6 +53,7 @@ pub fn run() {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: conv_id,
},
);
@@ -131,6 +132,7 @@ pub fn run() {
commands::ai::ai_list_skills,
commands::ai::ai_set_concurrency_config,
commands::ai::ai_set_agent_max_iterations,
commands::ai::ai_set_agent_max_retries,
// 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
commands::ai::audit::list_tool_executions,
// 知识库

View File

@@ -180,6 +180,11 @@ pub struct AppState {
/// loop 入口 load 快照透传形参;当前 loop 锁定边界,热改下次发消息生效)。
/// 默认 10与 agentic.rs::DEFAULT_MAX_AGENT_ITERATIONS 对齐。
pub agent_max_iterations: Arc<AtomicUsize>,
// ── 流式对话失败自动重试 ──
/// 流式对话失败自动重试次数F-260616-07 / 决策 a1只重试流前失败 Init Err——未输出
/// 任何 token流中途失败 MidStream Partial 保文不重试。退避复用 retry::backoff_delay +
/// is_status_retryable Fatal 分类 + 30s 总预算,详见 agentic.rs 重试循环。默认 3
pub agent_max_retries: Arc<AtomicUsize>,
// ── 工作流执行状态 ──
/// 工作流执行 → 节点状态机注册表
///
@@ -217,6 +222,9 @@ impl AppState {
agent_max_iterations: Arc::new(AtomicUsize::new(
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
)),
agent_max_retries: Arc::new(AtomicUsize::new(
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
)),
workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
db,
event_bus: EventBus::new(),