添加qiming-rcoder模块
This commit is contained in:
355
qiming-rcoder/crates/agent_runner/src/agent_runtime.rs
Normal file
355
qiming-rcoder/crates/agent_runner/src/agent_runtime.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
//! Agent Runtime 模块
|
||||
//!
|
||||
//! 简化版的 Agent Worker 管理器,利用 SACP 的 Send trait 支持。
|
||||
//!
|
||||
//! ## 新架构设计
|
||||
//!
|
||||
//! - 移除独立 OS 线程,使用 `tokio::spawn`
|
||||
//! - 简化 sender 管理,移除 ArcSwap
|
||||
//! - 保留自动重启功能
|
||||
//! - 保留心跳检测(僵尸检测)
|
||||
//! - 简化状态机(使用原子操作)
|
||||
//!
|
||||
//! ## 与旧架构对比
|
||||
//!
|
||||
//! | 组件 | 旧设计 | 新设计 |
|
||||
//! |------|--------|--------|
|
||||
//! | 运行环境 | 独立 OS 线程 + 独立运行时 | 主运行时 + tokio::spawn |
|
||||
//! | Sender 管理 | ArcSwap<Option<Sender>> | mpsc::Sender (固定) |
|
||||
//! | Worker 生命周期 | 手动管理线程 | JoinHandle |
|
||||
//! | Ready 信号 | oneshot::channel | JoinHandle 完成 |
|
||||
//! | 状态机 | watch::Sender + Mutex | Arc<AtomicState> |
|
||||
//! | 重启 | 替换 sender | abort + spawn |
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU8, AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::proxy_agent::AgentRequest;
|
||||
|
||||
/// Worker 状态
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WorkerState {
|
||||
/// 启动中
|
||||
Starting = 0,
|
||||
/// 运行中
|
||||
Running = 1,
|
||||
/// 停止中
|
||||
Stopping = 2,
|
||||
/// 已停止
|
||||
Stopped = 3,
|
||||
}
|
||||
|
||||
/// 原子状态包装器 (无需 Mutex)
|
||||
pub struct AtomicState(AtomicU8);
|
||||
|
||||
impl AtomicState {
|
||||
pub fn new(state: WorkerState) -> Self {
|
||||
Self(AtomicU8::new(state as u8))
|
||||
}
|
||||
|
||||
pub fn get(&self) -> WorkerState {
|
||||
match self.0.load(Ordering::Acquire) {
|
||||
0 => WorkerState::Starting,
|
||||
1 => WorkerState::Running,
|
||||
2 => WorkerState::Stopping,
|
||||
3 => WorkerState::Stopped,
|
||||
invalid => {
|
||||
tracing::error!(
|
||||
"[AtomicState] Invalid state value: {}, falling back to Stopped",
|
||||
invalid
|
||||
);
|
||||
WorkerState::Stopped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&self, state: WorkerState) {
|
||||
self.0.store(state as u8, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// 心跳包
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Heartbeat {
|
||||
/// 心跳时间戳
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Worker 就绪信号
|
||||
///
|
||||
/// Worker 在初始化完成后发送此信号
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkerReady {
|
||||
/// 就绪时间戳
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// 并发控制配置
|
||||
///
|
||||
/// 工作线程池大小 - 决定可以并发处理的 Agent 会话数量
|
||||
/// 🔥 已改为运行时可配置的全局变量,使用 get_concurrency_limit() 获取
|
||||
|
||||
/// 全局并发限制(运行时可配置)
|
||||
pub static WORKER_THREAD_POOL_SIZE: AtomicUsize = AtomicUsize::new(10);
|
||||
|
||||
/// 初始化并发限制(在应用启动时调用)
|
||||
pub fn init_concurrency_limit(limit: usize) {
|
||||
WORKER_THREAD_POOL_SIZE.store(limit, Ordering::Release);
|
||||
info!("🔧 Concurrency limit initialized: {}", limit);
|
||||
}
|
||||
|
||||
/// 获取当前并发限制
|
||||
pub fn get_concurrency_limit() -> usize {
|
||||
WORKER_THREAD_POOL_SIZE.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Agent 运行时
|
||||
///
|
||||
/// 替代 AgentWorkerManager,使用简化的架构:
|
||||
/// - 直接在主运行时中运行 (SACP 支持 Send)
|
||||
/// - 使用原子操作管理状态
|
||||
/// - 使用 JoinHandle 管理生命周期
|
||||
pub struct AgentRuntime {
|
||||
/// 请求发送端 (固定不变)
|
||||
request_tx: mpsc::Sender<AgentRequest>,
|
||||
|
||||
/// 当前 Worker 的 JoinHandle
|
||||
worker_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
|
||||
/// 当前状态
|
||||
state: Arc<AtomicState>,
|
||||
|
||||
/// 🔥 P1 修复: 最后心跳时间戳(毫秒,Unix timestamp)
|
||||
/// 使用 AtomicI64 替代 Mutex,避免频繁的锁竞争
|
||||
/// - 0 表示从未收到心跳
|
||||
/// - 正数表示最后一次心跳的 timestamp_millis()
|
||||
last_heartbeat_ts: Arc<AtomicI64>,
|
||||
|
||||
/// 活跃请求追踪: request_id -> 开始时间
|
||||
active_requests: Arc<Mutex<HashMap<String, chrono::DateTime<chrono::Utc>>>>,
|
||||
|
||||
/// 心跳超时阈值
|
||||
heartbeat_timeout: Duration,
|
||||
|
||||
/// 首次启动宽限期
|
||||
initial_grace_period: Duration,
|
||||
}
|
||||
|
||||
impl AgentRuntime {
|
||||
/// 创建新的 AgentRuntime
|
||||
///
|
||||
/// 返回 (runtime, request_receiver)
|
||||
pub fn new(request_buffer: usize) -> (Self, mpsc::Receiver<AgentRequest>) {
|
||||
let (request_tx, request_rx) = mpsc::channel(request_buffer);
|
||||
|
||||
let runtime = Self {
|
||||
request_tx,
|
||||
worker_handle: Arc::new(Mutex::new(None)),
|
||||
state: Arc::new(AtomicState::new(WorkerState::Starting)),
|
||||
last_heartbeat_ts: Arc::new(AtomicI64::new(0)),
|
||||
active_requests: Arc::new(Mutex::new(HashMap::new())),
|
||||
heartbeat_timeout: Duration::from_secs(15),
|
||||
initial_grace_period: Duration::from_secs(30),
|
||||
};
|
||||
|
||||
(runtime, request_rx)
|
||||
}
|
||||
|
||||
/// 启动 Worker (在主运行时中)
|
||||
///
|
||||
/// SACP 支持 Send,直接在主运行时中运行,无需独立线程
|
||||
pub async fn start(&self, receiver: mpsc::Receiver<AgentRequest>) {
|
||||
let state = self.state.clone();
|
||||
let last_heartbeat_ts = self.last_heartbeat_ts.clone();
|
||||
let active_requests = self.active_requests.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
// SACP 支持 Send,直接在主运行时中运行
|
||||
if let Err(e) = crate::proxy_agent::agent_worker_with_heartbeat(
|
||||
receiver,
|
||||
state.clone(),
|
||||
last_heartbeat_ts.clone(),
|
||||
active_requests.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Agent worker failed: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
*self.worker_handle.lock().await = Some(handle);
|
||||
self.state.set(WorkerState::Running);
|
||||
info!("AgentRuntime: worker started");
|
||||
}
|
||||
|
||||
/// 重启 Worker
|
||||
pub async fn restart(&self, new_receiver: mpsc::Receiver<AgentRequest>) {
|
||||
warn!("AgentRuntime: preparing to restart worker...");
|
||||
|
||||
// 1. 停止旧 worker
|
||||
if let Some(handle) = self.worker_handle.lock().await.take() {
|
||||
handle.abort();
|
||||
info!("AgentRuntime: previous worker terminated");
|
||||
}
|
||||
|
||||
// 2. 重置状态
|
||||
self.state.set(WorkerState::Starting);
|
||||
self.last_heartbeat_ts.store(0, Ordering::Release);
|
||||
*self.active_requests.lock().await = HashMap::new();
|
||||
|
||||
// 3. 启动新 worker
|
||||
self.start(new_receiver).await;
|
||||
info!("AgentRuntime: worker restart completed");
|
||||
}
|
||||
|
||||
/// 发送请求
|
||||
pub async fn send(&self, request: AgentRequest) -> anyhow::Result<()> {
|
||||
self.request_tx
|
||||
.send(request)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Worker is closed"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 健康检查
|
||||
pub async fn check_health(&self) -> bool {
|
||||
let state = self.state.get();
|
||||
|
||||
// 检查状态
|
||||
if state == WorkerState::Stopped {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 🔥 P1 修复: 使用原子操作检查心跳(无锁)
|
||||
let last_ts = self.last_heartbeat_ts.load(Ordering::Acquire);
|
||||
if last_ts > 0 {
|
||||
let elapsed_ms = Utc::now().timestamp_millis() - last_ts;
|
||||
elapsed_ms < self.heartbeat_timeout.as_millis() as i64
|
||||
} else {
|
||||
// 首次启动宽限期
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前状态
|
||||
pub fn state(&self) -> WorkerState {
|
||||
self.state.get()
|
||||
}
|
||||
|
||||
/// 🔥 P1 修复: 检查心跳是否超时(无锁)
|
||||
///
|
||||
/// ## 返回值
|
||||
///
|
||||
/// - `true`: 心跳超时(超过 15 秒未收到心跳)
|
||||
/// - `false`: 心跳正常或在宽限期内
|
||||
pub fn check_heartbeat_timeout(&self) -> bool {
|
||||
let last_ts = self.last_heartbeat_ts.load(Ordering::Acquire);
|
||||
|
||||
if last_ts > 0 {
|
||||
// 有心跳记录,检查是否超过 15 秒
|
||||
let elapsed_ms = Utc::now().timestamp_millis() - last_ts;
|
||||
elapsed_ms > 15_000
|
||||
} else {
|
||||
// 从未收到心跳,使用首次启动宽限期
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 P1 修复: 获取最后心跳时间(无锁)
|
||||
///
|
||||
/// ## 返回值
|
||||
///
|
||||
/// - `Some(timestamp)`: 最后心跳时间
|
||||
/// - `None`: 从未收到心跳
|
||||
pub fn last_heartbeat_time(&self) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
let last_ts = self.last_heartbeat_ts.load(Ordering::Acquire);
|
||||
if last_ts > 0 {
|
||||
// 将毫秒时间戳转换为 DateTime
|
||||
use chrono::TimeZone;
|
||||
// timestamp_millis_opt 返回 LocalResult,使用 single() 转换为 Option
|
||||
match chrono::Utc.timestamp_millis_opt(last_ts).single() {
|
||||
Some(dt) => Some(dt),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"[WorkerInfo] Invalid timestamp: {}, using current time",
|
||||
last_ts
|
||||
);
|
||||
Some(chrono::Utc::now())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取活跃请求句柄
|
||||
pub fn active_requests(&self) -> Arc<Mutex<HashMap<String, chrono::DateTime<chrono::Utc>>>> {
|
||||
self.active_requests.clone()
|
||||
}
|
||||
|
||||
/// 检查请求通道是否已关闭
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.request_tx.is_closed()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_atomic_state() {
|
||||
let state = AtomicState::new(WorkerState::Starting);
|
||||
assert_eq!(state.get(), WorkerState::Starting);
|
||||
|
||||
state.set(WorkerState::Running);
|
||||
assert_eq!(state.get(), WorkerState::Running);
|
||||
|
||||
state.set(WorkerState::Stopped);
|
||||
assert_eq!(state.get(), WorkerState::Stopped);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_runtime_creation() {
|
||||
let (runtime, _rx) = AgentRuntime::new(100);
|
||||
assert_eq!(runtime.state(), WorkerState::Starting);
|
||||
assert!(!runtime.is_closed());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_heartbeat_timeout_detection() {
|
||||
let (runtime, _rx) = AgentRuntime::new(100);
|
||||
|
||||
// 初始状态:从未收到心跳
|
||||
assert!(!runtime.check_heartbeat_timeout());
|
||||
|
||||
// 模拟心跳超时(设置20秒前的时间戳)
|
||||
let timestamp_20s_ago = Utc::now().timestamp_millis() - (20 * 1000);
|
||||
runtime
|
||||
.last_heartbeat_ts
|
||||
.store(timestamp_20s_ago, std::sync::atomic::Ordering::Release);
|
||||
|
||||
// 心跳超过 15 秒,应检测到超时
|
||||
assert!(runtime.check_heartbeat_timeout());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check() {
|
||||
let (runtime, _rx) = AgentRuntime::new(100);
|
||||
|
||||
// 初始状态应该是健康的
|
||||
assert!(runtime.check_health().await);
|
||||
|
||||
// 设置状态为 Stopped
|
||||
runtime.state.set(WorkerState::Stopped);
|
||||
assert!(!runtime.check_health().await);
|
||||
}
|
||||
}
|
||||
317
qiming-rcoder/crates/agent_runner/src/api_key_manager.rs
Normal file
317
qiming-rcoder/crates/agent_runner/src/api_key_manager.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
//! API 密钥管理器
|
||||
//!
|
||||
//! 在内存中存储 API 密钥配置,支持通过服务名称快速查询。
|
||||
//! 密钥配置通过 gRPC 从 rcoder 主服务传递到 agent_runner。
|
||||
//!
|
||||
//! ## 使用示例
|
||||
//!
|
||||
//! ```rust
|
||||
//! use agent_runner::api_key_manager::ApiKeyManager;
|
||||
//! use shared_types::ModelProviderConfig;
|
||||
//!
|
||||
//! let manager = ApiKeyManager::new();
|
||||
//!
|
||||
//! // 存储 API 配置
|
||||
//! let config = ModelProviderConfig {
|
||||
//! id: "anthropic-prov".to_string(),
|
||||
//! name: "anthropic".to_string(),
|
||||
//! api_key: "sk-ant-xxx".to_string(),
|
||||
//! base_url: "https://api.anthropic.com".to_string(),
|
||||
//! requires_openai_auth: false,
|
||||
//! default_model: "claude-3-5-sonnet-20241022".to_string(),
|
||||
//! api_protocol: Some("anthropic".to_string()),
|
||||
//! };
|
||||
//! manager.store_config("anthropic", config);
|
||||
//!
|
||||
//! // 查询 API 密钥
|
||||
//! if let Some(key) = manager.get_api_key("anthropic") {
|
||||
//! println!("API Key: {}", key);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use dashmap::DashMap;
|
||||
use shared_types::ModelProviderConfig;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// API 密钥管理器
|
||||
///
|
||||
/// 使用 DashMap 实现并发安全的 HashMap,支持多线程安全访问。
|
||||
/// 密钥配置存储在内存中,进程重启后清空。
|
||||
///
|
||||
/// 可以作为独立存储使用(通过 `new()`),也可以包装共享的 DashMap(通过 `from_shared()`)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiKeyManager {
|
||||
/// 服务名称 -> 密钥配置
|
||||
///
|
||||
/// 键为服务标识符(如 UUID),值为完整的 ModelProviderConfig。
|
||||
/// 包装共享的 DashMap 引用(不拥有数据)或拥有独立 DashMap。
|
||||
shared: Arc<DashMap<String, ModelProviderConfig>>,
|
||||
}
|
||||
|
||||
impl Default for ApiKeyManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiKeyManager {
|
||||
/// 创建新的 API 密钥管理器(拥有独立的 DashMap)
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```rust
|
||||
/// use agent_runner::api_key_manager::ApiKeyManager;
|
||||
///
|
||||
/// let manager = ApiKeyManager::new();
|
||||
/// ```
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
shared: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从共享 DashMap 创建包装器
|
||||
///
|
||||
/// 将 ApiKeyManager 改为包装共享的 DashMap,而不是拥有独立的数据。
|
||||
/// 这样多个组件可以共享同一个配置存储。
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `shared` - 共享的 DashMap<String, ModelProviderConfig>
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```rust
|
||||
/// use agent_runner::api_key_manager::ApiKeyManager;
|
||||
/// use dashmap::DashMap;
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// let shared_map = Arc::new(DashMap::new());
|
||||
/// let manager = ApiKeyManager::from_shared(shared_map);
|
||||
/// ```
|
||||
pub fn from_shared(shared: Arc<DashMap<String, ModelProviderConfig>>) -> Self {
|
||||
Self { shared }
|
||||
}
|
||||
|
||||
/// 存储 ModelProviderConfig 到内存
|
||||
///
|
||||
/// 如果已存在相同服务名称的配置,将被覆盖。
|
||||
/// 使用 DashMap 的 insert 方法确保原子性。
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `service_name` - 服务标识符(如 UUID)
|
||||
/// * `config` - 完整的模型提供商配置
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```rust
|
||||
/// # use agent_runner::api_key_manager::ApiKeyManager;
|
||||
/// # use shared_types::ModelProviderConfig;
|
||||
/// let manager = ApiKeyManager::new();
|
||||
/// let config = ModelProviderConfig {
|
||||
/// id: "anthropic-prov".to_string(),
|
||||
/// name: "anthropic".to_string(),
|
||||
/// api_key: "sk-ant-xxx".to_string(),
|
||||
/// base_url: "https://api.anthropic.com".to_string(),
|
||||
/// requires_openai_auth: false,
|
||||
/// default_model: "claude-3-5-sonnet-20241022".to_string(),
|
||||
/// api_protocol: Some("anthropic".to_string()),
|
||||
/// };
|
||||
/// manager.store_config("svc-uuid-123", config);
|
||||
/// ```
|
||||
pub fn store_config(&self, service_name: &str, config: ModelProviderConfig) {
|
||||
debug!(
|
||||
"🔑 [API_KEY_MANAGER] Storing config: service_name={}",
|
||||
service_name
|
||||
);
|
||||
self.shared.insert(service_name.to_string(), config);
|
||||
}
|
||||
|
||||
/// 获取完整的 ModelProviderConfig
|
||||
///
|
||||
/// 使用 DashMap 的 value() 方法避免克隆(返回引用)。
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `service_name` - 服务标识符
|
||||
///
|
||||
/// # 返回
|
||||
///
|
||||
/// 如果找到配置则返回 `Some(ModelProviderConfig)`,否则返回 `None`。
|
||||
pub fn get(&self, service_name: &str) -> Option<ModelProviderConfig> {
|
||||
self.shared.get(service_name).map(|r| r.value().clone())
|
||||
}
|
||||
|
||||
/// 获取 API 密钥
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `service_name` - 服务标识符
|
||||
///
|
||||
/// # 返回
|
||||
///
|
||||
/// 如果找到配置则返回 `Some(api_key)`,否则返回 `None`。
|
||||
pub fn get_api_key(&self, service_name: &str) -> Option<String> {
|
||||
self.get(service_name).map(|c| c.api_key)
|
||||
}
|
||||
|
||||
/// 获取 API Base URL
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `service_name` - 服务标识符
|
||||
///
|
||||
/// # 返回
|
||||
///
|
||||
/// 如果找到配置则返回 `Some(base_url)`,否则返回 `None`。
|
||||
pub fn get_base_url(&self, service_name: &str) -> Option<String> {
|
||||
self.get(service_name).map(|c| c.base_url)
|
||||
}
|
||||
|
||||
/// 移除指定服务的配置
|
||||
///
|
||||
/// 使用 DashMap 的 remove 方法,这是原子性操作(entry API)。
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `service_name` - 服务标识符
|
||||
///
|
||||
/// # 返回
|
||||
///
|
||||
/// 如果找到并移除了配置则返回 `Some(ModelProviderConfig)`,否则返回 `None`。
|
||||
pub fn remove(&self, service_name: &str) -> Option<ModelProviderConfig> {
|
||||
debug!(
|
||||
"🔑 [API_KEY_MANAGER] Removing config: service_name={}",
|
||||
service_name
|
||||
);
|
||||
self.shared.remove(service_name).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// 清空所有配置
|
||||
pub fn clear(&self) {
|
||||
info!("🔑 [API_KEY_MANAGER] Cleared all configs");
|
||||
self.shared.clear();
|
||||
}
|
||||
|
||||
/// 获取当前存储的配置数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.shared.len()
|
||||
}
|
||||
|
||||
/// 检查是否为空
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.shared.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_config(name: &str) -> ModelProviderConfig {
|
||||
ModelProviderConfig {
|
||||
id: format!("test_{}", name),
|
||||
name: name.to_string(),
|
||||
base_url: format!("https://api.{}.com", name),
|
||||
api_key: format!("sk-{}-123", name),
|
||||
requires_openai_auth: name == "openai",
|
||||
default_model: "test-model".to_string(),
|
||||
api_protocol: Some(name.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_and_get() {
|
||||
let manager = ApiKeyManager::new();
|
||||
let config = create_test_config("anthropic");
|
||||
|
||||
manager.store_config("anthropic", config.clone());
|
||||
|
||||
let retrieved = manager.get("anthropic");
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().api_key, "sk-anthropic-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_api_key() {
|
||||
let manager = ApiKeyManager::new();
|
||||
let config = create_test_config("openai");
|
||||
|
||||
manager.store_config("openai", config);
|
||||
|
||||
let api_key = manager.get_api_key("openai");
|
||||
assert_eq!(api_key, Some("sk-openai-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_base_url() {
|
||||
let manager = ApiKeyManager::new();
|
||||
let config = create_test_config("anthropic");
|
||||
|
||||
manager.store_config("anthropic", config);
|
||||
|
||||
let base_url = manager.get_base_url("anthropic");
|
||||
assert_eq!(base_url, Some("https://api.anthropic.com".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonexistent() {
|
||||
let manager = ApiKeyManager::new();
|
||||
|
||||
assert!(manager.get("nonexistent").is_none());
|
||||
assert!(manager.get_api_key("nonexistent").is_none());
|
||||
assert!(manager.get_base_url("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove() {
|
||||
let manager = ApiKeyManager::new();
|
||||
let config = create_test_config("anthropic");
|
||||
|
||||
manager.store_config("anthropic", config);
|
||||
assert_eq!(manager.len(), 1);
|
||||
|
||||
let removed = manager.remove("anthropic");
|
||||
assert!(removed.is_some());
|
||||
assert_eq!(manager.len(), 0);
|
||||
assert!(manager.get("anthropic").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear() {
|
||||
let manager = ApiKeyManager::new();
|
||||
|
||||
manager.store_config("anthropic", create_test_config("anthropic"));
|
||||
manager.store_config("openai", create_test_config("openai"));
|
||||
|
||||
assert_eq!(manager.len(), 2);
|
||||
manager.clear();
|
||||
assert_eq!(manager.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overwrite() {
|
||||
let manager = ApiKeyManager::new();
|
||||
|
||||
manager.store_config("anthropic", create_test_config("anthropic"));
|
||||
|
||||
let mut new_config = create_test_config("anthropic");
|
||||
new_config.api_key = "sk-updated".to_string();
|
||||
|
||||
manager.store_config("anthropic", new_config);
|
||||
|
||||
let api_key = manager.get_api_key("anthropic");
|
||||
assert_eq!(api_key, Some("sk-updated".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_empty() {
|
||||
let manager = ApiKeyManager::new();
|
||||
assert!(manager.is_empty());
|
||||
|
||||
manager.store_config("anthropic", create_test_config("anthropic"));
|
||||
assert!(!manager.is_empty());
|
||||
}
|
||||
}
|
||||
954
qiming-rcoder/crates/agent_runner/src/config.rs
Normal file
954
qiming-rcoder/crates/agent_runner/src/config.rs
Normal file
@@ -0,0 +1,954 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// 命令行参数
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "rcoder")]
|
||||
#[command(about = "AI-powered development platform")]
|
||||
#[command(version)]
|
||||
pub struct CliArgs {
|
||||
/// Service port
|
||||
#[arg(short, long, help = "Service port")]
|
||||
pub port: Option<u16>,
|
||||
|
||||
/// Project workspace directory
|
||||
#[arg(short = 'd', long, help = "Root directory for project workspace")]
|
||||
pub projects_dir: Option<PathBuf>,
|
||||
|
||||
/// Enable port-based reverse proxy
|
||||
#[arg(long, help = "Enable port-based reverse proxy")]
|
||||
pub enable_proxy: bool,
|
||||
|
||||
/// Proxy listener port
|
||||
#[arg(long, help = "Proxy service listener port")]
|
||||
pub proxy_port: Option<u16>,
|
||||
|
||||
/// Default backend port
|
||||
#[arg(long, help = "Default backend service port")]
|
||||
pub default_backend_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// 应用配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
/// 默认使用的 Agent ID
|
||||
#[serde(default = "default_agent_id")]
|
||||
pub default_agent_id: String,
|
||||
/// 项目工作的根目录,根据启动命令的当前目录来确定
|
||||
pub projects_dir: PathBuf,
|
||||
/// 服务端口
|
||||
pub port: u16,
|
||||
/// 代理配置
|
||||
pub proxy_config: Option<ProxyConfig>,
|
||||
/// Agent 清理配置
|
||||
#[serde(default)]
|
||||
pub agent_cleanup: Option<AgentCleanupConfig>,
|
||||
/// gRPC 超时配置
|
||||
#[serde(default)]
|
||||
pub grpc_timeouts: Option<GrpcTimeoutConfig>,
|
||||
/// Agent 并发配置
|
||||
#[serde(default)]
|
||||
pub agent_concurrency: Option<AgentConcurrencyConfig>,
|
||||
/// mcp-proxy 日志目录(可选)
|
||||
/// 当设置此值且日志级别为 debug 时,mcp-proxy convert 命令会自动追加
|
||||
/// --diagnostic 和 --log-dir 参数
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_proxy_log_dir: Option<String>,
|
||||
}
|
||||
|
||||
fn default_agent_id() -> String {
|
||||
"claude-code-acp-ts".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthCheckConfig {
|
||||
pub enabled: bool,
|
||||
pub interval_seconds: u64,
|
||||
pub timeout_seconds: u64,
|
||||
pub healthy_threshold: u32,
|
||||
pub unhealthy_threshold: u32,
|
||||
}
|
||||
|
||||
/// 代理配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProxyConfig {
|
||||
/// 代理监听端口
|
||||
pub listen_port: u16,
|
||||
/// 默认后端端口
|
||||
pub default_backend_port: u16,
|
||||
/// 后端服务主机
|
||||
pub backend_host: String,
|
||||
/// URL 中端口参数的名称
|
||||
pub port_param: String,
|
||||
/// 健康检查配置
|
||||
pub health_check: HealthCheckConfig,
|
||||
}
|
||||
|
||||
/// Agent cleanup configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentCleanupConfig {
|
||||
/// Idle timeout (seconds), default 300 (5 minutes)
|
||||
#[serde(default = "default_idle_timeout")]
|
||||
pub idle_timeout_secs: u64,
|
||||
/// Cleanup check interval (seconds), default 30
|
||||
#[serde(default = "default_cleanup_interval")]
|
||||
pub cleanup_interval_secs: u64,
|
||||
}
|
||||
|
||||
/// Agent 并发配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentConcurrencyConfig {
|
||||
/// 并发会话槽位数量,默认 10
|
||||
#[serde(default = "default_concurrency_limit")]
|
||||
pub concurrency_limit: usize,
|
||||
}
|
||||
|
||||
/// Agent 并发配置常量
|
||||
impl AgentConcurrencyConfig {
|
||||
/// 最小并发限制
|
||||
pub const MIN_CONCURRENCY_LIMIT: usize = 1;
|
||||
|
||||
/// 验证配置值是否在有效范围内
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.concurrency_limit < Self::MIN_CONCURRENCY_LIMIT {
|
||||
return Err(format!(
|
||||
"concurrency_limit must be >= {}, current value: {}",
|
||||
Self::MIN_CONCURRENCY_LIMIT,
|
||||
self.concurrency_limit
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_concurrency_limit() -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
impl Default for AgentConcurrencyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
concurrency_limit: default_concurrency_limit(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// gRPC timeout configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GrpcTimeoutConfig {
|
||||
/// Cancel session timeout (seconds), default 30
|
||||
#[serde(default = "default_cancel_timeout")]
|
||||
pub cancel_session_timeout_secs: u64,
|
||||
|
||||
/// ACP session creation timeout (seconds), default 100
|
||||
#[serde(default = "default_acp_session_timeout")]
|
||||
pub acp_session_create_timeout_secs: u64,
|
||||
|
||||
/// Agent cancel call timeout (seconds), default 10
|
||||
#[serde(default = "default_agent_cancel_timeout")]
|
||||
pub agent_cancel_timeout_secs: u64,
|
||||
|
||||
/// Port check timeout (milliseconds), default 500
|
||||
#[serde(default = "default_port_check_timeout")]
|
||||
pub port_check_timeout_millis: u64,
|
||||
}
|
||||
|
||||
/// gRPC timeout configuration constants
|
||||
impl GrpcTimeoutConfig {
|
||||
/// Minimum cancel session timeout (5 seconds)
|
||||
pub const MIN_CANCEL_TIMEOUT: u64 = 5;
|
||||
/// Maximum cancel session timeout (300 seconds = 5 minutes)
|
||||
pub const MAX_CANCEL_TIMEOUT: u64 = 300;
|
||||
/// Minimum ACP session creation timeout (10 seconds)
|
||||
pub const MIN_ACP_SESSION_TIMEOUT: u64 = 10;
|
||||
/// Maximum ACP session creation timeout (300 seconds = 5 minutes)
|
||||
pub const MAX_ACP_SESSION_TIMEOUT: u64 = 300;
|
||||
/// Minimum Agent cancel call timeout (5 seconds)
|
||||
pub const MIN_AGENT_CANCEL_TIMEOUT: u64 = 5;
|
||||
/// Maximum Agent cancel call timeout (60 seconds)
|
||||
pub const MAX_AGENT_CANCEL_TIMEOUT: u64 = 60;
|
||||
/// Minimum port check timeout (100 milliseconds)
|
||||
pub const MIN_PORT_CHECK_TIMEOUT: u64 = 100;
|
||||
/// Maximum port check timeout (10000 milliseconds = 10 seconds)
|
||||
pub const MAX_PORT_CHECK_TIMEOUT: u64 = 10000;
|
||||
|
||||
/// Validate that configuration values are within valid ranges
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.cancel_session_timeout_secs < Self::MIN_CANCEL_TIMEOUT
|
||||
|| self.cancel_session_timeout_secs > Self::MAX_CANCEL_TIMEOUT
|
||||
{
|
||||
return Err(format!(
|
||||
"cancel_session_timeout_secs must be between {} and {}, current: {}",
|
||||
Self::MIN_CANCEL_TIMEOUT,
|
||||
Self::MAX_CANCEL_TIMEOUT,
|
||||
self.cancel_session_timeout_secs
|
||||
));
|
||||
}
|
||||
|
||||
if self.acp_session_create_timeout_secs < Self::MIN_ACP_SESSION_TIMEOUT
|
||||
|| self.acp_session_create_timeout_secs > Self::MAX_ACP_SESSION_TIMEOUT
|
||||
{
|
||||
return Err(format!(
|
||||
"acp_session_create_timeout_secs must be between {} and {}, current: {}",
|
||||
Self::MIN_ACP_SESSION_TIMEOUT,
|
||||
Self::MAX_ACP_SESSION_TIMEOUT,
|
||||
self.acp_session_create_timeout_secs
|
||||
));
|
||||
}
|
||||
|
||||
if self.agent_cancel_timeout_secs < Self::MIN_AGENT_CANCEL_TIMEOUT
|
||||
|| self.agent_cancel_timeout_secs > Self::MAX_AGENT_CANCEL_TIMEOUT
|
||||
{
|
||||
return Err(format!(
|
||||
"agent_cancel_timeout_secs must be between {} and {}, current: {}",
|
||||
Self::MIN_AGENT_CANCEL_TIMEOUT,
|
||||
Self::MAX_AGENT_CANCEL_TIMEOUT,
|
||||
self.agent_cancel_timeout_secs
|
||||
));
|
||||
}
|
||||
|
||||
if self.port_check_timeout_millis < Self::MIN_PORT_CHECK_TIMEOUT
|
||||
|| self.port_check_timeout_millis > Self::MAX_PORT_CHECK_TIMEOUT
|
||||
{
|
||||
return Err(format!(
|
||||
"port_check_timeout_millis must be between {} and {}, current: {}",
|
||||
Self::MIN_PORT_CHECK_TIMEOUT,
|
||||
Self::MAX_PORT_CHECK_TIMEOUT,
|
||||
self.port_check_timeout_millis
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent 清理配置常量
|
||||
impl AgentCleanupConfig {
|
||||
/// 最小闲置超时时间(10 秒)
|
||||
pub const MIN_IDLE_TIMEOUT: u64 = 10;
|
||||
/// 最大闲置超时时间(24 小时)
|
||||
pub const MAX_IDLE_TIMEOUT: u64 = 24 * 60 * 60;
|
||||
/// 最小清理检查间隔(5 秒)
|
||||
pub const MIN_CLEANUP_INTERVAL: u64 = 5;
|
||||
/// 最大清理检查间隔(1 小时)
|
||||
pub const MAX_CLEANUP_INTERVAL: u64 = 60 * 60;
|
||||
|
||||
/// 验证配置值是否在有效范围内
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.idle_timeout_secs < Self::MIN_IDLE_TIMEOUT
|
||||
|| self.idle_timeout_secs > Self::MAX_IDLE_TIMEOUT
|
||||
{
|
||||
return Err(format!(
|
||||
"idle_timeout_secs must be between {} and {}, current value: {}",
|
||||
Self::MIN_IDLE_TIMEOUT,
|
||||
Self::MAX_IDLE_TIMEOUT,
|
||||
self.idle_timeout_secs
|
||||
));
|
||||
}
|
||||
|
||||
if self.cleanup_interval_secs < Self::MIN_CLEANUP_INTERVAL
|
||||
|| self.cleanup_interval_secs > Self::MAX_CLEANUP_INTERVAL
|
||||
{
|
||||
return Err(format!(
|
||||
"cleanup_interval_secs must be between {} and {}, current value: {}",
|
||||
Self::MIN_CLEANUP_INTERVAL,
|
||||
Self::MAX_CLEANUP_INTERVAL,
|
||||
self.cleanup_interval_secs
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "http-server")]
|
||||
fn default_idle_timeout() -> u64 {
|
||||
24 * 60 * 60 // 24 小时(Tauri 客户端模式)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "http-server"))]
|
||||
fn default_idle_timeout() -> u64 {
|
||||
300 // 5 分钟(CLI 模式)
|
||||
}
|
||||
|
||||
fn default_cleanup_interval() -> u64 {
|
||||
30 // 30 秒
|
||||
}
|
||||
|
||||
fn default_cancel_timeout() -> u64 {
|
||||
30 // 30 秒
|
||||
}
|
||||
|
||||
fn default_acp_session_timeout() -> u64 {
|
||||
100 // 100 秒
|
||||
}
|
||||
|
||||
fn default_agent_cancel_timeout() -> u64 {
|
||||
10 // 10 秒
|
||||
}
|
||||
|
||||
fn default_port_check_timeout() -> u64 {
|
||||
500 // 500 毫秒
|
||||
}
|
||||
|
||||
impl Default for AgentCleanupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
idle_timeout_secs: default_idle_timeout(),
|
||||
cleanup_interval_secs: default_cleanup_interval(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GrpcTimeoutConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cancel_session_timeout_secs: default_cancel_timeout(),
|
||||
acp_session_create_timeout_secs: default_acp_session_timeout(),
|
||||
agent_cancel_timeout_secs: default_agent_cancel_timeout(),
|
||||
port_check_timeout_millis: default_port_check_timeout(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置文件路径
|
||||
const CONFIG_FILE: &str = "config.yml";
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_agent_id: default_agent_id(),
|
||||
projects_dir: PathBuf::from("./project_workspace"),
|
||||
port: 8086,
|
||||
proxy_config: Some(ProxyConfig::default()),
|
||||
agent_cleanup: Some(AgentCleanupConfig::default()),
|
||||
grpc_timeouts: Some(GrpcTimeoutConfig::default()),
|
||||
agent_concurrency: Some(AgentConcurrencyConfig::default()),
|
||||
mcp_proxy_log_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProxyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
listen_port: 8088,
|
||||
default_backend_port: 8086,
|
||||
backend_host: "127.0.0.1".to_string(),
|
||||
port_param: "port".to_string(),
|
||||
health_check: HealthCheckConfig {
|
||||
enabled: true,
|
||||
interval_seconds: 5,
|
||||
timeout_seconds: 1,
|
||||
healthy_threshold: 2,
|
||||
unhealthy_threshold: 3,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载配置
|
||||
/// 配置优先级:命令行参数 > 环境变量 > 配置文件 > 默认配置
|
||||
pub fn load_config_with_args(cli_args: CliArgs) -> AppConfig {
|
||||
// 1. 首先加载默认配置
|
||||
let mut config = AppConfig::default();
|
||||
|
||||
// 2. 尝试从当前目录读取配置文件
|
||||
match load_config_from_file() {
|
||||
Ok(file_config) => {
|
||||
config = file_config;
|
||||
info!("Loaded config from {}", CONFIG_FILE);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to read config file {}: {}, using defaults",
|
||||
CONFIG_FILE, e
|
||||
);
|
||||
|
||||
// 创建默认配置文件
|
||||
if let Err(create_err) = create_default_config_file(&config) {
|
||||
error!("Failed to create default config file: {}", create_err);
|
||||
} else {
|
||||
info!("Created default config file: {}", CONFIG_FILE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 环境变量覆盖配置
|
||||
if let Ok(port) = env::var("RCODER_PORT") {
|
||||
match port.parse::<u16>() {
|
||||
Ok(p) => {
|
||||
config.port = p;
|
||||
info!("Set port from env RCODER_PORT: {}", p);
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Invalid RCODER_PORT env value: {}, keeping config port: {}",
|
||||
port, config.port
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 Agent 清理配置:支持环境变量覆盖
|
||||
if let Ok(idle_timeout) = env::var("RCODER_AGENT_IDLE_TIMEOUT_SECS") {
|
||||
match idle_timeout.parse::<u64>() {
|
||||
Ok(timeout) => {
|
||||
// 🔒 验证范围
|
||||
if (AgentCleanupConfig::MIN_IDLE_TIMEOUT..=AgentCleanupConfig::MAX_IDLE_TIMEOUT)
|
||||
.contains(&timeout)
|
||||
{
|
||||
config
|
||||
.agent_cleanup
|
||||
.get_or_insert_with(Default::default)
|
||||
.idle_timeout_secs = timeout;
|
||||
info!(
|
||||
"Set idle timeout from env RCODER_AGENT_IDLE_TIMEOUT_SECS: {} seconds",
|
||||
timeout
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Invalid RCODER_AGENT_IDLE_TIMEOUT_SECS: {} seconds, out of range [{}, {}], keeping config value",
|
||||
timeout,
|
||||
AgentCleanupConfig::MIN_IDLE_TIMEOUT,
|
||||
AgentCleanupConfig::MAX_IDLE_TIMEOUT
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Invalid RCODER_AGENT_IDLE_TIMEOUT_SECS format: {}, keeping config value",
|
||||
idle_timeout
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(cleanup_interval) = env::var("RCODER_AGENT_CLEANUP_INTERVAL_SECS") {
|
||||
match cleanup_interval.parse::<u64>() {
|
||||
Ok(interval) => {
|
||||
// 🔒 验证范围
|
||||
if (AgentCleanupConfig::MIN_CLEANUP_INTERVAL
|
||||
..=AgentCleanupConfig::MAX_CLEANUP_INTERVAL)
|
||||
.contains(&interval)
|
||||
{
|
||||
config
|
||||
.agent_cleanup
|
||||
.get_or_insert_with(Default::default)
|
||||
.cleanup_interval_secs = interval;
|
||||
info!(
|
||||
"Set cleanup interval from env RCODER_AGENT_CLEANUP_INTERVAL_SECS: {} seconds",
|
||||
interval
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Invalid RCODER_AGENT_CLEANUP_INTERVAL_SECS: {} seconds, out of range [{}, {}], keeping config value",
|
||||
interval,
|
||||
AgentCleanupConfig::MIN_CLEANUP_INTERVAL,
|
||||
AgentCleanupConfig::MAX_CLEANUP_INTERVAL
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Invalid RCODER_AGENT_CLEANUP_INTERVAL_SECS format: {}, keeping config value",
|
||||
cleanup_interval
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 Agent 并发配置:支持环境变量覆盖
|
||||
if let Ok(concurrency_limit) = env::var("RCODER_AGENT_CONCURRENCY_LIMIT") {
|
||||
match concurrency_limit.parse::<usize>() {
|
||||
Ok(limit) => {
|
||||
if limit >= AgentConcurrencyConfig::MIN_CONCURRENCY_LIMIT {
|
||||
config
|
||||
.agent_concurrency
|
||||
.get_or_insert_with(Default::default)
|
||||
.concurrency_limit = limit;
|
||||
info!(
|
||||
"Set concurrency limit from env RCODER_AGENT_CONCURRENCY_LIMIT: {}",
|
||||
limit
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Invalid RCODER_AGENT_CONCURRENCY_LIMIT: {}, must be >= {}, keeping config value",
|
||||
limit,
|
||||
AgentConcurrencyConfig::MIN_CONCURRENCY_LIMIT
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Invalid RCODER_AGENT_CONCURRENCY_LIMIT format: {}, keeping config value",
|
||||
concurrency_limit
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 gRPC 超时配置:支持环境变量覆盖
|
||||
if let Ok(cancel_timeout) = env::var("RCODER_CANCEL_SESSION_TIMEOUT_SECS") {
|
||||
match cancel_timeout.parse::<u64>() {
|
||||
Ok(timeout) => {
|
||||
if (GrpcTimeoutConfig::MIN_CANCEL_TIMEOUT..=GrpcTimeoutConfig::MAX_CANCEL_TIMEOUT)
|
||||
.contains(&timeout)
|
||||
{
|
||||
config
|
||||
.grpc_timeouts
|
||||
.get_or_insert_with(Default::default)
|
||||
.cancel_session_timeout_secs = timeout;
|
||||
info!(
|
||||
"Set cancel-session timeout from env RCODER_CANCEL_SESSION_TIMEOUT_SECS: {} seconds",
|
||||
timeout
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Invalid RCODER_CANCEL_SESSION_TIMEOUT_SECS: {} seconds, out of range [{}, {}]",
|
||||
timeout,
|
||||
GrpcTimeoutConfig::MIN_CANCEL_TIMEOUT,
|
||||
GrpcTimeoutConfig::MAX_CANCEL_TIMEOUT
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Invalid RCODER_CANCEL_SESSION_TIMEOUT_SECS format: {}",
|
||||
cancel_timeout
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(acp_timeout) = env::var("RCODER_ACP_SESSION_CREATE_TIMEOUT_SECS") {
|
||||
match acp_timeout.parse::<u64>() {
|
||||
Ok(timeout) => {
|
||||
if (GrpcTimeoutConfig::MIN_ACP_SESSION_TIMEOUT
|
||||
..=GrpcTimeoutConfig::MAX_ACP_SESSION_TIMEOUT)
|
||||
.contains(&timeout)
|
||||
{
|
||||
config
|
||||
.grpc_timeouts
|
||||
.get_or_insert_with(Default::default)
|
||||
.acp_session_create_timeout_secs = timeout;
|
||||
info!(
|
||||
"Set ACP session-create timeout from env RCODER_ACP_SESSION_CREATE_TIMEOUT_SECS: {} seconds",
|
||||
timeout
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Invalid RCODER_ACP_SESSION_CREATE_TIMEOUT_SECS: {} seconds, out of range [{}, {}]",
|
||||
timeout,
|
||||
GrpcTimeoutConfig::MIN_ACP_SESSION_TIMEOUT,
|
||||
GrpcTimeoutConfig::MAX_ACP_SESSION_TIMEOUT
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Invalid RCODER_ACP_SESSION_CREATE_TIMEOUT_SECS format: {}",
|
||||
acp_timeout
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(agent_cancel_timeout) = env::var("RCODER_AGENT_CANCEL_TIMEOUT_SECS") {
|
||||
match agent_cancel_timeout.parse::<u64>() {
|
||||
Ok(timeout) => {
|
||||
if (GrpcTimeoutConfig::MIN_AGENT_CANCEL_TIMEOUT
|
||||
..=GrpcTimeoutConfig::MAX_AGENT_CANCEL_TIMEOUT)
|
||||
.contains(&timeout)
|
||||
{
|
||||
config
|
||||
.grpc_timeouts
|
||||
.get_or_insert_with(Default::default)
|
||||
.agent_cancel_timeout_secs = timeout;
|
||||
info!(
|
||||
"Set agent-cancel timeout from env RCODER_AGENT_CANCEL_TIMEOUT_SECS: {} seconds",
|
||||
timeout
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Invalid RCODER_AGENT_CANCEL_TIMEOUT_SECS: {} seconds, out of range [{}, {}]",
|
||||
timeout,
|
||||
GrpcTimeoutConfig::MIN_AGENT_CANCEL_TIMEOUT,
|
||||
GrpcTimeoutConfig::MAX_AGENT_CANCEL_TIMEOUT
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Invalid RCODER_AGENT_CANCEL_TIMEOUT_SECS format: {}",
|
||||
agent_cancel_timeout
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(port_check_timeout) = env::var("RCODER_PORT_CHECK_TIMEOUT_MILLIS") {
|
||||
match port_check_timeout.parse::<u64>() {
|
||||
Ok(timeout) => {
|
||||
if (GrpcTimeoutConfig::MIN_PORT_CHECK_TIMEOUT
|
||||
..=GrpcTimeoutConfig::MAX_PORT_CHECK_TIMEOUT)
|
||||
.contains(&timeout)
|
||||
{
|
||||
config
|
||||
.grpc_timeouts
|
||||
.get_or_insert_with(Default::default)
|
||||
.port_check_timeout_millis = timeout;
|
||||
info!(
|
||||
"Set port-check timeout from env RCODER_PORT_CHECK_TIMEOUT_MILLIS: {} ms",
|
||||
timeout
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Invalid RCODER_PORT_CHECK_TIMEOUT_MILLIS: {} ms, out of range [{}, {}]",
|
||||
timeout,
|
||||
GrpcTimeoutConfig::MIN_PORT_CHECK_TIMEOUT,
|
||||
GrpcTimeoutConfig::MAX_PORT_CHECK_TIMEOUT
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Invalid RCODER_PORT_CHECK_TIMEOUT_MILLIS format: {}",
|
||||
port_check_timeout
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 验证最终配置的有效性
|
||||
if let Some(ref cleanup_config) = config.agent_cleanup
|
||||
&& let Err(e) = cleanup_config.validate()
|
||||
{
|
||||
warn!(
|
||||
"Agent cleanup config validation failed: {}, using defaults",
|
||||
e
|
||||
);
|
||||
config.agent_cleanup = Some(AgentCleanupConfig::default());
|
||||
}
|
||||
|
||||
// 🆕 验证 Agent 并发配置的有效性
|
||||
if let Some(ref concurrency_config) = config.agent_concurrency
|
||||
&& let Err(e) = concurrency_config.validate()
|
||||
{
|
||||
warn!(
|
||||
"Agent concurrency config validation failed: {}, using defaults",
|
||||
e
|
||||
);
|
||||
config.agent_concurrency = Some(AgentConcurrencyConfig::default());
|
||||
}
|
||||
|
||||
// 4. 处理代理配置
|
||||
if cli_args.enable_proxy {
|
||||
let proxy_config = ProxyConfig {
|
||||
listen_port: cli_args.proxy_port.unwrap_or(8080),
|
||||
default_backend_port: cli_args.default_backend_port.unwrap_or(config.port),
|
||||
backend_host: "127.0.0.1".to_string(),
|
||||
port_param: "port".to_string(),
|
||||
health_check: HealthCheckConfig {
|
||||
enabled: true,
|
||||
interval_seconds: 5,
|
||||
timeout_seconds: 1,
|
||||
healthy_threshold: 2,
|
||||
unhealthy_threshold: 3,
|
||||
},
|
||||
};
|
||||
info!(
|
||||
"Reverse proxy enabled, listening on port: {}",
|
||||
proxy_config.listen_port
|
||||
);
|
||||
config.proxy_config = Some(proxy_config);
|
||||
}
|
||||
|
||||
// 5. 命令行参数覆盖配置(优先级最高)
|
||||
if let Some(port) = cli_args.port {
|
||||
config.port = port;
|
||||
info!("Set port from CLI arg: {}", port);
|
||||
}
|
||||
|
||||
if let Some(projects_dir) = cli_args.projects_dir {
|
||||
config.projects_dir = projects_dir.clone();
|
||||
info!("Set projects directory from CLI arg: {:?}", projects_dir);
|
||||
}
|
||||
|
||||
info!(
|
||||
"最终配置: port={}, projects_dir={:?}, default_agent_id={}, proxy_enabled={}",
|
||||
config.port,
|
||||
config.projects_dir,
|
||||
config.default_agent_id,
|
||||
config.proxy_config.is_some()
|
||||
);
|
||||
|
||||
// 🆕 验证 gRPC 超时配置的有效性
|
||||
if let Some(ref grpc_timeouts) = config.grpc_timeouts
|
||||
&& let Err(e) = grpc_timeouts.validate()
|
||||
{
|
||||
warn!(
|
||||
"gRPC timeout config validation failed: {}, using defaults",
|
||||
e
|
||||
);
|
||||
config.grpc_timeouts = Some(GrpcTimeoutConfig::default());
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
/// 加载配置(保留旧接口以保持兼容性)
|
||||
pub fn load_config() -> AppConfig {
|
||||
let cli_args = CliArgs {
|
||||
port: None,
|
||||
projects_dir: None,
|
||||
enable_proxy: false,
|
||||
proxy_port: None,
|
||||
default_backend_port: None,
|
||||
};
|
||||
load_config_with_args(cli_args)
|
||||
}
|
||||
|
||||
/// 从文件加载配置
|
||||
fn load_config_from_file() -> anyhow::Result<AppConfig> {
|
||||
let config_content = fs::read_to_string(CONFIG_FILE)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read config file: {}", e))?;
|
||||
|
||||
let config: AppConfig = serde_yaml::from_str(&config_content)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse config file: {}", e))?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 创建默认配置文件
|
||||
fn create_default_config_file(config: &AppConfig) -> anyhow::Result<()> {
|
||||
// 获取 proxy_config,如果不存在则使用默认值
|
||||
let proxy_config = config.proxy_config.as_ref().cloned().unwrap_or_default();
|
||||
|
||||
// 获取 agent_cleanup 配置,如果不存在则使用默认值
|
||||
let agent_cleanup = config.agent_cleanup.as_ref().cloned().unwrap_or_default();
|
||||
|
||||
// 获取 grpc_timeouts 配置,如果不存在则使用默认值
|
||||
let grpc_timeouts = config.grpc_timeouts.as_ref().cloned().unwrap_or_default();
|
||||
|
||||
// 获取 agent_concurrency 配置,如果不存在则使用默认值
|
||||
let agent_concurrency = config
|
||||
.agent_concurrency
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// 手动构建带注释的 YAML 内容
|
||||
let content_with_comments = format!(
|
||||
r#"# rcoder 配置文件
|
||||
# 该文件在首次启动时自动生成
|
||||
|
||||
# 默认使用的 Agent ID
|
||||
default_agent_id: {}
|
||||
|
||||
# 项目工作目录
|
||||
projects_dir: {}
|
||||
|
||||
# 主服务端口
|
||||
port: {}
|
||||
|
||||
# Pingora 反向代理配置
|
||||
proxy_config:
|
||||
# 代理服务监听端口 (用于接收外部请求)
|
||||
listen_port: {}
|
||||
# 默认后端服务端口 (当请求未指定端口时使用)
|
||||
default_backend_port: {}
|
||||
# 后端服务主机地址
|
||||
backend_host: "{}"
|
||||
# URL 中端口参数的名称 (用于从路径中提取端口号)
|
||||
port_param: "{}"
|
||||
# 健康检查配置
|
||||
health_check:
|
||||
enabled: {}
|
||||
interval_seconds: {}
|
||||
timeout_seconds: {}
|
||||
healthy_threshold: {}
|
||||
unhealthy_threshold: {}
|
||||
|
||||
# Agent 清理配置
|
||||
# 如果省略此配置块,将使用以下默认值:
|
||||
# - idle_timeout_secs: 300 (5分钟)
|
||||
# - cleanup_interval_secs: 30 (30秒)
|
||||
agent_cleanup:
|
||||
# Agent 闲置超时时间(秒)
|
||||
# Agent 在闲置超过此时间后会被自动清理以释放资源
|
||||
# 有效范围: 10 - 86400 秒(10秒 - 24小时)
|
||||
# 可通过环境变量 RCODER_AGENT_IDLE_TIMEOUT_SECS 覆盖
|
||||
idle_timeout_secs: {}
|
||||
# 清理检查间隔(秒)
|
||||
# 系统每隔此时间检查一次是否有闲置的 Agent 需要清理
|
||||
# 有效范围: 5 - 3600 秒(5秒 - 1小时)
|
||||
# 可通过环境变量 RCODER_AGENT_CLEANUP_INTERVAL_SECS 覆盖
|
||||
cleanup_interval_secs: {}
|
||||
|
||||
# gRPC 超时配置
|
||||
# 如果省略此配置块,将使用以下默认值:
|
||||
# - cancel_session_timeout_secs: 30 (30秒)
|
||||
# - acp_session_create_timeout_secs: 100 (100秒)
|
||||
# - agent_cancel_timeout_secs: 10 (10秒)
|
||||
# - port_check_timeout_millis: 500 (500毫秒)
|
||||
grpc_timeouts:
|
||||
# 取消会话超时(秒)
|
||||
# gRPC 取消会话请求的最大等待时间
|
||||
# 有效范围: 5 - 300 秒
|
||||
# 可通过环境变量 RCODER_CANCEL_SESSION_TIMEOUT_SECS 覆盖
|
||||
cancel_session_timeout_secs: {}
|
||||
# ACP 会话创建超时(秒)
|
||||
# Agent 创建新会话的最大等待时间(MCP 工具较多时可能需要更长时间)
|
||||
# 有效范围: 10 - 300 秒
|
||||
# 可通过环境变量 RCODER_ACP_SESSION_CREATE_TIMEOUT_SECS 覆盖
|
||||
acp_session_create_timeout_secs: {}
|
||||
# Agent 取消调用超时(秒)
|
||||
# Agent 内部取消操作的最大等待时间
|
||||
# 有效范围: 5 - 60 秒
|
||||
# 可通过环境变量 RCODER_AGENT_CANCEL_TIMEOUT_SECS 覆盖
|
||||
agent_cancel_timeout_secs: {}
|
||||
# 端口检查超时(毫秒)
|
||||
# 检查端口可用性的最大等待时间
|
||||
# 有效范围: 100 - 10000 毫秒
|
||||
# 可通过环境变量 RCODER_PORT_CHECK_TIMEOUT_MILLIS 覆盖
|
||||
port_check_timeout_millis: {}
|
||||
|
||||
# Agent 并发配置
|
||||
# 如果省略此配置块,将使用以下默认值:
|
||||
# - concurrency_limit: 10 (10个并发会话)
|
||||
agent_concurrency:
|
||||
# Agent 并发会话槽位数量
|
||||
# 决定可以同时处理的 Agent 会话数量
|
||||
# 有效范围: >= 1
|
||||
# 可通过环境变量 RCODER_AGENT_CONCURRENCY_LIMIT 覆盖
|
||||
concurrency_limit: {}
|
||||
"#,
|
||||
config.default_agent_id,
|
||||
config.projects_dir.display(),
|
||||
config.port,
|
||||
proxy_config.listen_port,
|
||||
proxy_config.default_backend_port,
|
||||
proxy_config.backend_host,
|
||||
proxy_config.port_param,
|
||||
proxy_config.health_check.enabled,
|
||||
proxy_config.health_check.interval_seconds,
|
||||
proxy_config.health_check.timeout_seconds,
|
||||
proxy_config.health_check.healthy_threshold,
|
||||
proxy_config.health_check.unhealthy_threshold,
|
||||
agent_cleanup.idle_timeout_secs,
|
||||
agent_cleanup.cleanup_interval_secs,
|
||||
grpc_timeouts.cancel_session_timeout_secs,
|
||||
grpc_timeouts.acp_session_create_timeout_secs,
|
||||
grpc_timeouts.agent_cancel_timeout_secs,
|
||||
grpc_timeouts.port_check_timeout_millis,
|
||||
agent_concurrency.concurrency_limit
|
||||
);
|
||||
|
||||
fs::write(CONFIG_FILE, content_with_comments)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write config file: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_agent_cleanup_default_values() {
|
||||
let config = AgentCleanupConfig::default();
|
||||
assert_eq!(config.idle_timeout_secs, 300);
|
||||
assert_eq!(config.cleanup_interval_secs, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_cleanup_validate_valid_range() {
|
||||
let config = AgentCleanupConfig {
|
||||
idle_timeout_secs: 600, // 10 分钟
|
||||
cleanup_interval_secs: 60, // 1 分钟
|
||||
};
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_cleanup_validate_min_boundaries() {
|
||||
// 测试最小边界值
|
||||
let config = AgentCleanupConfig {
|
||||
idle_timeout_secs: AgentCleanupConfig::MIN_IDLE_TIMEOUT,
|
||||
cleanup_interval_secs: AgentCleanupConfig::MIN_CLEANUP_INTERVAL,
|
||||
};
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_cleanup_validate_max_boundaries() {
|
||||
// 测试最大边界值
|
||||
let config = AgentCleanupConfig {
|
||||
idle_timeout_secs: AgentCleanupConfig::MAX_IDLE_TIMEOUT,
|
||||
cleanup_interval_secs: AgentCleanupConfig::MAX_CLEANUP_INTERVAL,
|
||||
};
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_cleanup_validate_idle_timeout_too_small() {
|
||||
let config = AgentCleanupConfig {
|
||||
idle_timeout_secs: 5, // 小于最小值 10
|
||||
cleanup_interval_secs: 30,
|
||||
};
|
||||
assert!(config.validate().is_err());
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(err.contains("idle_timeout_secs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_cleanup_validate_idle_timeout_too_large() {
|
||||
let config = AgentCleanupConfig {
|
||||
idle_timeout_secs: 100000, // 大于最大值 86400
|
||||
cleanup_interval_secs: 30,
|
||||
};
|
||||
assert!(config.validate().is_err());
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(err.contains("idle_timeout_secs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_cleanup_validate_cleanup_interval_too_small() {
|
||||
let config = AgentCleanupConfig {
|
||||
idle_timeout_secs: 180,
|
||||
cleanup_interval_secs: 2, // 小于最小值 5
|
||||
};
|
||||
assert!(config.validate().is_err());
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(err.contains("cleanup_interval_secs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_cleanup_validate_cleanup_interval_too_large() {
|
||||
let config = AgentCleanupConfig {
|
||||
idle_timeout_secs: 180,
|
||||
cleanup_interval_secs: 5000, // 大于最大值 3600
|
||||
};
|
||||
assert!(config.validate().is_err());
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(err.contains("cleanup_interval_secs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_cleanup_validate_both_invalid() {
|
||||
let config = AgentCleanupConfig {
|
||||
idle_timeout_secs: 0,
|
||||
cleanup_interval_secs: 0,
|
||||
};
|
||||
assert!(config.validate().is_err());
|
||||
// 应该先检测到 idle_timeout_secs 的错误
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(err.contains("idle_timeout_secs"));
|
||||
}
|
||||
}
|
||||
1822
qiming-rcoder/crates/agent_runner/src/grpc/agent_service_impl.rs
Normal file
1822
qiming-rcoder/crates/agent_runner/src/grpc/agent_service_impl.rs
Normal file
File diff suppressed because it is too large
Load Diff
7
qiming-rcoder/crates/agent_runner/src/grpc/mod.rs
Normal file
7
qiming-rcoder/crates/agent_runner/src/grpc/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! gRPC 服务模块
|
||||
//!
|
||||
//! 提供 agent_runner 的 gRPC 服务端实现,用于替代原有的 HTTP 接口
|
||||
|
||||
pub mod agent_service_impl;
|
||||
|
||||
pub use agent_service_impl::AgentServiceImpl;
|
||||
@@ -0,0 +1,35 @@
|
||||
//! 健康检查处理器
|
||||
|
||||
use axum::Json;
|
||||
use chrono::Utc;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// 健康检查响应结构
|
||||
#[derive(serde::Serialize, ToSchema)]
|
||||
pub struct HealthResponse {
|
||||
/// 服务状态
|
||||
pub status: String,
|
||||
/// 时间戳
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
/// 服务名称
|
||||
pub service: String,
|
||||
}
|
||||
|
||||
/// 健康检查端点
|
||||
///
|
||||
/// 检查服务的健康状态
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
responses(
|
||||
(status = 200, description = "服务健康状态", body = HealthResponse)
|
||||
),
|
||||
tag = "system"
|
||||
)]
|
||||
pub async fn health_check() -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "healthy".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
service: "rcoder-ai-service".to_string(),
|
||||
})
|
||||
}
|
||||
4
qiming-rcoder/crates/agent_runner/src/handler/mod.rs
Normal file
4
qiming-rcoder/crates/agent_runner/src/handler/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! HTTP 路由和处理器模块
|
||||
mod health_handler;
|
||||
|
||||
pub use health_handler::*;
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Computer Agent Cancel Handler
|
||||
//!
|
||||
//! 处理 POST /computer/agent/session/cancel 请求
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
};
|
||||
use agent_client_protocol::schema::{CancelNotification, SessionId};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::CancelNotificationRequestWrapper;
|
||||
use crate::http_server::router::AppState;
|
||||
use crate::service::AGENT_REGISTRY;
|
||||
use shared_types::{
|
||||
ComputerAgentCancelRequest, ComputerAgentCancelResponse, HttpResult, I18nJsonOrQuery,
|
||||
error_codes::ERR_VALIDATION, get_i18n_message, AppError,
|
||||
};
|
||||
|
||||
use super::locale_from_headers;
|
||||
|
||||
/// 取消 Computer Agent 会话任务
|
||||
///
|
||||
/// 直接操作 AGENT_REGISTRY 发送取消信号
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/computer/agent/session/cancel",
|
||||
params(
|
||||
ComputerAgentCancelRequest
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Cancel request successful", body = HttpResult<ComputerAgentCancelResponse>),
|
||||
(status = 400, description = "Bad request - missing fields"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Computer Agent"
|
||||
)]
|
||||
pub async fn handle_computer_cancel(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<ComputerAgentCancelRequest>,
|
||||
) -> Result<Json<HttpResult<ComputerAgentCancelResponse>>, AppError> {
|
||||
let locale = locale_from_headers(&headers);
|
||||
info!(
|
||||
"🚫 [HTTP] Computer Agent 取消请求: user_id={:?}, project_id={}, session_id={:?}",
|
||||
request.user_id, request.project_id, request.session_id
|
||||
);
|
||||
|
||||
// 1. 验证必填字段
|
||||
// user_id 是 Option<String>,需要用 as_ref() 或直接检查
|
||||
if request.user_id.as_ref().map_or(true, |s| s.is_empty()) {
|
||||
return Err(AppError::with_i18n_key(
|
||||
ERR_VALIDATION,
|
||||
&get_i18n_message("error.user_id_required", locale),
|
||||
));
|
||||
}
|
||||
|
||||
if request.project_id.is_empty() {
|
||||
return Err(AppError::with_i18n_key(
|
||||
ERR_VALIDATION,
|
||||
&get_i18n_message("error.project_id_required", locale),
|
||||
));
|
||||
}
|
||||
|
||||
// 2. 查找 session_id (如果未提供,从 AGENT_REGISTRY 获取)
|
||||
let session_id = if let Some(sid) = request.session_id {
|
||||
sid
|
||||
} else {
|
||||
// 从 AGENT_REGISTRY 查找
|
||||
match AGENT_REGISTRY.get_agent_info(&request.project_id) {
|
||||
Some(info) => {
|
||||
// session_id 是 SessionId 类型,直接转换为 String
|
||||
info.session_id.to_string()
|
||||
}
|
||||
None => {
|
||||
// Agent 不存在,幂等返回成功
|
||||
info!(
|
||||
"ℹ️ [HTTP] Agent 不存在,幂等返回成功: project_id={}",
|
||||
request.project_id
|
||||
);
|
||||
let response = ComputerAgentCancelResponse {
|
||||
success: true,
|
||||
session_id: String::new(),
|
||||
};
|
||||
return Ok(Json(HttpResult::success(response)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 从 AGENT_REGISTRY 获取 Agent 信息并发送取消信号
|
||||
if let Some(agent_info) = AGENT_REGISTRY.get_agent_info(&request.project_id) {
|
||||
// 获取 cancel_tx
|
||||
let cancel_tx = agent_info.cancel_tx.clone();
|
||||
|
||||
// 释放读锁
|
||||
drop(agent_info);
|
||||
|
||||
// 检查是否已经空闲或停止中(幂等性)
|
||||
if cancel_tx.is_closed() {
|
||||
info!(
|
||||
"ℹ️ [HTTP] Agent stopped, cancel channel is closed: session_id={}",
|
||||
session_id
|
||||
);
|
||||
} else {
|
||||
// 创建取消通知
|
||||
let session_id_obj = SessionId::new(Arc::from(session_id.as_str()));
|
||||
let cancel_notification = CancelNotification::new(session_id_obj);
|
||||
|
||||
// 创建 oneshot channel 接收取消结果 (HTTP 不等待结果,直接丢弃)
|
||||
let (result_tx, _result_rx) = oneshot::channel();
|
||||
let cancel_request = CancelNotificationRequestWrapper {
|
||||
cancel_notification,
|
||||
result_tx,
|
||||
};
|
||||
|
||||
// 发送取消信号 (异步)
|
||||
match cancel_tx.send(cancel_request).await {
|
||||
Ok(_) => {
|
||||
info!("[HTTP] Cancel signal sent: session_id={}", session_id);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [HTTP] Failed to send cancel signal: session_id={}, error={}",
|
||||
session_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Session 不存在,幂等返回成功
|
||||
info!(
|
||||
"ℹ️ [HTTP] Agent not found, returning success idempotently: session_id={}",
|
||||
session_id
|
||||
);
|
||||
}
|
||||
|
||||
let response = ComputerAgentCancelResponse {
|
||||
success: true,
|
||||
session_id: session_id.clone(),
|
||||
};
|
||||
|
||||
Ok(Json(HttpResult::success(response)))
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Computer Chat Handler
|
||||
//!
|
||||
//! 处理 POST /computer/chat 请求
|
||||
|
||||
use axum::{extract::State, http::HeaderMap, Json};
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::http_server::router::AppState;
|
||||
use crate::service::AGENT_REGISTRY;
|
||||
use crate::service::chat_handler::{ChatHandlerContext, ChatHandlerInput, handle_chat_core};
|
||||
use crate::service::{SESSION_CACHE, SessionData};
|
||||
use dashmap::mapref::entry::Entry;
|
||||
use shared_types::{
|
||||
ChatResponse, ComputerChatRequest, HttpResult, I18nJsonOrQuery, ServiceType,
|
||||
error_codes::ERR_INTERNAL_SERVER_ERROR, error_codes::ERR_VALIDATION, get_i18n_message,
|
||||
};
|
||||
|
||||
use super::locale_from_headers;
|
||||
|
||||
/// 处理 Computer Agent Chat 请求
|
||||
///
|
||||
/// 直接调用 agent_runner 本地的 handle_chat_core(),无需 gRPC 转发
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/computer/chat",
|
||||
request_body = ComputerChatRequest,
|
||||
responses(
|
||||
(status = 200, description = "Chat request successful", body = HttpResult<ChatResponse>),
|
||||
(status = 400, description = "Bad request - missing user_id"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Computer Agent"
|
||||
)]
|
||||
pub async fn handle_computer_chat(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<ComputerChatRequest>,
|
||||
) -> Result<Json<HttpResult<ChatResponse>>, shared_types::AppError> {
|
||||
let locale = locale_from_headers(&headers);
|
||||
info!(
|
||||
"📨 [HTTP] Received Computer Chat request:\n\
|
||||
├─ user_id: {:?}\n\
|
||||
├─ project_id: {:?}\n\
|
||||
├─ session_id: {:?}\n\
|
||||
├─ request_id: {:?}\n\
|
||||
├─ prompt ({}chars): {:?}\n\
|
||||
├─ pod_id: {:?}\n\
|
||||
├─ tenant_id: {:?}\n\
|
||||
├─ space_id: {:?}\n\
|
||||
├─ isolation_type: {:?}\n\
|
||||
├─ attachments: {:?}\n\
|
||||
├─ data_source_attachments: {:?}\n\
|
||||
├─ model_provider: {:#?}\n\
|
||||
├─ agent_config: {:#?}\n\
|
||||
├─ system_prompt: {:?}\n\
|
||||
└─ user_prompt: {:?}",
|
||||
request.user_id,
|
||||
request.project_id,
|
||||
request.session_id,
|
||||
request.request_id,
|
||||
request.prompt.len(),
|
||||
request.prompt,
|
||||
request.pod_id,
|
||||
request.tenant_id,
|
||||
request.space_id,
|
||||
request.isolation_type,
|
||||
request.attachments,
|
||||
request.data_source_attachments,
|
||||
request.model_provider,
|
||||
request.agent_config,
|
||||
request.system_prompt,
|
||||
request.user_prompt
|
||||
);
|
||||
|
||||
// 1. 验证必填字段
|
||||
if request.user_id.is_empty() {
|
||||
let error_msg = get_i18n_message("error.user_id_required", locale);
|
||||
error!("[HTTP] {}", error_msg);
|
||||
return Err(shared_types::AppError::with_i18n_key(
|
||||
ERR_VALIDATION,
|
||||
&error_msg,
|
||||
));
|
||||
}
|
||||
|
||||
let user_id = request.user_id.clone();
|
||||
|
||||
// 2. 生成或使用提供的 project_id (直接用 UUID,去掉连字符,与 rcoder 保持一致)
|
||||
let project_id = request
|
||||
.project_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string().replace('-', ""));
|
||||
|
||||
// 3. 自动查找现有 session_id (如果未提供)
|
||||
let session_id = request.session_id.or_else(|| {
|
||||
AGENT_REGISTRY
|
||||
.get_agent_info(&project_id)
|
||||
.map(|info| info.session_id.to_string())
|
||||
});
|
||||
|
||||
// 4. 创建项目工作目录(使用配置中的 projects_dir,支持外部配置)
|
||||
// Docker 挂载:宿主机 /computer-project-workspace/{user_id} → 容器 /home/user
|
||||
// Agent 工作目录:/home/user/{project_id}
|
||||
let project_dir = state.config.projects_dir.join(&project_id);
|
||||
|
||||
if let Err(e) = tokio::fs::create_dir_all(&project_dir).await {
|
||||
let error_msg = format!(
|
||||
"{}: {}",
|
||||
get_i18n_message("error.project_dir_create_failed", locale),
|
||||
e
|
||||
);
|
||||
error!("[HTTP] {}", error_msg);
|
||||
return Err(shared_types::AppError::with_message(
|
||||
ERR_INTERNAL_SERVER_ERROR,
|
||||
&error_msg,
|
||||
));
|
||||
}
|
||||
|
||||
// 5. 生成或使用提供的 request_id
|
||||
let request_id = request
|
||||
.request_id
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
|
||||
// 6. 构建 ChatHandlerInput
|
||||
let input = ChatHandlerInput {
|
||||
project_id: project_id.clone(),
|
||||
project_dir,
|
||||
session_id,
|
||||
prompt: request.prompt,
|
||||
request_id: request_id.clone(),
|
||||
attachments: request.attachments,
|
||||
data_source_attachments: request.data_source_attachments,
|
||||
model_config: request.model_provider,
|
||||
service_type: ServiceType::ComputerAgentRunner,
|
||||
agent_config_override: request.agent_config,
|
||||
system_prompt_override: request.system_prompt,
|
||||
user_prompt_template_override: request.user_prompt,
|
||||
skip_slot_limit: true, // HTTP Server 部署,跳过槽位限制
|
||||
};
|
||||
|
||||
// 7. 构建 ChatHandlerContext
|
||||
let context = ChatHandlerContext {
|
||||
agent_runtime: state.agent_runtime.clone(),
|
||||
shared_api_key_manager: state.shared_api_key_manager.clone(),
|
||||
project_uuid_map: state.project_uuid_map.clone(),
|
||||
};
|
||||
|
||||
// 8. 调用核心 Chat 处理逻辑
|
||||
let output = handle_chat_core(input, &context).await;
|
||||
|
||||
// 🔧 关键修复:将 session 写入 SESSION_CACHE(SSE 进度流需要从这里读取)
|
||||
let session_id_str = output.session_id.clone();
|
||||
match SESSION_CACHE.entry(session_id_str.clone()) {
|
||||
Entry::Occupied(entry) => {
|
||||
info!(
|
||||
"[HTTP] SESSION_CACHE already exists, reusing: session_id={}",
|
||||
session_id_str
|
||||
);
|
||||
entry.get().clone()
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
let data = SessionData::new(1000);
|
||||
info!(
|
||||
"[HTTP] SESSION_CACHE created: session_id={}",
|
||||
session_id_str
|
||||
);
|
||||
entry.insert(data.clone());
|
||||
data
|
||||
}
|
||||
};
|
||||
|
||||
// 9. 构建响应
|
||||
let response = ChatResponse {
|
||||
project_id: output.project_id.clone(),
|
||||
session_id: output.session_id.clone(),
|
||||
error: output.error.clone(),
|
||||
request_id: Some(request_id),
|
||||
need_fallback: None,
|
||||
fallback_reason: None,
|
||||
};
|
||||
|
||||
// 10. 根据执行结果返回成功或错误
|
||||
if output.error.is_some() || !output.success {
|
||||
error!(
|
||||
"❌ [HTTP] Computer Chat failed: session_id={}, error={:?}",
|
||||
response.session_id, response.error
|
||||
);
|
||||
// 返回成功的 HTTP 状态码,但 HttpResult 包含错误信息
|
||||
// 这与 rcoder 的行为一致:HTTP 200 + HttpResult.error
|
||||
return Ok(Json(HttpResult::error(
|
||||
output
|
||||
.error_code
|
||||
.as_deref()
|
||||
.unwrap_or(ERR_INTERNAL_SERVER_ERROR),
|
||||
&shared_types::get_error_message(
|
||||
output
|
||||
.error_code
|
||||
.as_deref()
|
||||
.unwrap_or(ERR_INTERNAL_SERVER_ERROR),
|
||||
locale,
|
||||
),
|
||||
)));
|
||||
}
|
||||
|
||||
info!(
|
||||
"✅ [HTTP] Computer Chat response: session_id={}, error={:?}",
|
||||
response.session_id, response.error
|
||||
);
|
||||
|
||||
Ok(Json(HttpResult::success(response)))
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Computer Agent Progress Handler
|
||||
//!
|
||||
//! 处理 GET /computer/progress/{session_id} 请求 (SSE 流)
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use futures_util::stream::{Stream, StreamExt};
|
||||
use std::convert::Infallible;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::http_server::router::AppState;
|
||||
use crate::service::{AGENT_REGISTRY, SESSION_CACHE};
|
||||
use shared_types::{
|
||||
AgentStatus, HttpResult, SessionMessageType, UnifiedSessionMessage,
|
||||
error_codes::{ERR_INTERNAL_SERVER_ERROR, ERR_SESSION_NOT_FOUND},
|
||||
get_i18n_message,
|
||||
};
|
||||
|
||||
use super::locale_from_headers;
|
||||
|
||||
/// 统一的 SSE 流类型
|
||||
type SseStream = Pin<Box<dyn Stream<Item = Result<Event, Infallible>> + Send>>;
|
||||
|
||||
/// 检查 Agent 是否处于 idle 状态
|
||||
///
|
||||
/// 返回 true 如果:
|
||||
/// - Agent 不存在,或
|
||||
/// - Agent 状态为 Idle,或
|
||||
/// - Agent 的 session_id 与当前请求的 session_id 不匹配
|
||||
async fn is_agent_idle(session_id: &str) -> bool {
|
||||
// 通过 session_id 获取 agent_info
|
||||
if let Some(info) = AGENT_REGISTRY.get_agent_info_by_session(session_id) {
|
||||
// 检查状态是否为 Idle,或者 session_id 是否匹配
|
||||
info.status == AgentStatus::Idle || info.session_id.to_string() != session_id
|
||||
} else {
|
||||
// Agent 不存在,视为 idle
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 Agent idle 时的结束事件
|
||||
///
|
||||
/// 当 Agent 处于闲置状态时,发送此事件通知前端没有正在执行的任务
|
||||
fn create_idle_end_event(session_id: &str) -> Event {
|
||||
let unified_message = UnifiedSessionMessage {
|
||||
session_id: session_id.to_string(),
|
||||
message_type: SessionMessageType::SessionPromptEnd,
|
||||
sub_type: "end_turn".to_string(),
|
||||
data: serde_json::json!({
|
||||
"reason": "EndTurn",
|
||||
"description": "Agent 当前无在执行任务"
|
||||
}),
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
|
||||
let json_data = match serde_json::to_string(&unified_message) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [HTTP] 序列化 SessionPromptEnd 消息失败: session_id={}, error={}",
|
||||
session_id, e
|
||||
);
|
||||
// 返回包含 session_id 的最小可用结构
|
||||
format!(
|
||||
r#"{{"sessionId":"{}","messageType":"sessionPromptEnd","subType":"end_turn","data":{{"reason":"EndTurn","description":"Agent 当前无在执行任务"}}}}"#,
|
||||
session_id
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Event::default().event("end_turn").data(json_data)
|
||||
}
|
||||
|
||||
/// 创建心跳消息流
|
||||
///
|
||||
/// 定期发送符合 UnifiedSessionMessage 格式的心跳消息
|
||||
fn create_heartbeat_stream(
|
||||
session_id: String,
|
||||
) -> impl Stream<Item = Result<Event, Infallible>> + Send {
|
||||
let heartbeat_interval = Duration::from_secs(15);
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(10);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(heartbeat_interval);
|
||||
// 立即发送第一个心跳,然后按间隔发送
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// 创建心跳消息
|
||||
let heartbeat_msg = UnifiedSessionMessage::heartbeat(session_id.clone());
|
||||
let json_str = match serde_json::to_string(&heartbeat_msg) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("[HTTP] Failed to serialize heartbeat message: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 sub_type ("ping") 作为事件名
|
||||
if tx
|
||||
.send(Ok(Event::default().event("ping").data(json_str)))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
// 接收端已关闭,停止发送心跳
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ReceiverStream::new(rx)
|
||||
}
|
||||
|
||||
/// Computer Agent 进度流 (SSE)
|
||||
///
|
||||
/// 直接从 SESSION_CACHE 订阅消息流,无需 gRPC
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/computer/progress/{session_id}",
|
||||
responses(
|
||||
(status = 200, description = "SSE progress stream", content_type = "text/event-stream"),
|
||||
(status = 404, description = "Session not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Computer Agent"
|
||||
)]
|
||||
pub async fn handle_computer_progress(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(session_id): Path<String>,
|
||||
) -> Result<Sse<SseStream>, (StatusCode, Json<HttpResult<String>>)> {
|
||||
let locale = locale_from_headers(&headers);
|
||||
info!(
|
||||
"📡 [HTTP] Computer Agent progress stream subscribed: session_id={}",
|
||||
session_id
|
||||
);
|
||||
|
||||
// 0. 检查 Agent 状态(必须在 SESSION_CACHE 查找之前检查)
|
||||
if is_agent_idle(&session_id).await {
|
||||
info!(
|
||||
"💤 [HTTP] Agent is idle, sending SessionPromptEnd and closing: session_id={}",
|
||||
session_id
|
||||
);
|
||||
let end_event = create_idle_end_event(&session_id);
|
||||
let stream: SseStream = Box::pin(futures_util::stream::iter([Ok(end_event)]));
|
||||
return Ok(Sse::new(stream));
|
||||
}
|
||||
|
||||
// 1. 从 SESSION_CACHE 获取 session_data
|
||||
// 🛡️ 关键修复:先 clone Arc<SessionData>,立即释放 DashMap shard 读锁
|
||||
// 之前直接在 Ref 上调用 create_new_connection().await,导致 DashMap 读锁跨 await 持有
|
||||
// 可能造成与 SESSION_CACHE.entry()/remove() 等写操作的死锁
|
||||
let session_data = match SESSION_CACHE.get(&session_id) {
|
||||
Some(data) => data.value().clone(),
|
||||
None => {
|
||||
warn!(" [HTTP] Session not found: session_id={}", session_id);
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(HttpResult::error_with_message(
|
||||
ERR_SESSION_NOT_FOUND,
|
||||
locale,
|
||||
&format!(
|
||||
"{}: {}",
|
||||
get_i18n_message("error.session_not_found", locale),
|
||||
session_id
|
||||
),
|
||||
)),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 创建新的消息订阅(DashMap 锁已释放,此处 await 安全)
|
||||
let (message_rx, _cancel_token) = match session_data.create_new_connection(1000).await {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ [HTTP] Failed to create session connection: session_id={}, error={}",
|
||||
session_id, e
|
||||
);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(HttpResult::error_with_message(
|
||||
ERR_INTERNAL_SERVER_ERROR,
|
||||
locale,
|
||||
&format!(
|
||||
"{}: {}",
|
||||
get_i18n_message("error.internal_server_error", locale),
|
||||
e
|
||||
),
|
||||
)),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 创建消息流和心跳流
|
||||
// UnifiedSessionMessage 已使用 #[serde(rename_all = "camelCase")], 序列化后符合 RCoder 约定
|
||||
let message_stream = ReceiverStream::new(message_rx).map(|msg| {
|
||||
let is_terminal = matches!(msg.message_type, SessionMessageType::SessionPromptEnd);
|
||||
let json_str = match serde_json::to_string(&msg) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("[HTTP] Failed to serialize message: {}", e);
|
||||
return (Ok(Event::default().data("{}")), false);
|
||||
}
|
||||
};
|
||||
(Ok(Event::default().event(msg.sub_type).data(json_str)), is_terminal)
|
||||
});
|
||||
|
||||
// 4. 创建心跳流(标记为非终端)
|
||||
let heartbeat_stream = create_heartbeat_stream(session_id.clone())
|
||||
.map(|event| (event, false));
|
||||
|
||||
// 5. 合并两个流,并用 scan 监测终止条件
|
||||
// select 会继续轮询心跳流(永不结束),所以必须在合并流层面检测终止
|
||||
// 终止条件:
|
||||
// - 收到 SessionPromptEnd(终端消息)→ 发送后结束流
|
||||
// - channel 关闭 → message_stream 返回 None,scan 最终也会结束
|
||||
let merged_stream = futures_util::stream::select(message_stream, heartbeat_stream)
|
||||
.scan(false, |seen_terminal, (event, is_terminal)| {
|
||||
if *seen_terminal {
|
||||
// 已发送终端消息,结束流
|
||||
return std::future::ready(None);
|
||||
}
|
||||
|
||||
if is_terminal {
|
||||
*seen_terminal = true;
|
||||
}
|
||||
|
||||
std::future::ready(Some(event))
|
||||
});
|
||||
|
||||
info!("[HTTP] SSE stream established: session_id={}", session_id);
|
||||
|
||||
let stream: SseStream = Box::pin(merged_stream);
|
||||
Ok(Sse::new(stream))
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Computer Agent Status Handler
|
||||
//!
|
||||
//! 处理 POST /computer/agent/status 请求
|
||||
|
||||
use axum::{extract::State, http::HeaderMap, Json};
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::http_server::router::AppState;
|
||||
use crate::service::AGENT_REGISTRY;
|
||||
use shared_types::{
|
||||
ComputerAgentStatusRequest, ComputerAgentStatusResponse, HttpResult, I18nJsonOrQuery,
|
||||
error_codes::ERR_VALIDATION, get_i18n_message,
|
||||
};
|
||||
|
||||
use super::locale_from_headers;
|
||||
|
||||
/// 查询 Computer Agent 状态
|
||||
///
|
||||
/// 直接使用 AGENT_REGISTRY 查询,无需 gRPC 调用
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/computer/agent/status",
|
||||
request_body = ComputerAgentStatusRequest,
|
||||
responses(
|
||||
(status = 200, description = "Status query successful", body = HttpResult<ComputerAgentStatusResponse>),
|
||||
(status = 400, description = "Bad request - missing fields"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Computer Agent"
|
||||
)]
|
||||
pub async fn handle_computer_status(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<ComputerAgentStatusRequest>,
|
||||
) -> Result<Json<HttpResult<ComputerAgentStatusResponse>>, shared_types::AppError> {
|
||||
let locale = locale_from_headers(&headers);
|
||||
|
||||
// 使用 garde 进行字段校验
|
||||
let I18nJsonOrQuery(request) = I18nJsonOrQuery(request).validate_into_app_error()?;
|
||||
let project_id = request.project_id.as_ref().expect("validated: project_id is required and non-empty");
|
||||
|
||||
// 验证 user_id 或 project_id 至少有一个
|
||||
let user_id_empty = request.user_id.as_ref().map_or(true, |s| s.is_empty());
|
||||
if user_id_empty && project_id.is_empty() {
|
||||
return Err(shared_types::AppError::with_i18n_key(
|
||||
ERR_VALIDATION,
|
||||
&get_i18n_message("error.user_id_or_project_id_required", locale),
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
"🔍 [HTTP] Computer Agent 状态查询: user_id={:?}, project_id={}, pod_id={:?}, tenant_id={:?}, space_id={:?}, isolation_type={:?}",
|
||||
request.user_id, project_id, request.pod_id, request.tenant_id, request.space_id, request.isolation_type
|
||||
);
|
||||
|
||||
// 从 AGENT_REGISTRY 查询 Agent 状态
|
||||
let agent_info = AGENT_REGISTRY.get_agent_info(project_id);
|
||||
|
||||
let response = match agent_info {
|
||||
Some(info) => {
|
||||
// Agent 存在且活跃
|
||||
info!(
|
||||
"✅ [HTTP] Agent status: project_id={}, is_alive=true, session_id={:?}",
|
||||
project_id, info.session_id
|
||||
);
|
||||
|
||||
ComputerAgentStatusResponse {
|
||||
user_id: request.user_id.clone(),
|
||||
project_id: project_id.to_string(),
|
||||
is_alive: true,
|
||||
session_id: Some(info.session_id.to_string()),
|
||||
status: Some(format!("{:?}", info.status)),
|
||||
last_activity: Some(info.last_activity),
|
||||
created_at: Some(info.created_at),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Agent 不存在
|
||||
warn!(" [HTTP] Agent not found: project_id={}", project_id);
|
||||
|
||||
ComputerAgentStatusResponse {
|
||||
user_id: request.user_id.clone(),
|
||||
project_id: project_id.to_string(),
|
||||
is_alive: false,
|
||||
session_id: None,
|
||||
status: None,
|
||||
last_activity: None,
|
||||
created_at: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(HttpResult::success(response)))
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Computer Agent Stop Handler
|
||||
//!
|
||||
//! 处理 POST /computer/agent/stop 请求
|
||||
|
||||
use axum::{extract::State, http::HeaderMap, Json};
|
||||
use agent_client_protocol::schema::{CancelNotification, SessionId};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::CancelNotificationRequestWrapper;
|
||||
use crate::http_server::router::AppState;
|
||||
use crate::service::AGENT_REGISTRY;
|
||||
use shared_types::{
|
||||
ComputerAgentStopRequest, ComputerAgentStopResponse, HttpResult, I18nJsonOrQuery,
|
||||
error_codes::{ERR_VALIDATION, SUCCESS},
|
||||
get_error_message, get_i18n_message,
|
||||
};
|
||||
|
||||
use super::locale_from_headers;
|
||||
|
||||
/// 停止 Computer Agent
|
||||
///
|
||||
/// 1. 发送取消信号停止正在运行的任务
|
||||
/// 2. 从 AGENT_REGISTRY 移除 Agent 状态
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/computer/agent/stop",
|
||||
request_body = ComputerAgentStopRequest,
|
||||
responses(
|
||||
(status = 200, description = "Stop request successful", body = HttpResult<ComputerAgentStopResponse>),
|
||||
(status = 400, description = "Bad request - missing fields"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Computer Agent"
|
||||
)]
|
||||
pub async fn handle_computer_stop(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<ComputerAgentStopRequest>,
|
||||
) -> Result<Json<HttpResult<ComputerAgentStopResponse>>, shared_types::AppError> {
|
||||
let locale = locale_from_headers(&headers);
|
||||
|
||||
// 使用 garde 进行字段校验
|
||||
let I18nJsonOrQuery(request) = I18nJsonOrQuery(request).validate_into_app_error()?;
|
||||
let project_id = request.project_id.as_ref().expect("validated: project_id is required and non-empty");
|
||||
|
||||
// 验证 user_id 或 project_id 至少有一个
|
||||
let user_id_empty = request.user_id.as_ref().map_or(true, |s| s.is_empty());
|
||||
if user_id_empty && project_id.is_empty() {
|
||||
return Err(shared_types::AppError::with_i18n_key(
|
||||
ERR_VALIDATION,
|
||||
&get_i18n_message("error.user_id_or_project_id_required", locale),
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
"🛑 [HTTP] Computer Agent 停止请求: user_id={:?}, project_id={}, pod_id={:?}, tenant_id={:?}, space_id={:?}, isolation_type={:?}",
|
||||
request.user_id, project_id, request.pod_id, request.tenant_id, request.space_id, request.isolation_type
|
||||
);
|
||||
|
||||
// 获取 Agent 信息并发送取消信号
|
||||
let (success, message) =
|
||||
if let Some(agent_info) = AGENT_REGISTRY.get_agent_info(project_id) {
|
||||
let session_id = agent_info.session_id.to_string();
|
||||
let cancel_tx = agent_info.cancel_tx.clone();
|
||||
|
||||
// 释放读锁
|
||||
drop(agent_info);
|
||||
|
||||
// 发送取消信号(如果 channel 仍然打开)
|
||||
if !cancel_tx.is_closed() {
|
||||
let session_id_obj = SessionId::new(Arc::from(session_id.as_str()));
|
||||
let cancel_notification = CancelNotification::new(session_id_obj);
|
||||
|
||||
let (result_tx, _result_rx) = oneshot::channel();
|
||||
let cancel_request = CancelNotificationRequestWrapper {
|
||||
cancel_notification,
|
||||
result_tx,
|
||||
};
|
||||
|
||||
match cancel_tx.send(cancel_request).await {
|
||||
Ok(_) => {
|
||||
info!("[HTTP] Cancel signal sent: session_id={}", session_id);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [HTTP] Failed to send cancel signal: session_id={}, error={}",
|
||||
session_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从 AGENT_REGISTRY 移除 Agent
|
||||
let removed = AGENT_REGISTRY
|
||||
.remove_by_project(project_id)
|
||||
.is_some();
|
||||
|
||||
if removed {
|
||||
info!("[HTTP] Agent stopped: project_id={}", project_id);
|
||||
(true, get_error_message(SUCCESS, locale))
|
||||
} else {
|
||||
// 可能在取消期间已被清理
|
||||
info!(
|
||||
"ℹ️ [HTTP] Agent already cleaned up: project_id={}",
|
||||
project_id
|
||||
);
|
||||
(
|
||||
true,
|
||||
get_i18n_message("success.agent_already_stopped", locale),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Agent 不存在,幂等返回成功
|
||||
info!(
|
||||
"ℹ️ [HTTP] Agent not found, returning success idempotently: project_id={}",
|
||||
project_id
|
||||
);
|
||||
(
|
||||
true,
|
||||
get_i18n_message("success.agent_already_stopped", locale),
|
||||
)
|
||||
};
|
||||
|
||||
let response = ComputerAgentStopResponse {
|
||||
success,
|
||||
message,
|
||||
user_id: request.user_id.clone(),
|
||||
pod_id: None,
|
||||
project_id: project_id.to_string(),
|
||||
};
|
||||
|
||||
Ok(Json(HttpResult::success(response)))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! HTTP 请求处理器
|
||||
//!
|
||||
//! 仅在 `http-server` feature 启用时编译
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
pub mod computer_cancel;
|
||||
pub mod computer_chat;
|
||||
pub mod computer_progress;
|
||||
pub mod computer_status;
|
||||
pub mod computer_stop;
|
||||
pub mod rcoder_progress; // RCoder 模式的 SSE 进度流
|
||||
|
||||
pub(super) fn locale_from_headers(headers: &HeaderMap) -> &'static str {
|
||||
let accept_language = headers.get("accept-language").and_then(|v| v.to_str().ok());
|
||||
shared_types::parse_accept_language(accept_language)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! RCoder Agent Progress Handler
|
||||
//!
|
||||
//! 处理 GET /agent/progress/{session_id} 请求 (SSE 流)
|
||||
//!
|
||||
//! 与 computer_progress.rs 使用相同的 SSE 流模式,直接从 SESSION_CACHE 订阅消息
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::{HeaderMap, StatusCode},
|
||||
Json,
|
||||
response::sse::{Event, Sse},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use futures_util::stream::{Stream, StreamExt};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::service::{AGENT_REGISTRY, SESSION_CACHE};
|
||||
use shared_types::{
|
||||
AgentStatus, HttpResult, SessionMessageType, UnifiedSessionMessage,
|
||||
error_codes::{ERR_INTERNAL_SERVER_ERROR, ERR_SESSION_NOT_FOUND},
|
||||
get_i18n_message,
|
||||
};
|
||||
|
||||
use super::locale_from_headers;
|
||||
|
||||
/// 统一的 SSE 流类型
|
||||
type SseStream = Pin<Box<dyn Stream<Item = Result<Event, Infallible>> + Send>>;
|
||||
|
||||
/// 检查 Agent 是否处于 idle 状态
|
||||
///
|
||||
/// 返回 true 如果:
|
||||
/// - Agent 不存在,或
|
||||
/// - Agent 状态为 Idle,或
|
||||
/// - Agent 的 session_id 与当前请求的 session_id 不匹配
|
||||
async fn is_agent_idle(session_id: &str) -> bool {
|
||||
// 通过 session_id 获取 agent_info
|
||||
if let Some(info) = AGENT_REGISTRY.get_agent_info_by_session(session_id) {
|
||||
// 检查状态是否为 Idle,或者 session_id 是否匹配
|
||||
info.status == AgentStatus::Idle || info.session_id.to_string() != session_id
|
||||
} else {
|
||||
// Agent 不存在,视为 idle
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 Agent idle 时的结束事件
|
||||
///
|
||||
/// 当 Agent 处于闲置状态时,发送此事件通知前端没有正在执行的任务
|
||||
fn create_idle_end_event(session_id: &str) -> Event {
|
||||
let unified_message = UnifiedSessionMessage {
|
||||
session_id: session_id.to_string(),
|
||||
message_type: SessionMessageType::SessionPromptEnd,
|
||||
sub_type: "end_turn".to_string(),
|
||||
data: serde_json::json!({
|
||||
"reason": "EndTurn",
|
||||
"description": "Agent 当前无在执行任务"
|
||||
}),
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
|
||||
let json_data = match serde_json::to_string(&unified_message) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [RCoder] 序列化 SessionPromptEnd 消息失败: session_id={}, error={}",
|
||||
session_id, e
|
||||
);
|
||||
// 返回包含 session_id 的最小可用结构
|
||||
format!(
|
||||
r#"{{"sessionId":"{}","messageType":"sessionPromptEnd","subType":"end_turn","data":{{"reason":"EndTurn","description":"Agent 当前无在执行任务"}}}}"#,
|
||||
session_id
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Event::default().event("end_turn").data(json_data)
|
||||
}
|
||||
|
||||
/// 创建心跳消息流
|
||||
///
|
||||
/// 定期发送符合 UnifiedSessionMessage 格式的心跳消息
|
||||
fn create_heartbeat_stream(
|
||||
session_id: String,
|
||||
) -> impl Stream<Item = Result<Event, Infallible>> + Send {
|
||||
let heartbeat_interval = Duration::from_secs(15);
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(10);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(heartbeat_interval);
|
||||
// 立即发送第一个心跳,然后按间隔发送
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// 创建心跳消息
|
||||
let heartbeat_msg = UnifiedSessionMessage::heartbeat(session_id.clone());
|
||||
let json_str = match serde_json::to_string(&heartbeat_msg) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("[RCoder] Failed to serialize heartbeat message: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 sub_type ("ping") 作为事件名
|
||||
if tx
|
||||
.send(Ok(Event::default().event("ping").data(json_str)))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
// 接收端已关闭,停止发送心跳
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ReceiverStream::new(rx)
|
||||
}
|
||||
|
||||
/// RCoder Agent 进度流 (SSE)
|
||||
///
|
||||
/// 直接从 SESSION_CACHE 订阅消息流,无需 gRPC
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/agent/progress/{session_id}",
|
||||
params(
|
||||
("session_id" = String, Path, description = "会话ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "SSE progress stream", content_type = "text/event-stream"),
|
||||
(status = 404, description = "Session not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "RCoder Agent"
|
||||
)]
|
||||
pub async fn handle_rcoder_progress(
|
||||
headers: HeaderMap,
|
||||
Path(session_id): Path<String>,
|
||||
) -> Result<Sse<SseStream>, (StatusCode, Json<HttpResult<String>>)> {
|
||||
let locale = locale_from_headers(&headers);
|
||||
info!(
|
||||
"📡 [RCoder] Progress stream subscribed: session_id={}",
|
||||
session_id
|
||||
);
|
||||
|
||||
// 0. 检查 Agent 状态(必须在 SESSION_CACHE 查找之前检查)
|
||||
if is_agent_idle(&session_id).await {
|
||||
info!(
|
||||
"💤 [RCoder] Agent is idle, sending SessionPromptEnd and closing: session_id={}",
|
||||
session_id
|
||||
);
|
||||
let end_event = create_idle_end_event(&session_id);
|
||||
let stream: SseStream = Box::pin(futures_util::stream::iter([Ok(end_event)]));
|
||||
return Ok(Sse::new(stream));
|
||||
}
|
||||
|
||||
// 1. 从 SESSION_CACHE 获取 session_data
|
||||
// 🛡️ 关键:先 clone Arc<SessionData>,立即释放 DashMap shard 读锁
|
||||
let session_data = match SESSION_CACHE.get(&session_id) {
|
||||
Some(data) => data.value().clone(),
|
||||
None => {
|
||||
warn!(" [RCoder] Session not found: session_id={}", session_id);
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(HttpResult::error_with_message(
|
||||
ERR_SESSION_NOT_FOUND,
|
||||
locale,
|
||||
&format!(
|
||||
"{}: {}",
|
||||
get_i18n_message("error.session_not_found", locale),
|
||||
session_id
|
||||
),
|
||||
)),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 创建新的消息订阅(DashMap 锁已释放,此处 await 安全)
|
||||
let (message_rx, _cancel_token) = match session_data.create_new_connection(1000).await {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ [RCoder] Failed to create session connection: session_id={}, error={}",
|
||||
session_id, e
|
||||
);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(HttpResult::error_with_message(
|
||||
ERR_INTERNAL_SERVER_ERROR,
|
||||
locale,
|
||||
&format!(
|
||||
"{}: {}",
|
||||
get_i18n_message("error.internal_server_error", locale),
|
||||
e
|
||||
),
|
||||
)),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 创建消息流和心跳流
|
||||
let message_stream = ReceiverStream::new(message_rx).map(|msg| {
|
||||
let is_terminal = matches!(msg.message_type, SessionMessageType::SessionPromptEnd);
|
||||
let json_str = match serde_json::to_string(&msg) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("[RCoder] Failed to serialize message: {}", e);
|
||||
return (Ok(Event::default().data("{}")), false);
|
||||
}
|
||||
};
|
||||
(Ok(Event::default().event(msg.sub_type).data(json_str)), is_terminal)
|
||||
});
|
||||
|
||||
// 4. 创建心跳流(标记为非终端)
|
||||
let heartbeat_stream = create_heartbeat_stream(session_id.clone())
|
||||
.map(|event| (event, false));
|
||||
|
||||
// 5. 合并两个流,并用 scan 监测终止条件
|
||||
// select 会继续轮询心跳流(永不结束),所以必须在合并流层面检测终止
|
||||
// 终止条件:
|
||||
// - 收到 SessionPromptEnd(终端消息)→ 发送后结束流
|
||||
// - channel 关闭 → message_stream 返回 None,scan 最终也会结束
|
||||
let merged_stream = futures_util::stream::select(message_stream, heartbeat_stream)
|
||||
.scan(false, |seen_terminal, (event, is_terminal)| {
|
||||
if *seen_terminal {
|
||||
// 已发送终端消息,结束流
|
||||
return std::future::ready(None);
|
||||
}
|
||||
|
||||
if is_terminal {
|
||||
*seen_terminal = true;
|
||||
}
|
||||
|
||||
std::future::ready(Some(event))
|
||||
});
|
||||
|
||||
info!("[RCoder] SSE stream established: session_id={}", session_id);
|
||||
|
||||
let stream: SseStream = Box::pin(merged_stream);
|
||||
Ok(Sse::new(stream))
|
||||
}
|
||||
11
qiming-rcoder/crates/agent_runner/src/http_server/mod.rs
Normal file
11
qiming-rcoder/crates/agent_runner/src/http_server/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! HTTP 服务器模块
|
||||
//!
|
||||
//! 仅在 `http-server` feature 启用时编译
|
||||
//! 提供 Computer Agent HTTP REST API
|
||||
|
||||
pub mod handlers;
|
||||
pub mod router;
|
||||
pub mod start;
|
||||
|
||||
pub use router::{AppState, create_router};
|
||||
pub use start::{HttpServerConfig, start_http_server};
|
||||
186
qiming-rcoder/crates/agent_runner/src/http_server/router.rs
Normal file
186
qiming-rcoder/crates/agent_runner/src/http_server/router.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
//! HTTP 路由定义
|
||||
//!
|
||||
//! 定义所有 HTTP 端点和路由
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use tower_http::limit::RequestBodyLimitLayer;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
use crate::agent_runtime::AgentRuntime;
|
||||
use crate::api_key_manager::ApiKeyManager;
|
||||
use crate::config::AppConfig;
|
||||
use crate::service::local_agent_service::LocalAgentHttpService;
|
||||
|
||||
/// HTTP 应用状态
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: AppConfig,
|
||||
pub agent_runtime: Arc<AgentRuntime>,
|
||||
pub api_key_manager: Arc<ApiKeyManager>,
|
||||
pub shared_api_key_manager: Arc<DashMap<String, shared_types::ModelProviderConfig>>,
|
||||
pub project_uuid_map: Arc<DashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(
|
||||
config: AppConfig,
|
||||
agent_runtime: Arc<AgentRuntime>,
|
||||
shared_api_key_manager: Arc<DashMap<String, shared_types::ModelProviderConfig>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
agent_runtime: agent_runtime.clone(),
|
||||
api_key_manager: Arc::new(ApiKeyManager::from_shared(shared_api_key_manager.clone())),
|
||||
shared_api_key_manager,
|
||||
project_uuid_map: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 LocalAgentHttpService 实例用于 RCoder 模式
|
||||
pub fn create_local_agent_service(&self) -> Arc<LocalAgentHttpService> {
|
||||
Arc::new(LocalAgentHttpService::new(
|
||||
self.agent_runtime.clone(),
|
||||
self.shared_api_key_manager.clone(),
|
||||
self.project_uuid_map.clone(),
|
||||
self.config.projects_dir.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 HTTP 路由
|
||||
///
|
||||
/// 组合 Computer Agent 路由和 RCoder Agent 路由
|
||||
pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
use super::handlers::{
|
||||
computer_cancel, computer_chat, computer_progress, computer_status, computer_stop,
|
||||
rcoder_progress,
|
||||
};
|
||||
use shared_types::http_handlers;
|
||||
|
||||
// 创建 LocalAgentHttpService 实例
|
||||
let local_agent_service = state.create_local_agent_service();
|
||||
|
||||
// Computer Agent 路由
|
||||
let computer_routes = Router::new()
|
||||
.route("/computer/chat", post(computer_chat::handle_computer_chat))
|
||||
.route(
|
||||
"/computer/agent/stop",
|
||||
post(computer_stop::handle_computer_stop),
|
||||
)
|
||||
.route(
|
||||
"/computer/agent/status",
|
||||
post(computer_status::handle_computer_status),
|
||||
)
|
||||
.route(
|
||||
"/computer/agent/session/cancel",
|
||||
post(computer_cancel::handle_computer_cancel),
|
||||
)
|
||||
.route(
|
||||
"/computer/progress/{session_id}",
|
||||
get(computer_progress::handle_computer_progress),
|
||||
)
|
||||
.with_state(state.clone());
|
||||
|
||||
// RCoder Agent 路由(使用 LocalAgentHttpService)
|
||||
let rcoder_routes = Router::new()
|
||||
.route("/chat", post(http_handlers::handle_chat::<LocalAgentHttpService>))
|
||||
.route(
|
||||
"/agent/session/cancel",
|
||||
post(http_handlers::handle_cancel::<LocalAgentHttpService>),
|
||||
)
|
||||
.route("/agent/stop", post(http_handlers::handle_stop::<LocalAgentHttpService>))
|
||||
.route(
|
||||
"/agent/status/{project_id}",
|
||||
get(http_handlers::handle_status::<LocalAgentHttpService>),
|
||||
)
|
||||
.route(
|
||||
"/agent/progress/{session_id}",
|
||||
get(rcoder_progress::handle_rcoder_progress),
|
||||
)
|
||||
.with_state(local_agent_service);
|
||||
|
||||
// 通用路由
|
||||
let api_routes = Router::new()
|
||||
.route("/health", get(health_check))
|
||||
.with_state(state.clone());
|
||||
|
||||
// 组合路由
|
||||
Router::new()
|
||||
.merge(computer_routes)
|
||||
.merge(rcoder_routes)
|
||||
.merge(api_routes)
|
||||
.merge(create_swagger_ui())
|
||||
.layer(RequestBodyLimitLayer::new(50 * 1024 * 1024)) // 🔥 50MB body 限制
|
||||
}
|
||||
|
||||
/// 健康检查端点
|
||||
///
|
||||
/// 检查服务的健康状态
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
responses(
|
||||
(status = 200, description = "服务健康状态", body = shared_types::HealthResponse)
|
||||
),
|
||||
tag = "system"
|
||||
)]
|
||||
pub async fn health_check() -> Json<shared_types::HealthResponse> {
|
||||
Json(shared_types::HealthResponse::new("agent-runner"))
|
||||
}
|
||||
|
||||
/// 创建 Swagger UI
|
||||
fn create_swagger_ui() -> SwaggerUi {
|
||||
use super::handlers::{
|
||||
computer_cancel::__path_handle_computer_cancel, computer_chat::__path_handle_computer_chat,
|
||||
computer_progress::__path_handle_computer_progress,
|
||||
computer_status::__path_handle_computer_status, computer_stop::__path_handle_computer_stop,
|
||||
};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
// Computer Agent 端点
|
||||
handle_computer_chat,
|
||||
handle_computer_status,
|
||||
handle_computer_stop,
|
||||
handle_computer_cancel,
|
||||
handle_computer_progress,
|
||||
// 健康检查
|
||||
health_check,
|
||||
),
|
||||
components(schemas(
|
||||
// Computer Agent 类型
|
||||
shared_types::ComputerChatRequest,
|
||||
shared_types::ChatResponse,
|
||||
shared_types::ComputerAgentStatusRequest,
|
||||
shared_types::ComputerAgentStatusResponse,
|
||||
shared_types::ComputerAgentStopRequest,
|
||||
shared_types::ComputerAgentStopResponse,
|
||||
shared_types::ComputerAgentCancelRequest,
|
||||
shared_types::ComputerAgentCancelResponse,
|
||||
// RCoder Agent 类型
|
||||
shared_types::RcoderChatRequest,
|
||||
shared_types::RcoderAgentCancelRequest,
|
||||
shared_types::RcoderAgentCancelResponse,
|
||||
shared_types::RcoderAgentStopRequest,
|
||||
shared_types::RcoderAgentStopResponse,
|
||||
shared_types::AgentStatusResponse,
|
||||
// 通用类型
|
||||
shared_types::HealthResponse,
|
||||
)),
|
||||
tags(
|
||||
(name = "Computer Agent", description = "Computer Agent HTTP API"),
|
||||
(name = "RCoder Agent", description = "RCoder Agent HTTP API"),
|
||||
(name = "System", description = "系统管理接口")
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
SwaggerUi::new("/api/docs").url("/api-docs/openapi.json", ApiDoc::openapi())
|
||||
}
|
||||
266
qiming-rcoder/crates/agent_runner/src/http_server/start.rs
Normal file
266
qiming-rcoder/crates/agent_runner/src/http_server/start.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
//! HTTP 服务器启动模块
|
||||
//!
|
||||
//! 提供便捷的 HTTP 服务器启动 API
|
||||
//! 支持 HTTP REST API 和可选的 Pingora 代理服务
|
||||
|
||||
use anyhow::Result;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::agent_runtime::AgentRuntime;
|
||||
use crate::config::AppConfig;
|
||||
use crate::http_server::router::{AppState, create_router};
|
||||
use crate::proxy_agent::cleanup_task::{CleanupConfig, start_cleanup_task};
|
||||
use crate::proxy_agent::set_unlimited_mode;
|
||||
#[cfg(feature = "proxy")]
|
||||
use crate::proxy_agent::start_pingora;
|
||||
|
||||
/// HTTP 服务器配置
|
||||
pub struct HttpServerConfig {
|
||||
/// HTTP 监听端口
|
||||
pub port: u16,
|
||||
/// 应用配置
|
||||
pub app_config: AppConfig,
|
||||
/// Agent 运行时
|
||||
pub agent_runtime: Arc<AgentRuntime>,
|
||||
/// 共享 API Key Manager
|
||||
pub shared_api_key_manager: Arc<dashmap::DashMap<String, shared_types::ModelProviderConfig>>,
|
||||
}
|
||||
|
||||
/// HTTP 服务器控制柄
|
||||
///
|
||||
/// 用于控制 HTTP 服务器的生命周期
|
||||
#[derive(Clone)]
|
||||
pub struct HttpServerHandle {
|
||||
/// 关闭信号令牌
|
||||
shutdown_token: CancellationToken,
|
||||
/// 活跃任务集合
|
||||
join_set: Arc<tokio::sync::Mutex<JoinSet<()>>>,
|
||||
/// Pingora 结果(用于调用 stop)
|
||||
#[cfg(feature = "proxy")]
|
||||
pingora_result: Arc<tokio::sync::Mutex<Option<crate::proxy_agent::PingoraStartResult>>>,
|
||||
}
|
||||
|
||||
impl HttpServerHandle {
|
||||
/// 检查是否收到关闭信号
|
||||
pub fn is_shutdown(&self) -> bool {
|
||||
self.shutdown_token.is_cancelled()
|
||||
}
|
||||
|
||||
/// 停止 HTTP 服务器并等待所有任务完成
|
||||
pub async fn stop(&self) {
|
||||
info!("Stopping HTTP server...");
|
||||
|
||||
// 1. 发送关闭信号
|
||||
self.shutdown_token.cancel();
|
||||
|
||||
// 2. 停止 Pingora 服务
|
||||
#[cfg(feature = "proxy")]
|
||||
{
|
||||
let mut pingora_guard = self.pingora_result.lock().await;
|
||||
if let Some(mut pingora) = pingora_guard.take() {
|
||||
pingora.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 等待所有任务完成(带超时)
|
||||
// 使用 3 秒超时:清理任务会立即退出,axum 有 3 秒进行连接排空
|
||||
let timeout = Duration::from_secs(3);
|
||||
let mut join_set = self.join_set.lock().await;
|
||||
|
||||
loop {
|
||||
match tokio::time::timeout(timeout, join_set.join_next()).await {
|
||||
Ok(Some(Ok(()))) => {
|
||||
info!("Task exited normally");
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
warn!("Task error: {:?}", e);
|
||||
}
|
||||
Ok(None) => {
|
||||
// JoinSet 为空,所有任务已完成
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("Timed out waiting for tasks (3s), aborting remaining tasks");
|
||||
join_set.abort_all();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("HTTP server stopped");
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 HTTP 服务器
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```no_run
|
||||
/// use agent_runner::{AgentRuntime, start_http_server, HttpServerConfig, AppConfig, ProxyConfig, HealthCheckConfig};
|
||||
/// use std::sync::Arc;
|
||||
/// use std::path::PathBuf;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// // 创建 Agent Runtime
|
||||
/// let (runtime, receiver) = AgentRuntime::new(1000);
|
||||
/// let runtime = Arc::new(runtime);
|
||||
/// runtime.start(receiver).await;
|
||||
///
|
||||
/// // 配置 HTTP Server
|
||||
/// let config = HttpServerConfig {
|
||||
/// port: 8080,
|
||||
/// app_config: AppConfig {
|
||||
/// port: 8080,
|
||||
/// projects_dir: PathBuf::from("/app/computer-project-workspace"),
|
||||
/// // 可选:启用 Pingora 代理服务
|
||||
/// proxy_config: Some(ProxyConfig {
|
||||
/// listen_port: 8088,
|
||||
/// default_backend_port: 8080,
|
||||
/// backend_host: "127.0.0.1".to_string(),
|
||||
/// port_param: "port".to_string(),
|
||||
/// health_check: HealthCheckConfig::default(),
|
||||
/// }),
|
||||
/// ..Default::default()
|
||||
/// },
|
||||
/// agent_runtime: runtime,
|
||||
/// shared_api_key_manager: Arc::new(dashmap::DashMap::new()),
|
||||
/// };
|
||||
///
|
||||
/// // 启动 HTTP Server
|
||||
/// let handle = start_http_server(config).await.unwrap();
|
||||
///
|
||||
/// // 优雅停止
|
||||
/// handle.stop().await;
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn start_http_server(config: HttpServerConfig) -> Result<HttpServerHandle> {
|
||||
// 设置 mcp-proxy 日志目录环境变量(如果配置了的话)
|
||||
if let Some(ref log_dir) = config.app_config.mcp_proxy_log_dir {
|
||||
// SAFETY: 在服务启动时设置环境变量是安全的,此时尚未启动多线程任务
|
||||
unsafe {
|
||||
std::env::set_var("MCP_PROXY_LOG_DIR", log_dir);
|
||||
}
|
||||
info!("🔧 Set MCP_PROXY_LOG_DIR={}", log_dir);
|
||||
}
|
||||
|
||||
// 设置无限制模式(HTTP Server 部署不限制槽位)
|
||||
set_unlimited_mode(true);
|
||||
|
||||
// 创建关闭信号令牌
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let join_set = Arc::new(tokio::sync::Mutex::new(JoinSet::new()));
|
||||
#[cfg(feature = "proxy")]
|
||||
let pingora_result = Arc::new(tokio::sync::Mutex::new(None));
|
||||
|
||||
// 1. 启动 Agent 清理任务
|
||||
let cleanup_config = CleanupConfig {
|
||||
idle_timeout: Duration::from_secs(
|
||||
config
|
||||
.app_config
|
||||
.agent_cleanup
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.idle_timeout_secs,
|
||||
),
|
||||
cleanup_interval: Duration::from_secs(
|
||||
config
|
||||
.app_config
|
||||
.agent_cleanup
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.cleanup_interval_secs,
|
||||
),
|
||||
};
|
||||
info!(
|
||||
"🧹 [HTTP] Agent cleanup config: idle_timeout={}s, cleanup_interval={}s",
|
||||
cleanup_config.idle_timeout.as_secs(),
|
||||
cleanup_config.cleanup_interval.as_secs()
|
||||
);
|
||||
let cleanup_token = shutdown_token.child_token();
|
||||
join_set.lock().await.spawn(async move {
|
||||
tokio::select! {
|
||||
_ = start_cleanup_task(cleanup_config) => {}
|
||||
_ = cleanup_token.cancelled() => {
|
||||
info!("Cleanup task received shutdown signal");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 启动 Pingora 代理服务(如果配置了且启用了 proxy feature)
|
||||
#[cfg(feature = "proxy")]
|
||||
if let Some(proxy_config) = &config.app_config.proxy_config {
|
||||
let result = start_pingora(proxy_config, config.shared_api_key_manager.clone());
|
||||
// 保存 Pingora 结果以便后续调用 stop
|
||||
*pingora_result.lock().await = Some(result);
|
||||
} else {
|
||||
info!("Pingora proxy service is not configured, skipping startup");
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "proxy"))]
|
||||
info!("Pingora proxy service is disabled (proxy feature not enabled)");
|
||||
|
||||
// 3. 创建 HTTP 应用状态
|
||||
let state = Arc::new(AppState::new(
|
||||
config.app_config.clone(),
|
||||
config.agent_runtime,
|
||||
config.shared_api_key_manager,
|
||||
));
|
||||
|
||||
// 4. 创建路由
|
||||
let app = create_router(state.clone());
|
||||
|
||||
// 5. 绑定地址并启动 HTTP 服务器
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
|
||||
info!("HTTP server started on port {}", config.port);
|
||||
|
||||
info!("HTTP API endpoints:");
|
||||
info!(" POST /computer/chat - Computer Agent chat");
|
||||
info!(" POST /computer/agent/status - Computer Agent status");
|
||||
info!(" POST /computer/agent/stop - Computer Agent stop");
|
||||
info!(" POST /computer/agent/session/cancel - Computer Agent cancel");
|
||||
info!(" GET /computer/progress/:session_id - SSE progress stream");
|
||||
info!(" -- RCoder Agent endpoints (new) --");
|
||||
info!(" POST /chat - RCoder Agent chat");
|
||||
info!(" GET /agent/status/:project_id - RCoder Agent status");
|
||||
info!(" POST /agent/stop - RCoder Agent stop");
|
||||
info!(" POST /agent/session/cancel - RCoder Agent cancel");
|
||||
info!(" GET /agent/progress/:session_id - RCoder SSE progress stream");
|
||||
info!(" -- Common endpoints --");
|
||||
info!(" GET /health - Health check");
|
||||
info!(" GET /api/docs - Swagger API documentation");
|
||||
|
||||
// 6. 启动 HTTP 服务任务
|
||||
let http_token = shutdown_token.child_token();
|
||||
// 将 listener 和 app 移入任务中
|
||||
let http_app = app;
|
||||
let http_listener = listener;
|
||||
join_set.lock().await.spawn(async move {
|
||||
// 使用 graceful shutdown wrapper
|
||||
let server = axum::serve(http_listener, http_app).with_graceful_shutdown(async move {
|
||||
let _ = http_token.cancelled().await;
|
||||
});
|
||||
|
||||
match server.await {
|
||||
Ok(()) => info!("HTTP service exited normally"),
|
||||
Err(e) => error!("HTTP service error: {:?}", e),
|
||||
}
|
||||
});
|
||||
|
||||
// 创建 handle
|
||||
let handle = HttpServerHandle {
|
||||
shutdown_token,
|
||||
join_set,
|
||||
#[cfg(feature = "proxy")]
|
||||
pingora_result,
|
||||
};
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
37
qiming-rcoder/crates/agent_runner/src/lib.rs
Normal file
37
qiming-rcoder/crates/agent_runner/src/lib.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! agent_runner 库
|
||||
//!
|
||||
//! 提供 AI 代理运行时和 ACP 协议集成
|
||||
|
||||
pub mod agent_runtime;
|
||||
pub mod api_key_manager;
|
||||
mod config;
|
||||
pub mod grpc;
|
||||
mod handler;
|
||||
mod model;
|
||||
pub mod otel_tracing; // 🔥 设为 public,供其他模块使用
|
||||
mod proxy_agent;
|
||||
pub mod router;
|
||||
pub mod service; // 🔥 设为 public,供测试使用
|
||||
mod utils;
|
||||
|
||||
// 条件性编译:HTTP 服务器模块
|
||||
#[cfg(feature = "http-server")]
|
||||
pub mod http_server;
|
||||
|
||||
// 测试辅助模块 (仅在 testing feature 启用时编译)
|
||||
#[cfg(feature = "testing")]
|
||||
pub mod testing;
|
||||
|
||||
// 重新导出主要的类型和函数
|
||||
pub use agent_runtime::*;
|
||||
pub use config::*;
|
||||
pub use model::*;
|
||||
pub use otel_tracing::*;
|
||||
pub use proxy_agent::*;
|
||||
pub use service::*; // 重新导出 service 模块
|
||||
pub use utils::*;
|
||||
|
||||
#[cfg(feature = "http-server")]
|
||||
pub use http_server::start::HttpServerHandle;
|
||||
#[cfg(feature = "http-server")]
|
||||
pub use http_server::{HttpServerConfig, start_http_server};
|
||||
599
qiming-rcoder/crates/agent_runner/src/main.rs
Normal file
599
qiming-rcoder/crates/agent_runner/src/main.rs
Normal file
@@ -0,0 +1,599 @@
|
||||
use clap::Parser;
|
||||
use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
// 🆕 使用共享的遥测模块
|
||||
use rcoder_telemetry::{TelemetryConfig, TelemetryGuard};
|
||||
|
||||
mod agent_runtime;
|
||||
mod api_key_manager;
|
||||
mod config;
|
||||
mod grpc;
|
||||
mod handler;
|
||||
mod model;
|
||||
mod process_reaper;
|
||||
mod proxy_agent;
|
||||
|
||||
// 🔥 Pyroscope Profiler 模块(可选:需要 pyroscope feature)
|
||||
#[cfg(feature = "pyroscope")]
|
||||
mod profiler;
|
||||
|
||||
// 🔥 OpenTelemetry 追踪模块(可选:保留用于向后兼容)
|
||||
#[allow(dead_code)]
|
||||
mod otel_tracing;
|
||||
|
||||
mod router;
|
||||
mod service;
|
||||
mod utils;
|
||||
|
||||
// HTTP 服务器模块 (仅在 http-server feature 启用时)
|
||||
#[cfg(feature = "http-server")]
|
||||
mod http_server;
|
||||
|
||||
use agent_runtime::AgentRuntime;
|
||||
use config::{CliArgs, load_config_with_args};
|
||||
use model::*;
|
||||
use proxy_agent::cleanup_task::{CleanupConfig, start_cleanup_task};
|
||||
#[cfg(feature = "proxy")]
|
||||
use rcoder_proxy::{PingoraServerManager, ProxyConfig};
|
||||
use router::AppState;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::panic;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(unix)]
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
/// 🔥 设置自定义 Panic Hook
|
||||
///
|
||||
/// 当 agent_runner panic 时,将完整的 panic 信息(包括 backtrace)写入日志文件
|
||||
/// 这样即使容器被销毁,也能通过挂载的日志目录找到崩溃原因
|
||||
fn set_panic_hook() {
|
||||
let default_hook = panic::take_hook();
|
||||
|
||||
panic::set_hook(Box::new(move |panic_info| {
|
||||
// 🔥 立即写入日志文件(不依赖 tracing,确保在 panic 时也能写入)
|
||||
if let Err(e) = write_panic_to_file(panic_info) {
|
||||
// 如果文件写入失败,尝试输出到 stderr
|
||||
eprintln!("❌ [PANIC] Failed to write panic log file: {}", e);
|
||||
}
|
||||
|
||||
// 🔥 同时输出到 stderr(Docker 会捕获到容器日志)
|
||||
eprintln!("═══════════════════════════════════════════════════════════");
|
||||
eprintln!("❌ [PANIC] agent_runner encountered a fatal error!");
|
||||
eprintln!("═══════════════════════════════════════════════════════════");
|
||||
if let Some(location) = panic_info.location() {
|
||||
eprintln!(
|
||||
"panic.location: {}:{}:{}",
|
||||
location.file(),
|
||||
location.line(),
|
||||
location.column()
|
||||
);
|
||||
}
|
||||
eprintln!("panic.payload: {}", panic_info);
|
||||
eprintln!("═══════════════════════════════════════════════════════════");
|
||||
|
||||
// 调用默认 hook(会终止进程)
|
||||
default_hook(panic_info);
|
||||
}));
|
||||
}
|
||||
|
||||
/// 将 panic 信息写入日志文件
|
||||
fn write_panic_to_file(panic_info: &panic::PanicHookInfo) -> std::io::Result<()> {
|
||||
// 🔥 日志文件路径:/app/container-logs/agent_runner_panic.log(使用已有的挂载目录)
|
||||
let log_path = PathBuf::from("/app/container-logs/agent_runner_panic.log");
|
||||
|
||||
// 确保目录存在
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// 打开文件(追加模式)
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)?;
|
||||
|
||||
// 获取当前时间
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
|
||||
|
||||
// 写入 panic 信息
|
||||
writeln!(
|
||||
file,
|
||||
"═══════════════════════════════════════════════════════════"
|
||||
)?;
|
||||
writeln!(file, "❌ [PANIC] agent_runner encountered a fatal error!")?;
|
||||
writeln!(file, "time: {}", now)?;
|
||||
writeln!(
|
||||
file,
|
||||
"═══════════════════════════════════════════════════════════"
|
||||
)?;
|
||||
if let Some(location) = panic_info.location() {
|
||||
writeln!(
|
||||
file,
|
||||
"panic.location: {}:{}:{}",
|
||||
location.file(),
|
||||
location.line(),
|
||||
location.column()
|
||||
)?;
|
||||
}
|
||||
writeln!(file, "panic.payload: {}", panic_info)?;
|
||||
|
||||
// 写入 backtrace(如果启用)
|
||||
#[cfg(feature = "backtrace")]
|
||||
{
|
||||
if let Ok(backtrace) = std::backtrace::Backtrace::capture() {
|
||||
writeln!(file, "Backtrace:\n{}", backtrace)?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
"═══════════════════════════════════════════════════════════\n"
|
||||
)?;
|
||||
|
||||
// 强制刷新到磁盘
|
||||
file.flush()?;
|
||||
|
||||
eprintln!("✅ Panic info written to: {}", log_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 🔥 设置优雅关闭信号处理器
|
||||
///
|
||||
/// 监听系统信号,实现优雅关闭:
|
||||
/// - Unix: SIGTERM (Docker stop) + SIGINT (Ctrl+C)
|
||||
/// - Windows: Ctrl+C
|
||||
fn setup_shutdown_handler() -> tokio::task::JoinHandle<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
// 监听 SIGTERM(Docker stop)
|
||||
let mut sigterm = match signal(SignalKind::terminate()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("❌ [SIGNAL] Failed to register SIGTERM handler: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 SIGINT(Ctrl+C)
|
||||
let mut sigint = match signal(SignalKind::interrupt()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("❌ [SIGNAL] Failed to register SIGINT handler: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => {
|
||||
eprintln!("📨 [SIGNAL] Received SIGTERM (Docker stop), starting graceful shutdown...");
|
||||
write_shutdown_log("SIGTERM");
|
||||
}
|
||||
_ = sigint.recv() => {
|
||||
eprintln!("📨 [SIGNAL] Received SIGINT (Ctrl+C), starting graceful shutdown...");
|
||||
write_shutdown_log("SIGINT");
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("🧹 [SIGNAL] Cleaning up resources...");
|
||||
eprintln!("✅ [SIGNAL] Graceful shutdown completed, exiting");
|
||||
std::process::exit(0);
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
// Windows: 仅监听 Ctrl+C
|
||||
if let Ok(()) = tokio::signal::ctrl_c().await {
|
||||
eprintln!("📨 [SIGNAL] Received Ctrl+C, starting graceful shutdown...");
|
||||
write_shutdown_log("Ctrl+C");
|
||||
}
|
||||
|
||||
eprintln!("🧹 [SIGNAL] Cleaning up resources...");
|
||||
eprintln!("✅ [SIGNAL] Graceful shutdown completed, exiting");
|
||||
std::process::exit(0);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 将关闭事件写入日志文件
|
||||
fn write_shutdown_log(signal: &str) {
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
|
||||
let log_path = PathBuf::from("/app/container-logs/agent_runner_shutdown.log");
|
||||
|
||||
if let Some(parent) = log_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"═══════════════════════════════════════════════════════════"
|
||||
);
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"📨 [SHUTDOWN] agent_runner received a shutdown signal"
|
||||
);
|
||||
let _ = writeln!(file, "signal: {}", signal);
|
||||
let _ = writeln!(file, "time: {}", now);
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"═══════════════════════════════════════════════════════════\n"
|
||||
);
|
||||
let _ = file.flush();
|
||||
eprintln!("✅ Shutdown info written to: {}", log_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
// 路由创建函数已移动到 handler 模块
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// 🔥 设置自定义 Panic Hook,确保 panic 信息被记录
|
||||
set_panic_hook();
|
||||
|
||||
// 🔥 设置信号处理器,实现优雅关闭(Docker stop、Ctrl+C)
|
||||
let _shutdown_handle = setup_shutdown_handler();
|
||||
|
||||
// ✅ 初始化 Rustls CryptoProvider(必须在最前面,在任何可能使用 TLS 的代码之前)
|
||||
// 🔥 如果这里失败,会导致 panic,但 panic hook 会捕获并记录
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect(
|
||||
"❌ [FATAL] Rustls CryptoProvider initialization failed. The process cannot continue. This is usually an environment issue.",
|
||||
);
|
||||
|
||||
// 🆕 Initializing telemetry system(使用 rcoder-telemetry,包含控制台 + 文件日志)
|
||||
let telemetry_config = TelemetryConfig::from_env("agent_runner").with_file_log("agent-runner"); // 启用文件日志,前缀为 agent-runner
|
||||
let telemetry: TelemetryGuard = rcoder_telemetry::init(telemetry_config).await?;
|
||||
let telemetry = Arc::new(telemetry);
|
||||
|
||||
// 🆕 Pyroscope Profiler 初始化(可选:需要 pyroscope feature)
|
||||
#[cfg(feature = "pyroscope")]
|
||||
let _pyroscope_guard: Option<profiler::ProfilerGuard> = {
|
||||
info!("Pyroscope profiling feature enabled");
|
||||
match profiler::init_pyroscope_profiler_default() {
|
||||
Ok(guard) => {
|
||||
info!("Pyroscope profiler initialized successfully");
|
||||
Some(guard)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to initialize Pyroscope profiler: {}", e);
|
||||
warn!("Continuing without Pyroscope profiling");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "pyroscope"))]
|
||||
let _pyroscope_guard: Option<()> = None;
|
||||
|
||||
info!("Starting rcoder - AI-powered development platform");
|
||||
|
||||
// 解析命令行参数
|
||||
let cli_args = CliArgs::parse();
|
||||
|
||||
// 加载配置(包含命令行参数)
|
||||
let config = load_config_with_args(cli_args);
|
||||
|
||||
// 🔥 初始化并发限制(从配置读取)
|
||||
if let Some(ref concurrency_config) = config.agent_concurrency {
|
||||
agent_runtime::init_concurrency_limit(concurrency_config.concurrency_limit);
|
||||
}
|
||||
|
||||
// 🔥 创建 AgentRuntime(新架构)
|
||||
let (agent_runtime, task_receiver) = AgentRuntime::new(1000);
|
||||
let agent_runtime = Arc::new(agent_runtime);
|
||||
info!("🔧 [MAIN] AgentRuntime created");
|
||||
|
||||
// 🔥 启动 Worker(在主运行时中,无需独立线程)
|
||||
agent_runtime.start(task_receiver).await;
|
||||
info!("📌 [MAIN] Agent Worker started");
|
||||
|
||||
// 🔥 启动健康检查和重启任务
|
||||
let health_monitor = spawn_health_monitor(agent_runtime.clone());
|
||||
info!("[MAIN] Worker health monitor started");
|
||||
|
||||
// 🔥 启动僵尸进程回收器(PID 1 必须回收孤儿进程)
|
||||
let _reaper_handle = process_reaper::start_process_reaper();
|
||||
info!("🧹 [MAIN] Process reaper started (PID 1 mode)");
|
||||
|
||||
// 🆕 从配置中获取 Agent 清理配置,或使用默认值
|
||||
let agent_cleanup_config = config.agent_cleanup.clone().unwrap_or_default();
|
||||
let cleanup_config = CleanupConfig {
|
||||
idle_timeout: Duration::from_secs(agent_cleanup_config.idle_timeout_secs),
|
||||
cleanup_interval: Duration::from_secs(agent_cleanup_config.cleanup_interval_secs),
|
||||
};
|
||||
|
||||
info!(
|
||||
"🧹 [MAIN] Agent cleanup config: idle_timeout={}s, cleanup_interval={}s",
|
||||
agent_cleanup_config.idle_timeout_secs, agent_cleanup_config.cleanup_interval_secs
|
||||
);
|
||||
|
||||
// 在主异步运行时中启动清理任务
|
||||
let _cleanup_handle = start_cleanup_task(cleanup_config.clone());
|
||||
|
||||
// proxy_manager 不需要直接访问 app_state,通过参数传递即可
|
||||
|
||||
// 🔒 创建共享的 API 密钥 DashMap
|
||||
let shared_api_key_manager =
|
||||
Arc::new(dashmap::DashMap::<String, shared_types::ModelProviderConfig>::new());
|
||||
info!("🔑 [MAIN] Shared API key DashMap created");
|
||||
|
||||
// 🔥 创建 ApiKeyManager 包装器(包装共享 DashMap,消除双重存储)
|
||||
let api_key_manager = Arc::new(api_key_manager::ApiKeyManager::from_shared(
|
||||
shared_api_key_manager.clone(),
|
||||
));
|
||||
|
||||
// 🔒 project_id -> service_uuid 映射
|
||||
let project_uuid_map: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
|
||||
|
||||
// 🔥 http-server 模式:启动 HTTP + (可选 gRPC) + Pingora
|
||||
#[cfg(feature = "http-server")]
|
||||
{
|
||||
use http_server::{HttpServerConfig, start_http_server};
|
||||
use proxy_agent::set_unlimited_mode;
|
||||
|
||||
// 设置为无限制模式(HTTP Server 部署,不限制槽位)
|
||||
set_unlimited_mode(true);
|
||||
|
||||
// 🔥 1. 可选:启动 gRPC 服务(当 grpc-server feature 启用时)
|
||||
#[cfg(feature = "grpc-server")]
|
||||
let grpc_handle = {
|
||||
info!("ℹ️ HTTP server mode: starting HTTP + gRPC + Pingora");
|
||||
|
||||
let grpc_port = shared_types::GRPC_DEFAULT_PORT;
|
||||
let grpc_addr = format!("[::]:{}", grpc_port)
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse gRPC address: {}", e))?;
|
||||
|
||||
// 为 gRPC 创建 state
|
||||
let grpc_state = Arc::new(AppState {
|
||||
sessions: Arc::new(DashMap::new()),
|
||||
config: config.clone(),
|
||||
local_task_sender: agent_runtime.clone(),
|
||||
agent_runtime: agent_runtime.clone(),
|
||||
#[cfg(feature = "proxy")]
|
||||
pingora_service: None,
|
||||
api_key_manager: api_key_manager.clone(),
|
||||
shared_api_key_manager: shared_api_key_manager.clone(),
|
||||
project_uuid_map: project_uuid_map.clone(),
|
||||
});
|
||||
|
||||
// gRPC 消息大小限制
|
||||
let grpc_service = shared_types::grpc::agent_service_server::AgentServiceServer::new(
|
||||
grpc::AgentServiceImpl::new(grpc_state.clone()),
|
||||
)
|
||||
.max_decoding_message_size(shared_types::GRPC_MAX_MESSAGE_SIZE)
|
||||
.max_encoding_message_size(shared_types::GRPC_MAX_MESSAGE_SIZE);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
info!("gRPC service started, listening on port: {}", grpc_port);
|
||||
info!("gRPC endpoints (port {}):", grpc_port);
|
||||
info!(" agent.AgentService/Chat - gRPC chat");
|
||||
info!(" agent.AgentService/SubscribeProgress - gRPC progress stream");
|
||||
info!(" agent.AgentService/CancelSession - gRPC cancel");
|
||||
info!(" agent.AgentService/GetStatus - gRPC status");
|
||||
if let Err(e) = tonic::transport::Server::builder()
|
||||
.add_service(grpc_service)
|
||||
.serve(grpc_addr)
|
||||
.await
|
||||
{
|
||||
error!("gRPC server error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Some(handle)
|
||||
};
|
||||
|
||||
// 无 gRPC 模式
|
||||
#[cfg(not(feature = "grpc-server"))]
|
||||
{
|
||||
info!("ℹ️ HTTP server mode: starting HTTP + Pingora only (no gRPC)");
|
||||
}
|
||||
|
||||
// 🔥 2. 创建 HttpServerConfig(包含所有配置)
|
||||
let http_config = HttpServerConfig {
|
||||
port: config.port,
|
||||
app_config: config.clone(),
|
||||
agent_runtime: agent_runtime.clone(),
|
||||
shared_api_key_manager: shared_api_key_manager.clone(),
|
||||
};
|
||||
|
||||
// 🔥 3. 启动 HTTP 服务器(内部会启动 Pingora)
|
||||
let _handle = start_http_server(http_config).await?;
|
||||
|
||||
// 🔥 4. 同时等待 gRPC(如果有)和信号
|
||||
info!("HTTP + Pingora services started; running until shutdown signal is received");
|
||||
|
||||
#[cfg(feature = "grpc-server")]
|
||||
{
|
||||
tokio::select! {
|
||||
_ = grpc_handle.unwrap() => {
|
||||
info!("gRPC service ended unexpectedly, shutting down...");
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("📨 Received shutdown signal, preparing graceful shutdown...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "grpc-server"))]
|
||||
{
|
||||
tokio::signal::ctrl_c().await?;
|
||||
info!("📨 Received shutdown signal, preparing graceful shutdown...");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 🔥 non-http-server 模式:启动 gRPC + Pingora(用于 Docker 容器内)
|
||||
#[cfg(not(feature = "http-server"))]
|
||||
{
|
||||
info!("ℹ️ Container mode: starting gRPC + Pingora");
|
||||
|
||||
// 启动 gRPC 服务
|
||||
let grpc_port = shared_types::GRPC_DEFAULT_PORT;
|
||||
let grpc_addr = format!("[::]:{}", grpc_port)
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse gRPC address: {}", e))?;
|
||||
|
||||
// 为 gRPC 创建 state
|
||||
let grpc_state = Arc::new(AppState {
|
||||
sessions: Arc::new(DashMap::new()),
|
||||
config: config.clone(),
|
||||
local_task_sender: agent_runtime.clone(),
|
||||
agent_runtime: agent_runtime.clone(),
|
||||
#[cfg(feature = "proxy")]
|
||||
pingora_service: None,
|
||||
api_key_manager: api_key_manager.clone(),
|
||||
shared_api_key_manager: shared_api_key_manager.clone(),
|
||||
project_uuid_map: project_uuid_map.clone(),
|
||||
});
|
||||
|
||||
// gRPC 消息大小限制
|
||||
let grpc_service = shared_types::grpc::agent_service_server::AgentServiceServer::new(
|
||||
grpc::AgentServiceImpl::new(grpc_state.clone()),
|
||||
)
|
||||
.max_decoding_message_size(shared_types::GRPC_MAX_MESSAGE_SIZE)
|
||||
.max_encoding_message_size(shared_types::GRPC_MAX_MESSAGE_SIZE);
|
||||
|
||||
let grpc_handle = tokio::spawn(async move {
|
||||
info!("gRPC service started, listening on port: {}", grpc_port);
|
||||
info!("gRPC endpoints (port {}):", grpc_port);
|
||||
info!(" agent.AgentService/Chat - gRPC chat");
|
||||
info!(" agent.AgentService/SubscribeProgress - gRPC progress stream");
|
||||
info!(" agent.AgentService/CancelSession - gRPC cancel");
|
||||
info!(" agent.AgentService/GetStatus - gRPC status");
|
||||
if let Err(e) = tonic::transport::Server::builder()
|
||||
.add_service(grpc_service)
|
||||
.serve(grpc_addr)
|
||||
.await
|
||||
{
|
||||
error!("gRPC server error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// 启动轻量 HTTP 健康检查服务(供 docker_manager 健康检查使用)
|
||||
let health_port = config.port; // 默认 8086,来自 --port 参数
|
||||
let _health_handle = tokio::spawn(async move {
|
||||
use axum::{Json, Router, routing::get};
|
||||
|
||||
async fn health_check() -> Json<shared_types::HealthResponse> {
|
||||
Json(shared_types::HealthResponse::new("agent-runner"))
|
||||
}
|
||||
|
||||
let app = Router::new().route("/health", get(health_check));
|
||||
let addr = format!("0.0.0.0:{}", health_port);
|
||||
|
||||
info!(
|
||||
"🏥 HTTP health check service started, listening on port: {}",
|
||||
health_port
|
||||
);
|
||||
|
||||
let listener = match tokio::net::TcpListener::bind(&addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ Failed to bind HTTP health check service: {} (port: {})",
|
||||
e, health_port
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
error!("HTTP health check service error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// 启动 Pingora(如有配置且启用了 proxy feature)
|
||||
#[cfg(feature = "proxy")]
|
||||
let pingora_result = {
|
||||
use proxy_agent::start_pingora;
|
||||
|
||||
if let Some(proxy_config) = &config.proxy_config {
|
||||
Some(start_pingora(proxy_config, shared_api_key_manager.clone()))
|
||||
} else {
|
||||
info!("ℹ️ Pingora proxy service is not configured");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "proxy"))]
|
||||
let pingora_result: Option<()> = {
|
||||
info!("ℹ️ Pingora proxy service is disabled (proxy feature not enabled)");
|
||||
None
|
||||
};
|
||||
|
||||
// 等待 gRPC 服务
|
||||
let _ = grpc_handle.await;
|
||||
|
||||
// 停止 Pingora 服务
|
||||
#[cfg(feature = "proxy")]
|
||||
if let Some(mut result) = pingora_result {
|
||||
result.stop().await;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "proxy"))]
|
||||
let _ = pingora_result;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 健康监控任务 (新架构)
|
||||
///
|
||||
/// 定期检查 Agent Worker 健康状态,自动重启不健康的 Worker
|
||||
async fn spawn_health_monitor(runtime: Arc<AgentRuntime>) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||
let mut consecutive_failures: u32 = 0;
|
||||
const MAX_RESTART_ATTEMPTS: u32 = 5;
|
||||
const RESTART_COOLDOWN_SECS: u64 = 60;
|
||||
|
||||
info!("[HealthMonitor] Health monitor started");
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// 检查健康状态
|
||||
if !runtime.check_health().await {
|
||||
error!("[HealthMonitor] Worker reported unhealthy");
|
||||
|
||||
// 检查冷却期
|
||||
if consecutive_failures >= MAX_RESTART_ATTEMPTS {
|
||||
warn!(
|
||||
"⏳ [HealthMonitor] {} consecutive restart failures, entering cooldown",
|
||||
consecutive_failures
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(RESTART_COOLDOWN_SECS)).await;
|
||||
consecutive_failures = 0;
|
||||
info!("[HealthMonitor] Cooldown ended, reset failure counter");
|
||||
}
|
||||
|
||||
// 创建新的通道
|
||||
let (new_tx, new_rx) = tokio::sync::mpsc::channel(1000);
|
||||
|
||||
// 重启 worker
|
||||
runtime.restart(new_rx).await;
|
||||
consecutive_failures += 1;
|
||||
info!(
|
||||
"🔄 [HealthMonitor] Worker restart completed (attempt #{})",
|
||||
consecutive_failures
|
||||
);
|
||||
} else {
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
26
qiming-rcoder/crates/agent_runner/src/model.rs
Normal file
26
qiming-rcoder/crates/agent_runner/src/model.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
// 重新导出 shared_types 中的模型,保持向后兼容
|
||||
|
||||
pub use agent_abstraction::PromptMessage;
|
||||
|
||||
pub use shared_types::{
|
||||
// Agent model exports
|
||||
AgentStatus,
|
||||
// Session and message exports
|
||||
Attachment,
|
||||
AttachmentSource,
|
||||
AudioAttachment,
|
||||
// 取消相关类型
|
||||
CancelNotificationRequestWrapper,
|
||||
CancelResult,
|
||||
ChatPromptResponse,
|
||||
DocumentAttachment,
|
||||
ImageAttachment,
|
||||
ProjectAndAgentInfo,
|
||||
SessionMessageType,
|
||||
SessionNotify,
|
||||
TextAttachment,
|
||||
UnifiedSessionMessage,
|
||||
};
|
||||
|
||||
// 重新导出 ACP 类型,供客户端构造取消请求使用
|
||||
pub use agent_client_protocol::schema::{CancelNotification, SessionId};
|
||||
457
qiming-rcoder/crates/agent_runner/src/otel_tracing/mod.rs
Normal file
457
qiming-rcoder/crates/agent_runner/src/otel_tracing/mod.rs
Normal file
@@ -0,0 +1,457 @@
|
||||
//! OpenTelemetry 追踪模块
|
||||
//!
|
||||
//! 集成 `rcoder-telemetry` 提供完整的分布式追踪功能。
|
||||
|
||||
use opentelemetry::trace::Status;
|
||||
use tracing::{Level, Span, error, info, span};
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
/// OpenTelemetry 追踪配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TraceConfig {
|
||||
/// 是否启用追踪
|
||||
pub enabled: bool,
|
||||
/// OTLP 导出端点(如 Jaeger、OTLP)
|
||||
pub exporter_endpoint: Option<String>,
|
||||
/// 是否启用 Prometheus 指标
|
||||
pub prometheus_enabled: bool,
|
||||
/// 服务名称
|
||||
pub service_name: String,
|
||||
}
|
||||
|
||||
impl Default for TraceConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
exporter_endpoint: None,
|
||||
prometheus_enabled: true,
|
||||
service_name: "agent-runner".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化 OpenTelemetry 追踪
|
||||
///
|
||||
/// 集成 `rcoder-telemetry` 提供完整的分布式追踪功能:
|
||||
/// - OTLP 导出器(支持 Jaeger、Zipkin、OTLP Collector)
|
||||
/// - Prometheus 指标
|
||||
/// - Trace context 传播
|
||||
/// - Console 日志
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - 追踪配置
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// 返回遥测系统 Guard,需要在应用运行期间保持存活
|
||||
///
|
||||
/// # Environment Variables
|
||||
///
|
||||
/// 支持以下环境变量:
|
||||
/// - `OTEL_EXPORTER_OTLP_ENDPOINT`: OTLP 端点(如 http://jaeger:4317)
|
||||
/// - `OTEL_TRACES_SAMPLER_ARG`: 采样率 (0.0-1.0,默认 1.0)
|
||||
/// - `TELEMETRY_PROMETHEUS_ENABLED`: 是否启用 Prometheus(默认 true)
|
||||
pub async fn init_tracing(config: TraceConfig) -> anyhow::Result<rcoder_telemetry::TelemetryGuard> {
|
||||
if !config.enabled {
|
||||
info!("📍 [OTel] Tracing is disabled");
|
||||
// 即使追踪禁用,仍然Initializing Prometheus(如果启用)
|
||||
if config.prometheus_enabled {
|
||||
return rcoder_telemetry::init_prometheus_only(&config.service_name);
|
||||
}
|
||||
// 创建一个空的 guard(仅保留服务名称,用于后续渲染指标)
|
||||
// 使用空的 telemetry_config 创建一个没有任何功能的 guard
|
||||
let telemetry_config = rcoder_telemetry::TelemetryConfig::new(&config.service_name);
|
||||
return rcoder_telemetry::init(telemetry_config).await;
|
||||
}
|
||||
|
||||
// 使用 from_env 从环境变量读取配置,然后覆盖必要字段
|
||||
let mut telemetry_config = rcoder_telemetry::TelemetryConfig::from_env(&config.service_name);
|
||||
|
||||
// 如果配置中指定了端点,覆盖环境变量中的值
|
||||
if let Some(ref endpoint) = config.exporter_endpoint {
|
||||
telemetry_config = telemetry_config.with_otlp_endpoint(endpoint);
|
||||
}
|
||||
|
||||
// 根据配置设置 Prometheus
|
||||
if config.prometheus_enabled {
|
||||
telemetry_config = telemetry_config.with_prometheus();
|
||||
} else {
|
||||
telemetry_config = telemetry_config.without_prometheus();
|
||||
}
|
||||
|
||||
// Initializing telemetry system
|
||||
let guard = rcoder_telemetry::init(telemetry_config).await?;
|
||||
|
||||
info!(
|
||||
"✅ [OTel] Tracing initialized: OTLP={}, Prometheus={}",
|
||||
guard.is_otlp_enabled(),
|
||||
guard.is_prometheus_enabled()
|
||||
);
|
||||
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
impl TraceConfig {
|
||||
/// 从环境变量构建配置
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
enabled: std::env::var("OTEL_TRACING_ENABLED")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(true),
|
||||
exporter_endpoint: std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(),
|
||||
prometheus_enabled: std::env::var("TELEMETRY_PROMETHEUS_ENABLED")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(true),
|
||||
service_name: "agent-runner".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 OTLP 端点
|
||||
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
|
||||
self.exporter_endpoint = Some(endpoint.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 禁用 Prometheus
|
||||
pub fn without_prometheus(mut self) -> Self {
|
||||
self.prometheus_enabled = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// 禁用追踪
|
||||
pub fn disabled(mut self) -> Self {
|
||||
self.enabled = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置服务名称
|
||||
pub fn with_service_name(mut self, name: impl Into<String>) -> Self {
|
||||
self.service_name = name.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求追踪 Guard(自动管理 span 生命周期)
|
||||
///
|
||||
/// 使用 `OpenTelemetrySpanExt` 支持动态属性设置
|
||||
///
|
||||
/// 注意:此结构体实现 Send + Sync,可在 tokio::spawn 中安全使用
|
||||
pub struct RequestSpan {
|
||||
/// 底层 tracing span(用于 OpenTelemetrySpanExt 方法)
|
||||
span: Span,
|
||||
}
|
||||
|
||||
impl RequestSpan {
|
||||
/// 创建新的请求 span
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `project_id` - 项目 ID
|
||||
/// * `request_id` - 请求 ID
|
||||
/// * `operation` - 操作名称(如 "process_prompt")
|
||||
pub fn new(project_id: &str, request_id: &str, operation: &str) -> Self {
|
||||
let span = span!(
|
||||
Level::INFO,
|
||||
"agent_request",
|
||||
project_id = %project_id,
|
||||
request_id = %request_id,
|
||||
operation = %operation,
|
||||
);
|
||||
|
||||
info!(
|
||||
"📍 [OTel] Span created: project_id={}, request_id={}, operation={}",
|
||||
project_id, request_id, operation
|
||||
);
|
||||
|
||||
Self { span }
|
||||
}
|
||||
|
||||
/// 设置动态属性(使用 OpenTelemetrySpanExt)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - 属性键
|
||||
/// * `value` - 属性值
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use agent_runner::RequestSpan;
|
||||
///
|
||||
/// let span = RequestSpan::new("proj", "req", "op");
|
||||
/// span.set_attribute("http.status_code", 200);
|
||||
/// span.set_attribute("user.id", "user-123");
|
||||
/// ```
|
||||
pub fn set_attribute<K, V>(&self, key: K, value: V)
|
||||
where
|
||||
K: Into<opentelemetry::Key>,
|
||||
V: Into<opentelemetry::Value>,
|
||||
{
|
||||
self.span.set_attribute(key, value);
|
||||
}
|
||||
|
||||
/// 批量设置动态属性
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `attributes` - 属性列表 (key, value)
|
||||
pub fn set_attributes(&self, attributes: &[(&str, &str)]) {
|
||||
for (key, value) in attributes {
|
||||
// 需要转换为 String 以满足 'static 生命周期要求
|
||||
self.span.set_attribute(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 span 状态为成功
|
||||
pub fn set_ok(&self) {
|
||||
self.span.set_status(Status::Ok);
|
||||
}
|
||||
|
||||
/// 设置 span 状态为错误
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `description` - 错误描述
|
||||
pub fn set_error(&self, description: impl Into<std::borrow::Cow<'static, str>>) {
|
||||
self.span.set_status(Status::error(description));
|
||||
}
|
||||
|
||||
/// 添加事件到当前 span(使用 OpenTelemetrySpanExt)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - 事件名称
|
||||
/// * `attributes` - 事件属性
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use agent_runner::RequestSpan;
|
||||
/// use opentelemetry::KeyValue;
|
||||
///
|
||||
/// let span = RequestSpan::new("proj", "req", "op");
|
||||
/// span.add_event("cache_hit", vec![
|
||||
/// KeyValue::new("cache.key", "user:123"),
|
||||
/// KeyValue::new("cache.ttl", 300),
|
||||
/// ]);
|
||||
/// ```
|
||||
pub fn add_event(
|
||||
&self,
|
||||
name: impl Into<std::borrow::Cow<'static, str>>,
|
||||
attributes: Vec<opentelemetry::KeyValue>,
|
||||
) {
|
||||
self.span.add_event(name, attributes);
|
||||
}
|
||||
|
||||
/// 记录事件(简化版本,兼容旧 API)
|
||||
pub fn event(&self, name: &str, attributes: &[(&str, String)]) {
|
||||
// 转换为 String 以满足 'static 生命周期要求
|
||||
let kv_attrs: Vec<opentelemetry::KeyValue> = attributes
|
||||
.iter()
|
||||
.map(|(k, v)| opentelemetry::KeyValue::new(k.to_string(), v.clone()))
|
||||
.collect();
|
||||
|
||||
self.span.add_event(name.to_string(), kv_attrs);
|
||||
|
||||
info!(
|
||||
"📍 [OTel] Event: {}, attributes: {:?}",
|
||||
name,
|
||||
attributes
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
/// 记录错误到当前 span
|
||||
pub fn error(&self, err: &anyhow::Error) {
|
||||
self.set_error(err.to_string());
|
||||
error!(
|
||||
error = %err,
|
||||
"📍 [OTel] Request error"
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取 OpenTelemetry 上下文(用于跨服务传播)
|
||||
pub fn context(&self) -> opentelemetry::Context {
|
||||
self.span.context()
|
||||
}
|
||||
|
||||
/// 完成 span(手动关闭)
|
||||
pub fn finish(self) {
|
||||
self.set_ok();
|
||||
// span 在 drop 时会自动关闭
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RequestSpan {
|
||||
fn drop(&mut self) {
|
||||
info!("📍 [OTel] Span closed");
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建子 span(用于追踪子操作)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `_parent` - 父 span 的引用(子 span 会自动继承父 span 的上下文)
|
||||
/// * `name` - 子 span 名称
|
||||
/// * `attributes` - 附加属性
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use agent_runner::{RequestSpan, child_span};
|
||||
///
|
||||
/// let parent = RequestSpan::new("proj", "req", "parent_op");
|
||||
/// let child = child_span(&parent, "child_op", &[("key", "value".to_string())]);
|
||||
/// child.finish();
|
||||
/// parent.finish();
|
||||
/// ```
|
||||
pub fn child_span(_parent: &RequestSpan, name: &str, attributes: &[(&str, String)]) -> RequestSpan {
|
||||
let span = span!(
|
||||
Level::INFO,
|
||||
"child_operation",
|
||||
otel.name = %name,
|
||||
);
|
||||
|
||||
// 使用 OpenTelemetrySpanExt 设置动态属性
|
||||
// 需要转换为 String 以满足 'static 生命周期要求
|
||||
for (key, value) in attributes {
|
||||
span.set_attribute(key.to_string(), value.clone());
|
||||
}
|
||||
|
||||
info!("📍 [OTel] Child span created: {}", name);
|
||||
|
||||
RequestSpan { span }
|
||||
}
|
||||
|
||||
/// 从上下文中提取当前 span(用于跨线程传递)
|
||||
pub fn current_span() -> RequestSpan {
|
||||
let span = Span::current();
|
||||
|
||||
RequestSpan { span }
|
||||
}
|
||||
|
||||
/// 创建带属性的 span
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use agent_runner::otel_tracing::span_with_attributes;
|
||||
///
|
||||
/// let span = span_with_attributes(
|
||||
/// "process_attachment",
|
||||
/// &[
|
||||
/// ("file_name", "test.pdf".to_string()),
|
||||
/// ("file_size", "1024".to_string()),
|
||||
/// ]
|
||||
/// );
|
||||
/// ```
|
||||
pub fn span_with_attributes(name: &str, attributes: &[(&str, String)]) -> RequestSpan {
|
||||
let span = span!(
|
||||
Level::INFO,
|
||||
"custom_operation",
|
||||
otel.name = %name,
|
||||
);
|
||||
|
||||
// 使用 OpenTelemetrySpanExt 设置动态属性
|
||||
// 需要转换为 String 以满足 'static 生命周期要求
|
||||
for (key, value) in attributes {
|
||||
span.set_attribute(key.to_string(), value.clone());
|
||||
}
|
||||
|
||||
RequestSpan { span }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_trace_config_default() {
|
||||
let config = TraceConfig::default();
|
||||
assert!(config.enabled);
|
||||
assert!(config.exporter_endpoint.is_none());
|
||||
assert!(config.prometheus_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_span_creation() {
|
||||
let span = RequestSpan::new("test_project", "test_request", "test_operation");
|
||||
span.event("test_event", &[("key", "value".to_string())]);
|
||||
span.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_span_with_attributes() {
|
||||
let span = RequestSpan::new("test_project", "test_request", "test_operation");
|
||||
|
||||
// 使用 OpenTelemetrySpanExt 设置动态属性
|
||||
span.set_attribute("http.method", "GET");
|
||||
span.set_attribute("http.status_code", 200i64);
|
||||
span.set_attribute("user.id", "user-123");
|
||||
|
||||
span.set_ok();
|
||||
span.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_span_with_error() {
|
||||
let span = RequestSpan::new("test_project", "test_request", "test_operation");
|
||||
span.set_error("Connection timeout");
|
||||
// span 会在 drop 时自动关闭
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_span_auto_drop() {
|
||||
let _span = RequestSpan::new("test_project", "test_request", "test_operation");
|
||||
// Span 会在 drop 时自动关闭
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_child_span() {
|
||||
let parent = RequestSpan::new("test_project", "test_request", "parent_operation");
|
||||
let child = child_span(&parent, "child_operation", &[("attr", "value".to_string())]);
|
||||
child.finish();
|
||||
parent.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_span_with_attributes() {
|
||||
let span = span_with_attributes(
|
||||
"custom_op",
|
||||
&[
|
||||
("file_name", "test.pdf".to_string()),
|
||||
("file_size", "1024".to_string()),
|
||||
],
|
||||
);
|
||||
span.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_event() {
|
||||
let span = RequestSpan::new("test_project", "test_request", "test_operation");
|
||||
|
||||
span.add_event(
|
||||
"cache_hit",
|
||||
vec![
|
||||
opentelemetry::KeyValue::new("cache.key", "user:123"),
|
||||
opentelemetry::KeyValue::new("cache.ttl", 300i64),
|
||||
],
|
||||
);
|
||||
|
||||
span.finish();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_init_tracing() {
|
||||
let config = TraceConfig::default();
|
||||
let result = init_tracing(config).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
672
qiming-rcoder/crates/agent_runner/src/process_reaper.rs
Normal file
672
qiming-rcoder/crates/agent_runner/src/process_reaper.rs
Normal file
@@ -0,0 +1,672 @@
|
||||
//! 僵尸进程回收器 (Zombie Process Reaper)
|
||||
//!
|
||||
//! 当 agent_runner 作为容器的 PID 1 运行时,它需要负责回收孤儿进程。
|
||||
//! 此模块实现了一个基于 SIGCHLD 信号的子进程回收机制。
|
||||
//!
|
||||
//! # 设计原理
|
||||
//!
|
||||
//! 在 Linux 容器中,如果 PID 1 不调用 wait() 回收子进程,这些子进程
|
||||
//! 退出后会变成僵尸进程(Zombie),占用系统资源。
|
||||
//!
|
||||
//! # 使用方式
|
||||
//!
|
||||
//! ```rust
|
||||
//! use process_reaper::start_process_reaper;
|
||||
//!
|
||||
//! // 在主函数中启动回收器
|
||||
//! let _reaper_handle = start_process_reaper();
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use tokio::process::Child;
|
||||
#[cfg(unix)]
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// 进程回收器配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReaperConfig {
|
||||
/// 是否启用详细日志
|
||||
pub verbose: bool,
|
||||
/// 是否启用主动僵尸进程检测
|
||||
pub enable_zombie_detection: bool,
|
||||
/// 僵尸进程检测间隔(秒)
|
||||
pub zombie_detection_interval_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for ReaperConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
verbose: false,
|
||||
enable_zombie_detection: true,
|
||||
zombie_detection_interval_secs: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 僵尸进程信息
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ZombieProcessInfo {
|
||||
pub pid: u32,
|
||||
pub ppid: u32,
|
||||
pub comm: String,
|
||||
pub state: char,
|
||||
}
|
||||
|
||||
/// 进程回收器状态
|
||||
#[derive(Debug)]
|
||||
struct ReaperState {
|
||||
/// 追踪活跃的子进程
|
||||
/// 存储格式: pid -> Child
|
||||
active_children: HashMap<u32, Child>,
|
||||
/// 回收的进程总数
|
||||
reaped_count: u64,
|
||||
/// 检测到的僵尸进程数
|
||||
zombie_detected_count: u64,
|
||||
/// 配置
|
||||
config: ReaperConfig,
|
||||
}
|
||||
|
||||
impl ReaperState {
|
||||
fn new(config: ReaperConfig) -> Self {
|
||||
Self {
|
||||
active_children: HashMap::new(),
|
||||
reaped_count: 0,
|
||||
zombie_detected_count: 0,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册一个子进程,稍后自动回收
|
||||
fn register_child(&mut self, child: Child) {
|
||||
let id = child.id().unwrap_or(0);
|
||||
if id > 0 {
|
||||
self.active_children.insert(id, child);
|
||||
if self.config.verbose {
|
||||
debug!("[ProcessReaper] Registered child process PID={}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试回收所有已退出的子进程
|
||||
fn reap_all(&mut self) {
|
||||
let mut reaped_now = 0;
|
||||
|
||||
// 使用 entry API 避免 DashMap/RwLock 问题(虽然这里是普通 HashMap)
|
||||
self.active_children.retain(|pid, child| {
|
||||
// 尝试查询进程状态(非阻塞)
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
// 进程已退出
|
||||
reaped_now += 1;
|
||||
if self.config.verbose {
|
||||
debug!(
|
||||
"[ProcessReaper] Reaped child process PID={}, exit_status={:?}",
|
||||
pid, status
|
||||
);
|
||||
}
|
||||
false // 移除已回收的进程
|
||||
}
|
||||
Ok(None) => {
|
||||
// 进程仍在运行
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
// 查询失败,可能进程已不存在
|
||||
warn!("[ProcessReaper] Failed to query child PID={}: {}", pid, e);
|
||||
false // 移除无法查询的进程
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if reaped_now > 0 {
|
||||
self.reaped_count += reaped_now;
|
||||
info!(
|
||||
"[ProcessReaper] Reaped {} child processes (total: {})",
|
||||
reaped_now, self.reaped_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔍 主动检测系统中的僵尸进程
|
||||
///
|
||||
/// 扫描 /proc 文件系统,查找状态为 'Z' (Zombie) 的进程
|
||||
fn detect_zombie_processes(&mut self) -> Vec<ZombieProcessInfo> {
|
||||
let mut zombies = Vec::new();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let proc_path = "/proc";
|
||||
|
||||
// 读取 /proc 目录下的所有 PID 目录
|
||||
if let Ok(entries) = fs::read_dir(proc_path) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
// 检查是否是数字(PID 目录)
|
||||
if let Ok(pid) = name.to_string_lossy().parse::<u32>() {
|
||||
// 读取 /proc/[pid]/stat 文件
|
||||
let stat_path = entry.path().join("stat");
|
||||
if let Ok(content) = fs::read_to_string(&stat_path)
|
||||
&& let Some(info) = parse_stat_file(pid, &content)
|
||||
&& info.state == 'Z'
|
||||
{
|
||||
zombies.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !zombies.is_empty() {
|
||||
self.zombie_detected_count += zombies.len() as u64;
|
||||
warn!(
|
||||
"[ProcessReaper] Detected {} zombie processes (total detected: {})",
|
||||
zombies.len(),
|
||||
self.zombie_detected_count
|
||||
);
|
||||
|
||||
for zombie in &zombies {
|
||||
warn!(
|
||||
"[ProcessReaper] Zombie process: PID={}, PPID={}, CMD={}",
|
||||
zombie.pid, zombie.ppid, zombie.comm
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
zombies
|
||||
}
|
||||
|
||||
/// 🔧 主动清理所有僵尸进程
|
||||
///
|
||||
/// 使用 waitpid 循环回收所有可能的僵尸进程,不仅仅是追踪的子进程
|
||||
/// 这是 PID 1 的责任:回收所有孤儿进程
|
||||
fn reap_all_zombies_blocking(&mut self) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid};
|
||||
use nix::unistd::Pid;
|
||||
|
||||
let mut reaped_this_round = 0;
|
||||
|
||||
// 循环调用 waitpid,直到没有更多僵尸进程
|
||||
loop {
|
||||
match waitpid(
|
||||
Pid::from_raw(-1), // -1 表示等待任意子进程
|
||||
Some(WaitPidFlag::WNOHANG),
|
||||
) {
|
||||
Ok(WaitStatus::Exited(pid, exit_code)) => {
|
||||
reaped_this_round += 1;
|
||||
debug!(
|
||||
"[ProcessReaper] Reaped zombie proactively: PID={}, exit_code={}",
|
||||
pid, exit_code
|
||||
);
|
||||
}
|
||||
Ok(WaitStatus::Signaled(pid, signal, _)) => {
|
||||
reaped_this_round += 1;
|
||||
debug!(
|
||||
"[ProcessReaper] Reaped zombie proactively: PID={}, signal={:?}",
|
||||
pid, signal
|
||||
);
|
||||
}
|
||||
Ok(WaitStatus::StillAlive) => {
|
||||
// WNOHANG: 没有更多的僵尸进程
|
||||
break;
|
||||
}
|
||||
Ok(WaitStatus::Stopped(pid, signal)) => {
|
||||
// 进程被停止(不是退出),不计入回收
|
||||
debug!(
|
||||
"[ProcessReaper] Process stopped: PID={}, signal={:?}",
|
||||
pid, signal
|
||||
);
|
||||
// 继续循环,可能还有其他僵尸进程
|
||||
continue;
|
||||
}
|
||||
Ok(WaitStatus::Continued(pid)) => {
|
||||
// 进程被恢复(SIGCONT),不计入回收
|
||||
debug!("[ProcessReaper] Process resumed: PID={}", pid);
|
||||
// 继续循环,可能还有其他僵尸进程
|
||||
continue;
|
||||
}
|
||||
#[cfg(linux_android)]
|
||||
Ok(WaitStatus::PtraceEvent(pid, signal, event)) => {
|
||||
// ptrace 事件,不计入回收
|
||||
debug!(
|
||||
"[ProcessReaper] ptrace event: PID={}, signal={:?}, event={}",
|
||||
pid, signal, event
|
||||
);
|
||||
continue;
|
||||
}
|
||||
#[cfg(linux_android)]
|
||||
Ok(WaitStatus::PtraceSyscall(pid)) => {
|
||||
// ptrace 系统调用,不计入回收
|
||||
debug!("[ProcessReaper] ptrace syscall: PID={}", pid);
|
||||
continue;
|
||||
}
|
||||
// 非Linux平台忽略 ptrace 相关状态(macOS 上 WaitStatus 包含这些变体但不会实际触发)
|
||||
Ok(_) => {
|
||||
debug!("[ProcessReaper] Ignored waitpid status");
|
||||
continue;
|
||||
}
|
||||
Err(nix::errno::Errno::ECHILD) => {
|
||||
// 没有子进程
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[ProcessReaper] waitpid error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if reaped_this_round > 0 {
|
||||
self.reaped_count += reaped_this_round;
|
||||
info!(
|
||||
"[ProcessReaper] Reaped {} zombies proactively (total: {})",
|
||||
reaped_this_round, self.reaped_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
debug!("[ProcessReaper] Non-Unix platform, skipping zombie detection");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 /proc/[pid]/stat 文件
|
||||
///
|
||||
/// 文件格式:pid (comm) state ppid ...
|
||||
/// 示例:1 (init) S 0 0 0 0 ...
|
||||
fn parse_stat_file(pid: u32, content: &str) -> Option<ZombieProcessInfo> {
|
||||
// stat 文件格式:pid (comm) state ppid ...
|
||||
// 需要找到 comm 的结束括号
|
||||
let content = content.trim();
|
||||
|
||||
// 找到第一个 '(' 和最后一个 ')'
|
||||
let open_paren = content.find('(')?;
|
||||
let close_paren = content.rfind(')')?;
|
||||
|
||||
let comm = content[open_paren + 1..close_paren].to_string();
|
||||
let after_comm = &content[close_paren + 1..];
|
||||
|
||||
// 解析 state 和 ppid
|
||||
// 格式:) state ppid ...
|
||||
let parts: Vec<&str> = after_comm.split_whitespace().collect();
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let state = parts.first()?.chars().next()?;
|
||||
let ppid: u32 = parts.get(1)?.parse().ok()?;
|
||||
|
||||
Some(ZombieProcessInfo {
|
||||
pid,
|
||||
ppid,
|
||||
comm,
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
/// 启动进程回收器任务
|
||||
///
|
||||
/// 此函数会:
|
||||
/// 1. 注册 SIGCHLD 信号处理器
|
||||
/// 2. 在后台循环中等待信号并回收子进程
|
||||
/// 3. 定期主动检测和清理僵尸进程
|
||||
///
|
||||
/// # 返回值
|
||||
///
|
||||
/// 返回一个 JoinHandle,可以用于等待回收器任务退出(通常不需要)
|
||||
pub fn start_process_reaper() -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move { run_reaper(ReaperConfig::default()).await })
|
||||
}
|
||||
|
||||
/// 启动进程回收器任务(带配置)
|
||||
pub fn start_process_reaper_with_config(config: ReaperConfig) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move { run_reaper(config).await })
|
||||
}
|
||||
|
||||
/// 核心回收逻辑
|
||||
#[cfg(unix)]
|
||||
async fn run_reaper(config: ReaperConfig) {
|
||||
info!("[ProcessReaper] Zombie process reaper started (PID 1 mode)");
|
||||
if config.enable_zombie_detection {
|
||||
info!(
|
||||
"[ProcessReaper] Zombie detection enabled, interval: {} seconds",
|
||||
config.zombie_detection_interval_secs
|
||||
);
|
||||
}
|
||||
|
||||
// 创建 SIGCHLD 信号监听器
|
||||
let sigchld = match signal(SignalKind::child()) {
|
||||
Ok(sig) => sig,
|
||||
Err(e) => {
|
||||
error!("[ProcessReaper] Failed to register SIGCHLD handler: {}", e);
|
||||
error!("[ProcessReaper] Falling back to polling mode");
|
||||
|
||||
// 回退模式:使用轮询
|
||||
run_reaper_polling(config).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let state = ReaperState::new(config.clone());
|
||||
|
||||
// 启动定期轮询任务(作为信号机制的补充)
|
||||
let mut poll_interval = tokio::time::interval(std::time::Duration::from_secs(5));
|
||||
poll_interval.tick().await; // 跳过第一次立即触发
|
||||
|
||||
// 根据配置决定是否启用僵尸进程检测
|
||||
if config.enable_zombie_detection {
|
||||
run_reaper_with_detection(
|
||||
sigchld,
|
||||
poll_interval,
|
||||
state,
|
||||
config.zombie_detection_interval_secs,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
run_reaper_without_detection(sigchld, poll_interval, state).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows 上的回收逻辑(无操作,Windows 没有僵尸进程问题)
|
||||
#[cfg(not(unix))]
|
||||
async fn run_reaper(_config: ReaperConfig) {
|
||||
info!("[ProcessReaper] Non-Unix platform: zombie reaper not applicable");
|
||||
}
|
||||
|
||||
/// 🔍 启用僵尸进程检测的回收循环
|
||||
#[cfg(unix)]
|
||||
async fn run_reaper_with_detection(
|
||||
mut sigchld: tokio::signal::unix::Signal,
|
||||
mut poll_interval: tokio::time::Interval,
|
||||
mut state: ReaperState,
|
||||
detect_interval_secs: u64,
|
||||
) {
|
||||
let mut zombie_detect_interval =
|
||||
tokio::time::interval(std::time::Duration::from_secs(detect_interval_secs));
|
||||
zombie_detect_interval.tick().await; // 跳过第一次立即触发
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// 等待 SIGCHLD 信号
|
||||
_ = sigchld.recv() => {
|
||||
if state.config.verbose {
|
||||
debug!("[ProcessReaper] Received SIGCHLD");
|
||||
}
|
||||
state.reap_all();
|
||||
state.reap_all_zombies_blocking();
|
||||
}
|
||||
// 定期轮询(每 5 秒)
|
||||
_ = poll_interval.tick() => {
|
||||
state.reap_all();
|
||||
}
|
||||
// 🔍 定期主动检测和清理僵尸进程
|
||||
_ = zombie_detect_interval.tick() => {
|
||||
debug!("[ProcessReaper] Running scheduled zombie detection...");
|
||||
|
||||
// 先检测有哪些僵尸进程
|
||||
let zombies = state.detect_zombie_processes();
|
||||
|
||||
// 然后主动清理所有僵尸进程
|
||||
if !zombies.is_empty() {
|
||||
state.reap_all_zombies_blocking();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚫 不启用僵尸进程检测的回收循环
|
||||
#[cfg(unix)]
|
||||
async fn run_reaper_without_detection(
|
||||
mut sigchld: tokio::signal::unix::Signal,
|
||||
mut poll_interval: tokio::time::Interval,
|
||||
mut state: ReaperState,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
// 等待 SIGCHLD 信号
|
||||
_ = sigchld.recv() => {
|
||||
if state.config.verbose {
|
||||
debug!("[ProcessReaper] Received SIGCHLD");
|
||||
}
|
||||
state.reap_all();
|
||||
state.reap_all_zombies_blocking();
|
||||
}
|
||||
// 定期轮询(每 5 秒)
|
||||
_ = poll_interval.tick() => {
|
||||
state.reap_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 轮询模式回退(当信号机制不可用时)
|
||||
#[cfg(unix)]
|
||||
async fn run_reaper_polling(config: ReaperConfig) {
|
||||
info!("[ProcessReaper] Using polling mode for zombie reaping");
|
||||
|
||||
let state = ReaperState::new(config.clone());
|
||||
|
||||
// 根据配置决定是否启用僵尸进程检测
|
||||
if config.enable_zombie_detection {
|
||||
run_reaper_polling_with_detection(state, config.zombie_detection_interval_secs).await
|
||||
} else {
|
||||
run_reaper_polling_without_detection(state).await
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔍 轮询模式 + 僵尸进程检测
|
||||
#[cfg(unix)]
|
||||
async fn run_reaper_polling_with_detection(mut state: ReaperState, detect_interval_secs: u64) {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));
|
||||
let mut zombie_detect_interval =
|
||||
tokio::time::interval(std::time::Duration::from_secs(detect_interval_secs));
|
||||
zombie_detect_interval.tick().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
state.reap_all();
|
||||
state.reap_all_zombies_blocking();
|
||||
}
|
||||
_ = zombie_detect_interval.tick() => {
|
||||
let zombies = state.detect_zombie_processes();
|
||||
if !zombies.is_empty() {
|
||||
state.reap_all_zombies_blocking();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚫 轮询模式(无僵尸进程检测)
|
||||
#[cfg(unix)]
|
||||
async fn run_reaper_polling_without_detection(mut state: ReaperState) {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
state.reap_all();
|
||||
state.reap_all_zombies_blocking();
|
||||
}
|
||||
}
|
||||
|
||||
/// 回收器句柄(可选:用于外部注册子进程)
|
||||
///
|
||||
/// 注意:当前实现中,子进程由各自创建者管理。
|
||||
/// 此结构保留用于未来扩展,例如中央化子进程管理。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcessReaperHandle {
|
||||
_config: ReaperConfig,
|
||||
}
|
||||
|
||||
impl ProcessReaperHandle {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
_config: ReaperConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔍 手动触发僵尸进程检测(仅用于调试)
|
||||
pub fn detect_zombies_now(&self) -> Vec<ZombieProcessInfo> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let proc_path = "/proc";
|
||||
let mut zombies = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(proc_path) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if let Ok(pid) = name.to_string_lossy().parse::<u32>() {
|
||||
let stat_path = entry.path().join("stat");
|
||||
if let Ok(content) = fs::read_to_string(&stat_path)
|
||||
&& let Some(info) = parse_stat_file(pid, &content)
|
||||
&& info.state == 'Z'
|
||||
{
|
||||
zombies.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
zombies
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册一个子进程(未来扩展)
|
||||
#[allow(dead_code)]
|
||||
pub fn register(&self, _child: Child) {
|
||||
// 当前实现中,子进程由各自的创建者负责回收
|
||||
// 此方法保留用于未来中央化管理的扩展
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProcessReaperHandle {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Stdio;
|
||||
use tokio::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reaper_state() {
|
||||
let config = ReaperConfig {
|
||||
verbose: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut state = ReaperState::new(config);
|
||||
|
||||
// 创建一个长时间运行的进程
|
||||
let child = tokio::process::Command::new("sleep")
|
||||
.arg("10")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let pid = child.id().unwrap();
|
||||
state.register_child(child);
|
||||
|
||||
// 立即调用 reap_all,进程应该还在运行
|
||||
state.reap_all();
|
||||
|
||||
// 验证进程仍在列表中(因为还没退出)
|
||||
assert!(state.active_children.contains_key(&pid));
|
||||
assert_eq!(state.reaped_count, 0); // 还没有回收任何进程
|
||||
|
||||
// 清理:杀死进程
|
||||
if let Some(mut child) = state.active_children.remove(&pid) {
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_long_running_process() {
|
||||
let config = ReaperConfig::default();
|
||||
let mut state = ReaperState::new(config);
|
||||
|
||||
// 创建一个长时间运行的进程
|
||||
let child = tokio::process::Command::new("sleep")
|
||||
.arg("10")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let pid = child.id().unwrap();
|
||||
state.register_child(child);
|
||||
|
||||
// 立即尝试回收,进程应该还在运行
|
||||
state.reap_all();
|
||||
|
||||
// 验证进程仍在列表中
|
||||
assert!(state.active_children.contains_key(&pid));
|
||||
|
||||
// 清理:杀死进程
|
||||
if let Some(mut child) = state.active_children.remove(&pid) {
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_start_process_reaper() {
|
||||
let handle = start_process_reaper();
|
||||
|
||||
// 创建几个快速退出的子进程
|
||||
for _ in 0..3 {
|
||||
let _ = tokio::process::Command::new("true")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
|
||||
// 等待回收器处理
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// 回收器应该仍在运行
|
||||
assert!(!handle.is_finished());
|
||||
|
||||
// 取消任务
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stat_file() {
|
||||
let content = "1 (init) S 0 0 0 0 -1 4194560 667 5569406 8 23660837 1 0 0 0 0 0 0 0 20 0 1 0 3642608 1340 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0";
|
||||
let info = parse_stat_file(1, content).unwrap();
|
||||
|
||||
assert_eq!(info.pid, 1);
|
||||
assert_eq!(info.ppid, 0);
|
||||
assert_eq!(info.comm, "init");
|
||||
assert_eq!(info.state, 'S');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stat_file_with_parentheses_in_comm() {
|
||||
// 进程名包含括号的情况
|
||||
let content = "1234 (test(a)b)) Z 1 1234 1234 0 -1 4194560 0 0 0 0 0 0 0 0 20 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0";
|
||||
let info = parse_stat_file(1234, content).unwrap();
|
||||
|
||||
assert_eq!(info.pid, 1234);
|
||||
assert_eq!(info.ppid, 1);
|
||||
assert_eq!(info.comm, "test(a)b)");
|
||||
assert_eq!(info.state, 'Z'); // 僵尸进程
|
||||
}
|
||||
}
|
||||
111
qiming-rcoder/crates/agent_runner/src/profiler.rs
Normal file
111
qiming-rcoder/crates/agent_runner/src/profiler.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! Pyroscope Profiler 集成模块
|
||||
//!
|
||||
//! 提供连续性能分析功能,包括:
|
||||
//! - CPU Profiling
|
||||
//! - Memory Profiling (Heap/Allocs)
|
||||
//! - Stack trace sampling
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pyroscope::PyroscopeAgent;
|
||||
use pyroscope_pprofrs::{PprofConfig, pprof_backend};
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Profiler 配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProfilerConfig {
|
||||
/// Pyroscope Server 地址
|
||||
pub server_url: String,
|
||||
/// 应用名称(支持标签格式)
|
||||
pub application_name: String,
|
||||
/// 采样频率(Hz)
|
||||
pub sample_rate: u32,
|
||||
/// 是否启用内存 profiling
|
||||
pub enable_memory: bool,
|
||||
/// 是否启用 CPU profiling
|
||||
pub enable_cpu: bool,
|
||||
}
|
||||
|
||||
impl Default for ProfilerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server_url: "http://pyroscope:4040".to_string(),
|
||||
application_name: "agent_runner{env=dev}".to_string(),
|
||||
sample_rate: 100,
|
||||
enable_memory: true,
|
||||
enable_cpu: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProfilerConfig {
|
||||
/// 从环境变量加载配置
|
||||
pub fn from_env() -> Self {
|
||||
let mut config = Self::default();
|
||||
|
||||
if let Ok(url) = std::env::var("PYROSCOPE_URL") {
|
||||
config.server_url = url;
|
||||
}
|
||||
|
||||
if let Ok(name) = std::env::var("PYROSCOPE_APP_NAME") {
|
||||
config.application_name = name;
|
||||
}
|
||||
|
||||
if let Ok(project_id) = std::env::var("PROJECT_ID") {
|
||||
config.application_name = format!("agent_runner{{project_id={}}}", project_id);
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
/// Profiler Guard
|
||||
///
|
||||
/// 当 dropped 时自动停止 profiler
|
||||
pub struct ProfilerGuard {
|
||||
_agent: Option<pyroscope::PyroscopeAgent<pyroscope::pyroscope::PyroscopeAgentRunning>>,
|
||||
}
|
||||
|
||||
/// 初始化并启动 Pyroscope Profiler
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 如果 profiler 初始化失败,返回错误
|
||||
pub fn init_pyroscope_profiler(config: ProfilerConfig) -> Result<ProfilerGuard> {
|
||||
if !config.enable_cpu && !config.enable_memory {
|
||||
debug!("Pyroscope profiler disabled, skipping initialization");
|
||||
return Ok(ProfilerGuard { _agent: None });
|
||||
}
|
||||
|
||||
info!(
|
||||
"Initializing Pyroscope profiler: {}",
|
||||
config.application_name
|
||||
);
|
||||
info!(" Server URL: {}", config.server_url);
|
||||
info!(" Sample rate: {} Hz", config.sample_rate);
|
||||
info!(" CPU profiling: {}", config.enable_cpu);
|
||||
info!(" Memory profiling: {}", config.enable_memory);
|
||||
|
||||
// 使用正确的 API: builder() -> backend() -> build()
|
||||
let agent = PyroscopeAgent::builder(config.server_url, config.application_name)
|
||||
.backend(pprof_backend(
|
||||
PprofConfig::new().sample_rate(config.sample_rate),
|
||||
))
|
||||
.build()
|
||||
.context("Failed to build Pyroscope agent")?;
|
||||
|
||||
// 启动 profiling,返回 PyroscopeAgent<PyroscopeAgentRunning>
|
||||
let agent_running = agent
|
||||
.start()
|
||||
.context("Failed to start Pyroscope profiler")?;
|
||||
|
||||
info!("Pyroscope profiler started successfully");
|
||||
|
||||
Ok(ProfilerGuard {
|
||||
_agent: Some(agent_running),
|
||||
})
|
||||
}
|
||||
|
||||
/// 便捷函数:使用默认配置初始化 profiler
|
||||
pub fn init_pyroscope_profiler_default() -> Result<ProfilerGuard> {
|
||||
init_pyroscope_profiler(ProfilerConfig::from_env())
|
||||
}
|
||||
618
qiming-rcoder/crates/agent_runner/src/proxy_agent/acp_agent.rs
Normal file
618
qiming-rcoder/crates/agent_runner/src/proxy_agent/acp_agent.rs
Normal file
@@ -0,0 +1,618 @@
|
||||
//! ACP Agent Worker 模块 (SACP 版本)
|
||||
//!
|
||||
//! 负责处理 Agent 请求队列,管理 Agent 会话的创建和复用。
|
||||
//! 使用 AcpSessionManager 进行会话管理。
|
||||
//!
|
||||
//! ## SACP 迁移说明
|
||||
//!
|
||||
//! - 移除了 `spawn_blocking` + `LocalSet` 模式(SACP 支持 Send trait)
|
||||
//! - 使用标准 `tokio::spawn` 进行并发处理
|
||||
//! - 简化了并发模型,提高性能
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use dashmap::DashMap;
|
||||
|
||||
use agent_abstraction::session::AcpSessionManager;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use shared_types::ModelProviderConfig;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use agent_client_protocol::schema::SessionId;
|
||||
|
||||
use crate::{
|
||||
agent_runtime::get_concurrency_limit,
|
||||
model::{AgentStatus, ChatPromptResponse, ProjectAndAgentInfo},
|
||||
proxy_agent::SESSION_REQUEST_CONTEXT,
|
||||
service::{AGENT_REGISTRY, AgentSessionRegistry, StateAwareNotifier},
|
||||
utils::ContentBuilder,
|
||||
};
|
||||
|
||||
/// 🔥 配置标志:是否为无限制模式(HTTP Server 部署)
|
||||
static IS_UNLIMITED_MODE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 设置运行模式(供 main.rs 调用)
|
||||
pub fn set_unlimited_mode(enabled: bool) {
|
||||
IS_UNLIMITED_MODE.store(enabled, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
// 🔥 OpenTelemetry 追踪
|
||||
#[cfg(feature = "otel")]
|
||||
use crate::otel_tracing::RequestSpan;
|
||||
|
||||
// 🔥 简化版本:如果没有 OpenTelemetry,使用空的 span
|
||||
#[cfg(not(feature = "otel"))]
|
||||
struct RequestSpan;
|
||||
|
||||
#[cfg(not(feature = "otel"))]
|
||||
impl RequestSpan {
|
||||
fn new(_project_id: &str, _request_id: &str, _operation: &str) -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent 请求结构 (SACP 版本)
|
||||
///
|
||||
/// 不再需要 LocalSet,直接在 tokio::spawn 中运行
|
||||
#[derive(Debug)]
|
||||
pub struct AgentRequest {
|
||||
/// Agent 抽象层的 prompt 消息
|
||||
prompt_message: agent_abstraction::PromptMessage,
|
||||
/// 发送回执消息的通道
|
||||
chat_prompt_tx: oneshot::Sender<ChatPromptResponse>,
|
||||
/// 模型提供商配置
|
||||
model_provider: Option<ModelProviderConfig>,
|
||||
/// 🔥 关联的 service UUID(用于 API 密钥管理)
|
||||
service_uuid: Option<String>,
|
||||
/// 🔥 共享的 API 密钥管理器(用于自动清理)
|
||||
shared_api_key_manager: Option<Arc<DashMap<String, ModelProviderConfig>>>,
|
||||
/// 是否跳过槽位限制(HTTP Server 宿主机部署时为 true)
|
||||
skip_slot_limit: bool,
|
||||
}
|
||||
|
||||
impl AgentRequest {
|
||||
pub fn new(
|
||||
prompt_message: agent_abstraction::PromptMessage,
|
||||
model_provider: Option<ModelProviderConfig>,
|
||||
) -> (Self, oneshot::Receiver<ChatPromptResponse>) {
|
||||
let (chat_prompt_tx, chat_prompt_rx) = oneshot::channel();
|
||||
(
|
||||
Self {
|
||||
prompt_message,
|
||||
chat_prompt_tx,
|
||||
model_provider,
|
||||
service_uuid: None,
|
||||
shared_api_key_manager: None,
|
||||
skip_slot_limit: false,
|
||||
},
|
||||
chat_prompt_rx,
|
||||
)
|
||||
}
|
||||
|
||||
/// 设置 service_uuid
|
||||
pub fn with_service_uuid(mut self, service_uuid: Option<String>) -> Self {
|
||||
self.service_uuid = service_uuid;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置 shared_api_key_manager
|
||||
pub fn with_key_manager(
|
||||
mut self,
|
||||
key_manager: Option<Arc<DashMap<String, ModelProviderConfig>>>,
|
||||
) -> Self {
|
||||
self.shared_api_key_manager = key_manager;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置是否跳过槽位限制
|
||||
pub fn with_skip_slot_limit(mut self, skip: bool) -> Self {
|
||||
self.skip_slot_limit = skip;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent Worker 任务
|
||||
///
|
||||
/// 使用标准 tokio::spawn 处理 Agent 请求队列。
|
||||
/// SACP 支持 Send trait,无需 LocalSet。
|
||||
pub async fn agent_worker(mut request_rx: mpsc::UnboundedReceiver<AgentRequest>) -> Result<()> {
|
||||
use agent_abstraction::session::{AcpAgentWorker, AgentWorker, WorkerRequest};
|
||||
|
||||
info!("agent_worker started (SACP version), listening for requests...");
|
||||
|
||||
// 创建 AcpSessionManager,注入 AGENT_REGISTRY 作为 SessionRegistry
|
||||
// SACP 版本只需要 2 个泛型参数:N (SessionNotifier) 和 R (SessionRegistry)
|
||||
let session_manager = Arc::new(
|
||||
AcpSessionManager::<StateAwareNotifier, AgentSessionRegistry>::new(
|
||||
Arc::new(StateAwareNotifier::new()),
|
||||
AGENT_REGISTRY.clone(),
|
||||
),
|
||||
);
|
||||
|
||||
// 创建 AcpAgentWorker
|
||||
let worker = AcpAgentWorker::new(session_manager);
|
||||
|
||||
while let Some(request) = request_rx.recv().await {
|
||||
let project_id = request.prompt_message.project_id.clone();
|
||||
let request_id = request.prompt_message.request_id.clone();
|
||||
|
||||
info!(
|
||||
"📨 Received request, project_id: {}, request_id: {}",
|
||||
project_id, request_id
|
||||
);
|
||||
|
||||
// 1. 预处理附件(agent_runner 特有逻辑)
|
||||
let attachment_blocks = if !request.prompt_message.attachments.is_empty() {
|
||||
match ContentBuilder::attachments_to_content_blocks(
|
||||
&request.prompt_message.attachments,
|
||||
&request.prompt_message.project_path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(blocks) => Some(blocks),
|
||||
Err(e) => {
|
||||
error!("Attachment processing failed: {:?}", e);
|
||||
|
||||
if let Err(send_err) = request.chat_prompt_tx.send(ChatPromptResponse {
|
||||
project_id: project_id.clone(),
|
||||
session_id: String::new(),
|
||||
code: shared_types::error_codes::ERR_AGENT_ERROR.to_string(),
|
||||
error: Some(format!(
|
||||
"{}: {:?}",
|
||||
shared_types::error_codes::get_i18n_message_default("error.attachment_processing_failed"),
|
||||
e
|
||||
)),
|
||||
request_id: Some(request_id),
|
||||
service_type: request.prompt_message.service_type.clone(),
|
||||
}) {
|
||||
error!(
|
||||
"Failed to send error response (receiver closed): {:?}",
|
||||
send_err
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 2. 创建 WorkerRequest
|
||||
let worker_request = WorkerRequest {
|
||||
prompt_message: request.prompt_message.clone(),
|
||||
model_provider: request.model_provider.clone(),
|
||||
attachment_blocks,
|
||||
service_uuid: request.service_uuid.clone(),
|
||||
shared_api_key_manager: request.shared_api_key_manager.clone(),
|
||||
};
|
||||
|
||||
// 3. 调用 AcpAgentWorker 处理(核心业务逻辑)
|
||||
let worker_response = match worker.process_request(worker_request).await {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
error!("Worker processing failed: {:?}", e);
|
||||
|
||||
if let Err(send_err) = request.chat_prompt_tx.send(ChatPromptResponse {
|
||||
project_id: project_id.clone(),
|
||||
session_id: String::new(),
|
||||
code: shared_types::error_codes::ERR_AGENT_ERROR.to_string(),
|
||||
error: Some(format!(
|
||||
"{}: {:?}",
|
||||
shared_types::error_codes::get_i18n_message_default("error.processing_failed"),
|
||||
e
|
||||
)),
|
||||
request_id: Some(request_id.clone()),
|
||||
service_type: request.prompt_message.service_type.clone(),
|
||||
}) {
|
||||
error!(
|
||||
"Failed to send error response (receiver closed): {:?}",
|
||||
send_err
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 4. 更新全局状态(使用统一的 AGENT_REGISTRY)
|
||||
if worker_response.is_new_session {
|
||||
if let Some(handles) = &worker_response.session_handles {
|
||||
debug!("🆕 New session, registering in AGENT_REGISTRY");
|
||||
|
||||
let project_and_agent_info = ProjectAndAgentInfo {
|
||||
project_id: project_id.clone(),
|
||||
session_id: SessionId::new(Arc::from(worker_response.session_id.as_str())),
|
||||
prompt_tx: handles.prompt_tx.clone(),
|
||||
cancel_tx: handles.cancel_tx.clone(),
|
||||
model_provider: request.model_provider.clone(),
|
||||
request_id: Some(request_id.clone()),
|
||||
status: AgentStatus::Active, // 🆕 修复:Worker 处理中应为 Active,而非 Idle
|
||||
last_activity: Utc::now(),
|
||||
created_at: Utc::now(),
|
||||
stop_handle: handles.lifecycle_handle.clone(),
|
||||
};
|
||||
|
||||
// 使用统一的 AGENT_REGISTRY 注册(自动处理所有映射)
|
||||
AGENT_REGISTRY.register(
|
||||
&project_id,
|
||||
&worker_response.session_id,
|
||||
project_and_agent_info,
|
||||
);
|
||||
|
||||
info!(
|
||||
"🔗 Agent 已注册到 AGENT_REGISTRY: project_id={}, session_id={}",
|
||||
project_id, worker_response.session_id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debug!("♻️ Reusing session, no global Registry update needed");
|
||||
}
|
||||
|
||||
// 5. 更新 SESSION_REQUEST_CONTEXT(请求追踪)
|
||||
SESSION_REQUEST_CONTEXT.insert(project_id, request_id.clone());
|
||||
|
||||
// 6. 转换并发送回执
|
||||
let chat_prompt_response = ChatPromptResponse {
|
||||
project_id: worker_response.project_id,
|
||||
session_id: worker_response.session_id,
|
||||
code: if worker_response.error.is_none() {
|
||||
shared_types::error_codes::SUCCESS.to_string()
|
||||
} else {
|
||||
shared_types::error_codes::ERR_AGENT_ERROR.to_string()
|
||||
},
|
||||
error: worker_response.error,
|
||||
request_id: worker_response.request_id,
|
||||
service_type: worker_response.service_type,
|
||||
};
|
||||
|
||||
if let Err(e) = request.chat_prompt_tx.send(chat_prompt_response) {
|
||||
error!("Failed to send acknowledgment: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
info!("🛑 agent_worker stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 带心跳的 Agent Worker (SACP 版本) - 新架构
|
||||
///
|
||||
/// 使用标准 tokio::spawn 进行并发处理(无需 LocalSet)
|
||||
/// SACP 的 Component<L> trait 要求 Send + 'static,因此可以安全地在多线程环境中使用
|
||||
///
|
||||
/// ## 参数变化
|
||||
///
|
||||
/// - `request_rx`: 使用有界 channel 代替 unbounded
|
||||
/// - `state`: 使用 `Arc<AtomicState>` 代替 `WorkerHandle`
|
||||
/// - `last_heartbeat_ts`: 🔥 P1 修复: 使用 `Arc<AtomicI64>` 代替 `Arc<Mutex<Option<DateTime>>>`
|
||||
/// - `active_requests`: 直接访问,用于请求追踪
|
||||
pub async fn agent_worker_with_heartbeat(
|
||||
mut request_rx: mpsc::Receiver<AgentRequest>,
|
||||
state: Arc<crate::agent_runtime::AtomicState>,
|
||||
last_heartbeat_ts: Arc<std::sync::atomic::AtomicI64>,
|
||||
active_requests: Arc<tokio::sync::Mutex<HashMap<String, chrono::DateTime<chrono::Utc>>>>,
|
||||
) -> Result<()> {
|
||||
info!("agent_worker started (SACP version with heartbeat), listening for requests...");
|
||||
|
||||
use agent_abstraction::session::{AcpAgentWorker, AgentWorker, WorkerRequest};
|
||||
use tokio::time::{Duration, interval};
|
||||
|
||||
// 创建 AcpSessionManager,注入 AGENT_REGISTRY 作为 SessionRegistry
|
||||
// SACP 版本只需要 2 个泛型参数:N (SessionNotifier) 和 R (SessionRegistry)
|
||||
let session_manager = Arc::new(
|
||||
AcpSessionManager::<StateAwareNotifier, AgentSessionRegistry>::new(
|
||||
Arc::new(StateAwareNotifier::new()),
|
||||
AGENT_REGISTRY.clone(),
|
||||
),
|
||||
);
|
||||
|
||||
// 创建 AcpAgentWorker
|
||||
let worker = AcpAgentWorker::new(session_manager);
|
||||
|
||||
// 设置状态为 Running(就绪信号)
|
||||
state.set(crate::agent_runtime::WorkerState::Running);
|
||||
info!("[Worker] SACP Worker initialized, state set to Running");
|
||||
|
||||
// 启动心跳任务 - 🔥 P1 修复: 使用原子操作直接更新 last_heartbeat_ts
|
||||
let last_heartbeat_ts_clone = last_heartbeat_ts.clone();
|
||||
let heartbeat_task = tokio::spawn(async move {
|
||||
let mut heartbeat_interval = interval(Duration::from_secs(5));
|
||||
loop {
|
||||
heartbeat_interval.tick().await;
|
||||
let timestamp = Utc::now();
|
||||
|
||||
// 📊 打印当前 Worker 占用情况
|
||||
if IS_UNLIMITED_MODE.load(Ordering::SeqCst) {
|
||||
// 无限制模式(HTTP Server 部署)- 不显示具体数量,避免误解
|
||||
info!("💓 [Worker] Heartbeat - active sessions: (unlimited)");
|
||||
} else {
|
||||
// 限制模式(Docker 容器部署)
|
||||
let active = AGENT_REGISTRY.stats().agent_count;
|
||||
let limit = get_concurrency_limit();
|
||||
info!(
|
||||
"💓 [Worker] Heartbeat - active sessions: {}/{}",
|
||||
active, limit
|
||||
);
|
||||
}
|
||||
|
||||
// 🔥 P1 修复: 使用原子操作直接更新时间戳(无锁)
|
||||
last_heartbeat_ts_clone.store(
|
||||
timestamp.timestamp_millis(),
|
||||
std::sync::atomic::Ordering::Release,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 主处理循环 - SACP 版本:使用标准 tokio::spawn 进行并发处理
|
||||
while let Some(request) = request_rx.recv().await {
|
||||
let project_id = request.prompt_message.project_id.clone();
|
||||
let request_id = request.prompt_message.request_id.clone();
|
||||
|
||||
info!(
|
||||
"📨 Received request, project_id: {}, request_id: {} - SACP concurrent processing",
|
||||
project_id, request_id
|
||||
);
|
||||
|
||||
// 克隆需要的变量,用于 spawn 任务
|
||||
let worker_clone = worker.clone();
|
||||
|
||||
// 🚀 SACP 版本:直接使用 tokio::spawn(无需 spawn_blocking + LocalSet)
|
||||
// SACP 的 Component<L> trait 要求 Send + 'static,因此可以安全地在多线程环境中使用
|
||||
tokio::spawn(async move {
|
||||
info!(
|
||||
"🔵 [SACP] 开始处理请求 project_id={}, request_id={}",
|
||||
project_id, request_id
|
||||
);
|
||||
|
||||
// 🔥 OpenTelemetry 追踪: 创建请求 span
|
||||
let _otel_span = RequestSpan::new(&project_id, &request_id, "process_agent_request");
|
||||
|
||||
// 1. 预处理附件(agent_runner 特有逻辑)
|
||||
let attachment_blocks = if !request.prompt_message.attachments.is_empty() {
|
||||
match ContentBuilder::attachments_to_content_blocks(
|
||||
&request.prompt_message.attachments,
|
||||
&request.prompt_message.project_path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(blocks) => Some(blocks),
|
||||
Err(e) => {
|
||||
error!("Attachment processing failed: {:?}", e);
|
||||
|
||||
// 🔥 DeferGuard 自动清理,无需手动调用 clear_pending_if_exists
|
||||
|
||||
if let Err(send_err) = request.chat_prompt_tx.send(ChatPromptResponse {
|
||||
project_id: project_id.clone(),
|
||||
session_id: String::new(),
|
||||
code: shared_types::error_codes::ERR_AGENT_ERROR.to_string(),
|
||||
error: Some(format!(
|
||||
"{}: {:?}",
|
||||
shared_types::error_codes::get_i18n_message_default("error.attachment_processing_failed"),
|
||||
e
|
||||
)),
|
||||
request_id: Some(request_id.clone()),
|
||||
service_type: request.prompt_message.service_type.clone(),
|
||||
}) {
|
||||
error!(
|
||||
"Failed to send error response (receiver closed): {:?}",
|
||||
send_err
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 2. 创建 WorkerRequest
|
||||
let worker_request = WorkerRequest {
|
||||
prompt_message: request.prompt_message.clone(),
|
||||
model_provider: request.model_provider.clone(),
|
||||
attachment_blocks,
|
||||
service_uuid: request.service_uuid.clone(),
|
||||
shared_api_key_manager: request.shared_api_key_manager.clone(),
|
||||
};
|
||||
|
||||
// 3. 调用 AcpAgentWorker 处理(核心业务逻辑)
|
||||
let worker_response = match worker_clone.process_request(worker_request).await {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
error!("Worker processing failed: {:?}", e);
|
||||
|
||||
// 🔥 DeferGuard 自动清理,无需手动调用 clear_pending_if_exists
|
||||
|
||||
if let Err(send_err) = request.chat_prompt_tx.send(ChatPromptResponse {
|
||||
project_id: project_id.clone(),
|
||||
session_id: String::new(),
|
||||
code: shared_types::error_codes::ERR_AGENT_ERROR.to_string(),
|
||||
error: Some(format!(
|
||||
"{}: {:?}",
|
||||
shared_types::error_codes::get_i18n_message_default("error.processing_failed"),
|
||||
e
|
||||
)),
|
||||
request_id: Some(request_id.clone()),
|
||||
service_type: request.prompt_message.service_type.clone(),
|
||||
}) {
|
||||
error!(
|
||||
"Failed to send error response (receiver closed): {:?}",
|
||||
send_err
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 4. 提取 session_handles(在移动 worker_response 之前)
|
||||
// 关键:需要保存 lifecycle_handle 用于后续等待会话结束
|
||||
let session_handles = worker_response.session_handles.clone();
|
||||
let is_new_session = worker_response.is_new_session;
|
||||
let response_session_id = worker_response.session_id.clone();
|
||||
|
||||
// 5. 更新全局状态(使用统一的 AGENT_REGISTRY)
|
||||
if is_new_session {
|
||||
// 🔥 修复:槽位对应 Agent 生命周期,只在创建新 Agent 时获取槽位
|
||||
// HTTP Server 部署模式跳过槽位限制
|
||||
if request.skip_slot_limit {
|
||||
info!(
|
||||
"⏭️ [原子槽位] 跳过限制(无限制模式): project_id={}",
|
||||
project_id
|
||||
);
|
||||
} else if !AGENT_REGISTRY.try_acquire_session_slot() {
|
||||
let limit = get_concurrency_limit();
|
||||
error!(
|
||||
"🛡️ [原子并发限制] Agent 会话槽位已满 ({}/{}), 拒绝新请求 - project_id={}, request_id={}",
|
||||
AGENT_REGISTRY.active_sessions_count(),
|
||||
limit,
|
||||
project_id,
|
||||
request_id
|
||||
);
|
||||
|
||||
// 清理 Pending 状态(如果已设置)
|
||||
AGENT_REGISTRY.clear_pending_if_exists(&project_id);
|
||||
|
||||
if let Err(send_err) = request.chat_prompt_tx.send(ChatPromptResponse {
|
||||
project_id: project_id.clone(),
|
||||
session_id: String::new(),
|
||||
code: shared_types::error_codes::ERR_TOO_MANY_REQUESTS.to_string(),
|
||||
error: Some(format!(
|
||||
"{}",
|
||||
shared_types::error_codes::get_i18n_message_default("error.system_busy")
|
||||
).replace("{}", &limit.to_string())),
|
||||
request_id: Some(request_id.clone()),
|
||||
service_type: request.prompt_message.service_type.clone(),
|
||||
}) {
|
||||
error!(
|
||||
"Failed to send reject response (receiver closed): {:?}",
|
||||
send_err
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
info!(
|
||||
"✅ [原子槽位] 成功获取槽位: {}/{} - project_id={}",
|
||||
AGENT_REGISTRY.active_sessions_count(),
|
||||
get_concurrency_limit(),
|
||||
project_id
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref handles) = session_handles {
|
||||
debug!("🆕 New session, registering in AGENT_REGISTRY");
|
||||
|
||||
let project_and_agent_info = ProjectAndAgentInfo {
|
||||
project_id: project_id.clone(),
|
||||
session_id: SessionId::new(Arc::from(response_session_id.as_str())),
|
||||
prompt_tx: handles.prompt_tx.clone(),
|
||||
cancel_tx: handles.cancel_tx.clone(),
|
||||
model_provider: request.model_provider.clone(),
|
||||
request_id: Some(request_id.clone()),
|
||||
status: AgentStatus::Active,
|
||||
last_activity: Utc::now(),
|
||||
created_at: Utc::now(),
|
||||
stop_handle: handles.lifecycle_handle.clone(),
|
||||
};
|
||||
|
||||
AGENT_REGISTRY.register(
|
||||
&project_id,
|
||||
&response_session_id,
|
||||
project_and_agent_info,
|
||||
);
|
||||
|
||||
info!(
|
||||
"🔗 Agent 已注册到 AGENT_REGISTRY: project_id={}, session_id={}",
|
||||
project_id, response_session_id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debug!("♻️ Reusing session, no new slot needed (Agent already holds slot)");
|
||||
}
|
||||
|
||||
// 6. 更新 SESSION_REQUEST_CONTEXT(请求追踪)
|
||||
SESSION_REQUEST_CONTEXT.insert(project_id.clone(), request_id.clone());
|
||||
|
||||
// 7. 转换并发送回执
|
||||
let chat_prompt_response = ChatPromptResponse {
|
||||
project_id: worker_response.project_id,
|
||||
session_id: worker_response.session_id,
|
||||
code: if worker_response.error.is_none() {
|
||||
shared_types::error_codes::SUCCESS.to_string()
|
||||
} else {
|
||||
shared_types::error_codes::ERR_AGENT_ERROR.to_string()
|
||||
},
|
||||
error: worker_response.error,
|
||||
request_id: worker_response.request_id,
|
||||
service_type: worker_response.service_type,
|
||||
};
|
||||
|
||||
if let Err(e) = request.chat_prompt_tx.send(chat_prompt_response) {
|
||||
error!("Failed to send acknowledgment: {:?}", e);
|
||||
} else {
|
||||
info!(
|
||||
"✅ 回执已发送,project_id: {}",
|
||||
request.prompt_message.project_id
|
||||
);
|
||||
}
|
||||
|
||||
// SACP 版本:生命周期管理简化
|
||||
//
|
||||
// 架构设计:
|
||||
// - 槽位对应 Agent 生命周期,而非每次请求
|
||||
// - 新会话:等待 Agent 生命周期结束,然后清理
|
||||
// - 复用会话:立即退出,不释放槽位(因为 Agent 还在运行)
|
||||
if is_new_session {
|
||||
if let Some(ref handles) = session_handles {
|
||||
if let Some(ref lifecycle) = handles.lifecycle_handle {
|
||||
info!(
|
||||
"🔄 [SACP] 新会话:等待 Agent 生命周期 - project_id={}, session_id={}",
|
||||
project_id, response_session_id
|
||||
);
|
||||
|
||||
// 等待以下任一事件:
|
||||
// 1. 用户调用 stop_agent → lifecycle.cancel()
|
||||
// 2. 清理任务停止闲置 Agent(5分钟)→ lifecycle.graceful_stop()
|
||||
// 3. Agent 进程异常退出
|
||||
lifecycle.cancellation_token().cancelled().await;
|
||||
|
||||
// 🔥 关键修复:lifecycle 结束后,主动清理 Agent 并释放槽位
|
||||
// 使用 session-aware 移除,避免旧 session 的 cleanup 误删新 session 的 registry 条目。
|
||||
// 场景:用户快速发送多条消息时,旧 session 被取消,新 session 注册。
|
||||
// 旧 session 的 spawned task 退出时,registry 中已是新 session 的条目。
|
||||
// 如果用 remove_by_project 会误删新 session,导致新请求超时。
|
||||
|
||||
AGENT_REGISTRY.remove_by_project_if_session_matches(&project_id, &response_session_id);
|
||||
|
||||
info!(
|
||||
"🛑 [SACP] Agent 生命周期结束,已清理 Registry - project_id={}, session_id={}",
|
||||
project_id, response_session_id
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"⚠️ [SACP] 新会话缺少 lifecycle_handle - project_id={}",
|
||||
project_id
|
||||
);
|
||||
// 缺少 lifecycle_handle,立即清理
|
||||
AGENT_REGISTRY.remove_by_project_if_session_matches(&project_id, &response_session_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!(
|
||||
"🔵 [SACP] 复用会话:请求处理完成 - project_id={}, session_id={}",
|
||||
project_id, response_session_id
|
||||
);
|
||||
// 复用会话时不释放槽位,因为槽位对应 Agent 生命周期
|
||||
// 而不是每次请求。Agent 还在运行,槽位应保持占用状态
|
||||
}
|
||||
});
|
||||
|
||||
// 立即继续循环,接收下一个请求 - 不等待上面的 spawn 完成
|
||||
}
|
||||
|
||||
// 清理心跳任务
|
||||
heartbeat_task.abort();
|
||||
|
||||
info!("🛑 agent_worker stopped");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! 定期清理闲置agent的任务
|
||||
//!
|
||||
//! 基于AgentLifecycleGuard的RAII原则,简化清理逻辑:
|
||||
//! 1. 定时扫描识别闲置的agent
|
||||
//! 2. 从PROJECT_AND_AGENT_INFO_MAP中移除
|
||||
//! 3. AgentLifecycleGuard自动drop并清理资源
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::model::AgentStatus;
|
||||
use crate::service::{AGENT_REGISTRY, SESSION_CACHE};
|
||||
|
||||
/// 清理配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CleanupConfig {
|
||||
/// 闲置超时时间(默认30分钟)
|
||||
pub idle_timeout: Duration,
|
||||
/// 清理检查间隔(默认5分钟)
|
||||
pub cleanup_interval: Duration,
|
||||
// 注意:force_terminate_timeout 字段已移除
|
||||
// 因为采用RAII模式,AgentLifecycleGuard会自动处理资源清理
|
||||
// 不需要强制终止超时机制
|
||||
}
|
||||
|
||||
impl Default for CleanupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
idle_timeout: Duration::from_secs(3 * 60), // 🆕 默认3分钟
|
||||
cleanup_interval: Duration::from_secs(30), // 默认30秒
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理任务统计信息
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CleanupStats {
|
||||
/// 总共清理的agent数量
|
||||
pub total_cleaned: u64,
|
||||
/// 成功清理的agent数量
|
||||
pub success_cleaned: u64,
|
||||
/// 清理失败的agent数量
|
||||
pub failed_cleaned: u64,
|
||||
/// 清理的孤立session数量
|
||||
pub orphaned_sessions_cleaned: u64,
|
||||
/// 清理的SSE消息数量
|
||||
pub sse_messages_cleaned: u64,
|
||||
/// 最后清理时间
|
||||
pub last_cleanup: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Agent清理器 - 基于RAII的简化版本
|
||||
pub struct AgentCleaner {
|
||||
config: CleanupConfig,
|
||||
stats: CleanupStats,
|
||||
}
|
||||
|
||||
impl AgentCleaner {
|
||||
/// 创建新的清理器
|
||||
pub fn new(config: CleanupConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
stats: CleanupStats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查agent是否闲置超时
|
||||
fn is_agent_idle_timeout(
|
||||
&self,
|
||||
last_activity: DateTime<Utc>,
|
||||
current_time: DateTime<Utc>,
|
||||
) -> bool {
|
||||
let duration = current_time.signed_duration_since(last_activity);
|
||||
duration.num_seconds() > 0
|
||||
&& duration.num_seconds() as u64 > self.config.idle_timeout.as_secs()
|
||||
}
|
||||
|
||||
/// 清理孤立的SSE消息数据
|
||||
/// 清理没有project_id引用的session和长期未活跃的session
|
||||
async fn cleanup_orphaned_sse_sessions(&mut self) -> (u64, u64) {
|
||||
let mut orphaned_count = 0;
|
||||
let mut messages_cleared = 0;
|
||||
|
||||
// 使用统一 Registry 收集所有活跃的 session_id
|
||||
let active_session_ids: std::collections::HashSet<String> = AGENT_REGISTRY
|
||||
.iter_agents()
|
||||
.map(|entry| entry.value().session_id.to_string())
|
||||
.collect();
|
||||
|
||||
// 检查SESSION_CACHE中的所有session
|
||||
let mut sessions_to_remove = Vec::new();
|
||||
|
||||
let session_ids: Vec<String> = SESSION_CACHE
|
||||
.iter()
|
||||
.map(|entry| entry.key().clone())
|
||||
.collect();
|
||||
|
||||
for session_id in session_ids {
|
||||
// 如果session_id不在活跃映射中,则为孤立session
|
||||
if !active_session_ids.contains(&session_id) {
|
||||
// 检查session中是否有消息
|
||||
if let Some(session_data_ref) = SESSION_CACHE.get(&session_id) {
|
||||
let session_data = session_data_ref.clone();
|
||||
drop(session_data_ref);
|
||||
|
||||
let message_count = session_data.message_count().await;
|
||||
|
||||
if message_count > 0 {
|
||||
info!(
|
||||
"Found orphaned session: session_id={}, message_count={}",
|
||||
session_id, message_count
|
||||
);
|
||||
|
||||
// 清理这个session的消息 - 直接移除条目
|
||||
if SESSION_CACHE.remove(&session_id).is_some() {
|
||||
messages_cleared += 1;
|
||||
}
|
||||
|
||||
// 如果清理后session为空,标记为待删除
|
||||
if session_data.message_count().await == 0 {
|
||||
sessions_to_remove.push(session_id.clone());
|
||||
}
|
||||
|
||||
orphaned_count += 1;
|
||||
} else {
|
||||
// 没有消息的空session,直接标记删除
|
||||
sessions_to_remove.push(session_id.clone());
|
||||
}
|
||||
} else {
|
||||
// session_data不存在,也标记删除
|
||||
sessions_to_remove.push(session_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除空的孤立session
|
||||
for session_id in sessions_to_remove {
|
||||
if let Some((_, _)) = SESSION_CACHE.remove(&session_id) {
|
||||
debug!("Removed empty session: {}", session_id);
|
||||
}
|
||||
}
|
||||
|
||||
if orphaned_count > 0 {
|
||||
info!(
|
||||
"Orphaned SSE session cleanup completed: session_count={}, message_count={}",
|
||||
orphaned_count, messages_cleared
|
||||
);
|
||||
}
|
||||
|
||||
(orphaned_count, messages_cleared)
|
||||
}
|
||||
|
||||
/// 执行一次清理操作 - 基于RAII的简化版
|
||||
/// 只需要从 MAP 中移除闲置agent,AgentLifecycleGuard 会自动清理资源
|
||||
async fn cleanup_idle_agents(&mut self) -> Result<CleanupStats> {
|
||||
let current_time = Utc::now();
|
||||
let mut cleaned_count = 0;
|
||||
let mut success_count = 0;
|
||||
let mut failed_count = 0;
|
||||
|
||||
// 使用统一 Registry 获取统计信息
|
||||
let registry_stats = AGENT_REGISTRY.stats();
|
||||
let total_agents = registry_stats.agent_count;
|
||||
|
||||
info!(
|
||||
"Starting cleanup for idle agents and SSE messages, current_time: {}, active_agent_count: {}",
|
||||
current_time, total_agents
|
||||
);
|
||||
|
||||
// 先清理孤立的SSE消息数据
|
||||
let (orphaned_sessions, sse_messages) = self.cleanup_orphaned_sse_sessions().await;
|
||||
|
||||
// 收集需要清理的agent ID(使用统一 Registry 遍历)
|
||||
let mut agents_to_remove = Vec::new();
|
||||
|
||||
for entry in AGENT_REGISTRY.iter_agents() {
|
||||
let project_id = entry.key();
|
||||
let agent_info = entry.value();
|
||||
|
||||
// 只清理Idle状态的agent,避免中断正在执行的任务
|
||||
if agent_info.status == AgentStatus::Idle
|
||||
&& self.is_agent_idle_timeout(agent_info.last_activity, current_time)
|
||||
{
|
||||
let idle_duration = (current_time - agent_info.last_activity).num_seconds();
|
||||
info!(
|
||||
"Found idle agent: project_id={}, status={:?}, last_activity: {}, idle_duration_seconds: {}, created_at: {}",
|
||||
project_id,
|
||||
agent_info.status,
|
||||
agent_info.last_activity,
|
||||
idle_duration,
|
||||
agent_info.created_at
|
||||
);
|
||||
agents_to_remove.push(project_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 执行清理 - RAII版:直接从 MAP 中移除,AgentLifecycleGuard 会自动清理
|
||||
for project_id in agents_to_remove {
|
||||
match self.cleanup_agent_raii(&project_id) {
|
||||
Ok(_) => {
|
||||
success_count += 1;
|
||||
info!("Agent cleaned successfully: {}", project_id);
|
||||
}
|
||||
Err(e) => {
|
||||
failed_count += 1;
|
||||
warn!("Failed to clean agent: {} - {}", project_id, e);
|
||||
}
|
||||
}
|
||||
cleaned_count += 1;
|
||||
}
|
||||
|
||||
// 更新统计信息
|
||||
self.stats.total_cleaned += cleaned_count;
|
||||
self.stats.success_cleaned += success_count;
|
||||
self.stats.failed_cleaned += failed_count;
|
||||
self.stats.orphaned_sessions_cleaned += orphaned_sessions;
|
||||
self.stats.sse_messages_cleaned += sse_messages;
|
||||
self.stats.last_cleanup = Some(current_time);
|
||||
|
||||
// 清理完成后的统计(使用统一 Registry)
|
||||
let final_stats = AGENT_REGISTRY.stats();
|
||||
let remaining_agents = final_stats.agent_count;
|
||||
let active_sessions = final_stats.session_count;
|
||||
let cached_sessions = SESSION_CACHE.len();
|
||||
|
||||
info!(
|
||||
"Cleanup completed: agent(total={}, success={}, failed={}, remaining={}) | session(active={}, cached={}) | sse_messages(cleared={})",
|
||||
cleaned_count,
|
||||
success_count,
|
||||
failed_count,
|
||||
remaining_agents,
|
||||
active_sessions,
|
||||
cached_sessions,
|
||||
sse_messages
|
||||
);
|
||||
|
||||
Ok(CleanupStats {
|
||||
total_cleaned: cleaned_count,
|
||||
success_cleaned: success_count,
|
||||
failed_cleaned: failed_count,
|
||||
orphaned_sessions_cleaned: orphaned_sessions,
|
||||
sse_messages_cleaned: sse_messages,
|
||||
last_cleanup: Some(current_time),
|
||||
})
|
||||
}
|
||||
|
||||
/// 基于RAII的简化清理方法
|
||||
/// 只需要从MAP中移除agent,AgentLifecycleGuard会自动清理所有资源
|
||||
fn cleanup_agent_raii(&self, project_id: &str) -> Result<()> {
|
||||
debug!("Starting RAII cleanup for agent: {}", project_id);
|
||||
|
||||
// 使用统一 Registry 检查并移除(内部自动同步清理所有映射)
|
||||
if AGENT_REGISTRY.contains_project(project_id) {
|
||||
// 通过统一 Registry 移除,自动清理:
|
||||
// - agent_info_map
|
||||
// - project_to_session 映射
|
||||
// - session_to_project 反向映射
|
||||
let removed = AGENT_REGISTRY.remove_by_project(project_id);
|
||||
|
||||
// 同步清理 SESSION_REQUEST_CONTEXT 中的 request_id
|
||||
crate::proxy_agent::SESSION_REQUEST_CONTEXT.remove(project_id);
|
||||
debug!(
|
||||
"🧼 [cleanup] Cleared project_id from SESSION_REQUEST_CONTEXT: {}",
|
||||
project_id
|
||||
);
|
||||
|
||||
if removed.is_some() {
|
||||
info!(
|
||||
"Agent removed from Registry; AgentLifecycleGuard will clean up resources automatically: {}",
|
||||
project_id
|
||||
);
|
||||
} else {
|
||||
warn!("Tried to remove agent but it was not found: {}", project_id);
|
||||
}
|
||||
} else {
|
||||
warn!("Agent not found in Registry: {}", project_id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 运行清理任务 - 简化版,只做定时清理
|
||||
pub async fn run(&mut self) {
|
||||
info!("Cleanup task started, config: {:?}", self.config);
|
||||
|
||||
let mut interval = tokio::time::interval(self.config.cleanup_interval);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
match self.cleanup_idle_agents().await {
|
||||
Ok(stats) => debug!("Periodic cleanup completed: {:?}", stats),
|
||||
Err(e) => warn!("Periodic cleanup failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取统计信息
|
||||
pub fn get_stats(&self) -> &CleanupStats {
|
||||
&self.stats
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动清理任务 - 普通异步版本
|
||||
///
|
||||
/// 清理任务只操作 Send 数据结构,可以在普通异步线程中运行
|
||||
pub fn start_cleanup_task(config: CleanupConfig) -> tokio::task::JoinHandle<()> {
|
||||
let mut cleaner = AgentCleaner::new(config);
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
cleaner.run().await;
|
||||
})
|
||||
}
|
||||
115
qiming-rcoder/crates/agent_runner/src/proxy_agent/mod.rs
Normal file
115
qiming-rcoder/crates/agent_runner/src/proxy_agent/mod.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
mod acp_agent;
|
||||
pub mod cleanup_task;
|
||||
|
||||
use crate::CancelNotificationRequestWrapper;
|
||||
// 导出 agent_worker 相关类型和函数
|
||||
pub use acp_agent::{AgentRequest, agent_worker_with_heartbeat, set_unlimited_mode};
|
||||
use shared_types::AgentLifecycleGuard;
|
||||
// SACP 类型导入
|
||||
#[cfg(feature = "proxy")]
|
||||
use crate::config::ProxyConfig;
|
||||
use dashmap::DashMap;
|
||||
#[cfg(feature = "proxy")]
|
||||
use rcoder_proxy::{PingoraServerManager, ProxyConfig as PingoraProxyConfig};
|
||||
use agent_client_protocol::schema::{PromptRequest, SessionId};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::sync::mpsc;
|
||||
#[cfg(feature = "proxy")]
|
||||
use tracing::{error, info};
|
||||
|
||||
/// Pingora 启动结果
|
||||
///
|
||||
/// 持有关闭信号的发送端,`stop()` 时直接发送信号,无需 Mutex 锁。
|
||||
#[cfg(feature = "proxy")]
|
||||
pub struct PingoraStartResult {
|
||||
/// 关闭信号发送端
|
||||
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "proxy")]
|
||||
impl PingoraStartResult {
|
||||
/// 停止 Pingora 服务器
|
||||
pub async fn stop(&mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 Pingora 代理服务
|
||||
///
|
||||
/// 封装 Pingora 的创建和启动逻辑,供 main.rs 和 http_server/start.rs 复用。
|
||||
/// shutdown 通道在外部创建,`stop()` 直接发送信号,不经过 Mutex,消除死锁风险。
|
||||
#[cfg(feature = "proxy")]
|
||||
#[must_use]
|
||||
pub fn start_pingora(
|
||||
proxy_config: &ProxyConfig,
|
||||
shared_api_key_manager: Arc<dashmap::DashMap<String, shared_types::ModelProviderConfig>>,
|
||||
) -> PingoraStartResult {
|
||||
info!(
|
||||
"Starting Pingora reverse proxy service, listening on port: {}",
|
||||
proxy_config.listen_port
|
||||
);
|
||||
info!(
|
||||
"Proxy route format: /proxy/{{port}}{{/path}} - e.g.: /proxy/{}/health",
|
||||
proxy_config.default_backend_port
|
||||
);
|
||||
|
||||
let pingora_config = PingoraProxyConfig {
|
||||
listen_port: proxy_config.listen_port,
|
||||
default_backend_port: proxy_config.default_backend_port,
|
||||
backend_host: proxy_config.backend_host.clone(),
|
||||
port_param: proxy_config.port_param.clone(),
|
||||
config_file: None,
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
// 创建 Pingora 服务器管理器
|
||||
let mut server_manager =
|
||||
PingoraServerManager::new(pingora_config).with_api_key_manager(shared_api_key_manager);
|
||||
|
||||
let pingora_service = server_manager.service();
|
||||
|
||||
// 启动健康检查循环(按配置)
|
||||
if proxy_config.health_check.enabled {
|
||||
let hc = &proxy_config.health_check;
|
||||
pingora_service.start_health_check_loop(hc.interval_seconds, hc.timeout_seconds * 1000);
|
||||
}
|
||||
|
||||
// 在外部创建 shutdown 通道,避免通过 Mutex 发送信号导致死锁
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
// 在后台任务中启动 Pingora(直接 move server_manager,无需 Arc<Mutex<>>)
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = server_manager.start(shutdown_rx).await {
|
||||
error!("Failed to start Pingora proxy server: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
info!(
|
||||
"✅ Pingora 代理服务已启动在端口 {}",
|
||||
proxy_config.listen_port
|
||||
);
|
||||
|
||||
PingoraStartResult {
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话级别的 request_id 上下文映射(project_id -> request_id)
|
||||
/// 用于在 session_notification 回调中获取当前请求的 request_id
|
||||
/// 避免使用 PROJECT_AND_AGENT_INFO_MAP 导致的锁竞争问题
|
||||
/// 注意:使用 project_id 而非 session_id,确保同一项目的多次请求能自动覆盖为最新值
|
||||
pub static SESSION_REQUEST_CONTEXT: LazyLock<DashMap<String, String>> = LazyLock::new(DashMap::new);
|
||||
|
||||
/// ACP协议的连接信息
|
||||
pub struct AcpConnectionInfo {
|
||||
/// 会话ID
|
||||
pub session_id: SessionId,
|
||||
/// 用于发送 Prompt 的通道
|
||||
pub prompt_tx: mpsc::UnboundedSender<PromptRequest>,
|
||||
/// 用于发送取消通知的通道(使用新类型)
|
||||
pub cancel_tx: mpsc::UnboundedSender<CancelNotificationRequestWrapper>,
|
||||
/// Agent停止句柄(将被包装为守卫并放入 ProjectAndAgentInfo)
|
||||
pub stop_handle: Option<Arc<AgentLifecycleGuard>>,
|
||||
}
|
||||
166
qiming-rcoder/crates/agent_runner/src/router.rs
Normal file
166
qiming-rcoder/crates/agent_runner/src/router.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::agent_runtime::AgentRuntime;
|
||||
use crate::{config::AppConfig, handler};
|
||||
use axum::{Router, response::IntoResponse, routing::get};
|
||||
use dashmap::DashMap;
|
||||
use rcoder_telemetry::{HttpMetricsLayer, TelemetryGuard};
|
||||
use serde::Serialize;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
/// 会话信息结构
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SessionInfo {
|
||||
pub session_id: String,
|
||||
pub user_id: String,
|
||||
pub project_id: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub last_activity: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// 应用状态
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
/// 活跃的会话映射, project_id -> SessionInfo
|
||||
pub sessions: Arc<DashMap<String, SessionInfo>>,
|
||||
/// 应用配置
|
||||
pub config: AppConfig,
|
||||
|
||||
/// 🆕 Agent Runtime(新架构)
|
||||
///
|
||||
/// **推荐**: 使用 `agent_runtime.send().await` 发送任务
|
||||
/// - 支持自动重启
|
||||
/// - 提供健康状态检查
|
||||
/// - 线程安全的原子操作
|
||||
pub local_task_sender: Arc<AgentRuntime>,
|
||||
|
||||
/// 🆕 Agent Runtime(用于健康检查和状态监控)
|
||||
pub agent_runtime: Arc<AgentRuntime>,
|
||||
|
||||
/// Pingora 代理服务引用(用于读取真实指标)
|
||||
#[cfg(feature = "proxy")]
|
||||
pub pingora_service: Option<Arc<rcoder_proxy::PingoraProxyService>>,
|
||||
|
||||
/// 🔒 API 密钥管理器(用于 Pingora 代理注入真实密钥)
|
||||
/// 注意:此现在是 shared_api_key_manager 的包装器
|
||||
pub api_key_manager: Arc<crate::api_key_manager::ApiKeyManager>,
|
||||
|
||||
/// 🔒 共享的 API 密钥 DashMap(直接与 Pingora 共享)
|
||||
/// 存储格式:<UUID> -> ModelProviderConfig
|
||||
pub shared_api_key_manager: Arc<DashMap<String, shared_types::ModelProviderConfig>>,
|
||||
|
||||
/// 🔒 project_id -> service_uuid 映射(用于清理时查找对应的配置)
|
||||
pub project_uuid_map: Arc<DashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// 创建 Axum 路由
|
||||
pub fn create_router(state: Arc<AppState>, telemetry: Option<Arc<TelemetryGuard>>) -> Router {
|
||||
let api_routes = Router::new()
|
||||
.route("/health", get(handler::health_check))
|
||||
.with_state(state.clone());
|
||||
|
||||
let mut router = Router::new().merge(api_routes).merge(create_swagger_ui());
|
||||
|
||||
// 添加 /metrics 端点(如果启用了 Prometheus)
|
||||
if let Some(ref guard) = telemetry {
|
||||
let guard_clone = Arc::clone(guard);
|
||||
router = router.route(
|
||||
"/metrics",
|
||||
get(move || {
|
||||
let guard = Arc::clone(&guard_clone);
|
||||
async move { metrics_handler(guard).await }
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 应用 HTTP 指标中间件
|
||||
router.layer(HttpMetricsLayer::new())
|
||||
}
|
||||
|
||||
/// Prometheus 指标处理器
|
||||
async fn metrics_handler(telemetry: Arc<TelemetryGuard>) -> impl IntoResponse {
|
||||
match telemetry.render_metrics() {
|
||||
Some(metrics) => (
|
||||
axum::http::StatusCode::OK,
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/plain; charset=utf-8",
|
||||
)],
|
||||
metrics,
|
||||
),
|
||||
None => (
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/plain; charset=utf-8",
|
||||
)],
|
||||
"Prometheus metrics not enabled".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenAPI 文档结构
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
handler::health_check,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
// 响应结构体
|
||||
handler::HealthResponse,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "system", description = "系统健康检查和状态监控接口"),
|
||||
),
|
||||
info(
|
||||
description = r#"
|
||||
RCoder AI 服务 API
|
||||
|
||||
基于 ACP (Agent Client Protocol) 的 AI 驱动开发平台,提供完整的 AI 代理集成解决方案。
|
||||
|
||||
## 主要功能
|
||||
|
||||
- **智能对话**: 支持文本、图像、音频、文档等多媒体内容的 AI 交互
|
||||
- **实时通知**: 通过 SSE 协议提供 AI 代理执行进度的实时推送
|
||||
- **会话管理**: 完整的会话生命周期管理,支持任务取消
|
||||
- **项目隔离**: 每个对话在独立的项目工作空间中进行,确保安全性
|
||||
|
||||
## 技术架构
|
||||
|
||||
- **协议**: ACP (Agent Client Protocol) v0.4
|
||||
- **gRPC 通信**: rcoder 通过 gRPC 与 agent_runner 通信
|
||||
- **健康检查**: 提供服务健康状态查询
|
||||
|
||||
## gRPC 接口
|
||||
|
||||
agent_runner 主要通过 gRPC 提供服务:
|
||||
- `Chat` - 聊天对话
|
||||
- `SubscribeProgress` - 订阅进度事件(Server Streaming)
|
||||
- `CancelSession` - 取消会话
|
||||
- `GetStatus` - 查询状态
|
||||
"#,
|
||||
title = "RCoder AI API",
|
||||
version = "2.0.0",
|
||||
license(name = "MIT OR Apache-2.0", url = "https://opensource.org/licenses/MIT"),
|
||||
contact(
|
||||
name = "RCoder Team",
|
||||
email = "team@rcoder.com",
|
||||
url = "https://github.com/rcoder/rcoder"
|
||||
)
|
||||
),
|
||||
servers(
|
||||
(url = "http://localhost:50051", description = "gRPC 服务 (agent_runner)"),
|
||||
(url = "http://localhost:8087", description = "HTTP API 服务 (rcoder)"),
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
/// 创建 Swagger UI 路由
|
||||
pub fn create_swagger_ui() -> SwaggerUi {
|
||||
SwaggerUi::new("/api/docs")
|
||||
.url("/api/docs/openapi.json", ApiDoc::openapi())
|
||||
.config(utoipa_swagger_ui::Config::new(["/api/docs/openapi.json"]))
|
||||
}
|
||||
1001
qiming-rcoder/crates/agent_runner/src/service/agent_registry.rs
Normal file
1001
qiming-rcoder/crates/agent_runner/src/service/agent_registry.rs
Normal file
File diff suppressed because it is too large
Load Diff
582
qiming-rcoder/crates/agent_runner/src/service/chat_handler.rs
Normal file
582
qiming-rcoder/crates/agent_runner/src/service/chat_handler.rs
Normal file
@@ -0,0 +1,582 @@
|
||||
//! Chat Handler 共享逻辑
|
||||
//!
|
||||
//! 封装 chat 请求处理的核心业务逻辑,供 gRPC 和 HTTP 复用。
|
||||
//!
|
||||
//! ## 设计原则
|
||||
//!
|
||||
//! - **RAII 状态管理**: 使用 PendingGuard 自动管理 Pending 状态
|
||||
//! - **DashMap Entry API**: 避免读写锁竞态条件
|
||||
//! - **Fail Fast**: 尽早暴露问题,便于定位修复
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use shared_types::{
|
||||
Attachment, CancelNotificationRequestWrapper, CancelResult, ChatAgentConfig, ChatPromptBuilder,
|
||||
ModelProviderConfig, ServiceType, error_codes,
|
||||
};
|
||||
use agent_client_protocol::schema::{CancelNotification, SessionId};
|
||||
use tokio::time::Duration;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::AgentRuntime;
|
||||
use crate::agent_runtime::WorkerState;
|
||||
use crate::proxy_agent::AgentRequest;
|
||||
use crate::service::{AGENT_REGISTRY, PendingGuard, SESSION_CACHE};
|
||||
|
||||
/// Chat Handler 输入参数
|
||||
///
|
||||
/// 包含处理 chat 请求所需的所有参数,与协议无关(gRPC/HTTP)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatHandlerInput {
|
||||
/// 项目 ID
|
||||
pub project_id: String,
|
||||
/// 项目工作目录(由调用方根据环境决定)
|
||||
pub project_dir: PathBuf,
|
||||
/// 会话 ID(可选,用于复用会话)
|
||||
pub session_id: Option<String>,
|
||||
/// 用户提示词
|
||||
pub prompt: String,
|
||||
/// 请求 ID(用于追踪)
|
||||
pub request_id: String,
|
||||
/// 附件列表
|
||||
pub attachments: Vec<Attachment>,
|
||||
/// 数据源附件列表
|
||||
pub data_source_attachments: Vec<String>,
|
||||
/// 模型配置(可选)
|
||||
pub model_config: Option<ModelProviderConfig>,
|
||||
/// 服务类型
|
||||
pub service_type: ServiceType,
|
||||
/// Agent 配置覆盖(可选)
|
||||
pub agent_config_override: Option<ChatAgentConfig>,
|
||||
/// 系统提示覆盖(可选)
|
||||
pub system_prompt_override: Option<String>,
|
||||
/// 用户提示模板覆盖(可选)
|
||||
pub user_prompt_template_override: Option<String>,
|
||||
/// 是否跳过槽位限制(HTTP Server 宿主机部署时为 true)
|
||||
pub skip_slot_limit: bool,
|
||||
}
|
||||
|
||||
/// Chat Handler 输出结果
|
||||
///
|
||||
/// 统一的响应结构,可转换为 gRPC 或 HTTP 响应
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatHandlerOutput {
|
||||
/// 项目 ID
|
||||
pub project_id: String,
|
||||
/// 会话 ID
|
||||
pub session_id: String,
|
||||
/// 是否成功
|
||||
pub success: bool,
|
||||
/// 错误消息(可选)
|
||||
pub error: Option<String>,
|
||||
/// 错误码(可选)
|
||||
pub error_code: Option<String>,
|
||||
/// 请求 ID(可选)
|
||||
pub request_id: Option<String>,
|
||||
/// 是否需要降级处理
|
||||
pub need_fallback: bool,
|
||||
/// 降级原因(可选)
|
||||
pub fallback_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatHandlerOutput {
|
||||
/// 创建错误响应
|
||||
pub fn error(
|
||||
project_id: String,
|
||||
session_id: String,
|
||||
error_msg: String,
|
||||
error_code: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
session_id,
|
||||
success: false,
|
||||
error: Some(error_msg),
|
||||
error_code: Some(error_code),
|
||||
request_id: None,
|
||||
need_fallback: false,
|
||||
fallback_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 Agent Busy 错误响应
|
||||
pub fn agent_busy(project_id: String, session_id: Option<String>) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
session_id: session_id.unwrap_or_default(),
|
||||
success: false,
|
||||
error: Some(error_codes::get_i18n_message_default("error.agent_busy")),
|
||||
error_code: Some(error_codes::ERR_AGENT_BUSY.to_string()),
|
||||
request_id: None,
|
||||
need_fallback: false,
|
||||
fallback_reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat Handler 依赖上下文
|
||||
///
|
||||
/// 包含处理 chat 请求所需的运行时依赖
|
||||
pub struct ChatHandlerContext {
|
||||
/// Agent 运行时
|
||||
pub agent_runtime: Arc<AgentRuntime>,
|
||||
/// 共享的 API 密钥管理器
|
||||
pub shared_api_key_manager: Arc<DashMap<String, ModelProviderConfig>>,
|
||||
/// project_id -> UUID 映射
|
||||
pub project_uuid_map: Arc<DashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// 取消当前正在执行的 Agent 任务
|
||||
///
|
||||
/// 发送取消通知并等待取消完成,超时时间为 10 秒
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cancel_tx` - 取消通知发送通道
|
||||
/// * `session_id` - 当前会话 ID
|
||||
/// * `project_id` - 项目 ID
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - 取消成功,Agent 状态已恢复为 Idle
|
||||
/// * `Err(ChatHandlerOutput)` - 取消失败,包含错误响应
|
||||
async fn cancel_current_task(
|
||||
cancel_tx: &tokio::sync::mpsc::Sender<CancelNotificationRequestWrapper>,
|
||||
session_id: &str,
|
||||
project_id: &str,
|
||||
) -> Result<(), ChatHandlerOutput> {
|
||||
info!(
|
||||
"[ChatHandler] Cancelling current task: project_id={}, session_id={}",
|
||||
project_id, session_id
|
||||
);
|
||||
|
||||
// 1. 检查 cancel_tx 是否有效
|
||||
if cancel_tx.is_closed() {
|
||||
error!(
|
||||
"[ChatHandler] Cancel channel closed: project_id={}, session_id={}",
|
||||
project_id, session_id
|
||||
);
|
||||
return Err(ChatHandlerOutput::error(
|
||||
project_id.to_string(),
|
||||
session_id.to_string(),
|
||||
error_codes::get_i18n_message_default("error.cancel_channel_closed"),
|
||||
error_codes::ERR_SERVICE_UNAVAILABLE.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 2. 创建 oneshot channel 等待取消结果
|
||||
let (result_tx, result_rx) = tokio::sync::oneshot::channel::<CancelResult>();
|
||||
let cancel_notification = CancelNotification::new(SessionId::new(Arc::from(session_id)));
|
||||
let cancel_request = CancelNotificationRequestWrapper {
|
||||
cancel_notification,
|
||||
result_tx,
|
||||
};
|
||||
|
||||
// 3. 发送取消通知
|
||||
if let Err(e) = cancel_tx.send(cancel_request).await {
|
||||
error!(
|
||||
"[ChatHandler] Failed to send cancel notification: project_id={}, error={}",
|
||||
project_id, e
|
||||
);
|
||||
return Err(ChatHandlerOutput::error(
|
||||
project_id.to_string(),
|
||||
session_id.to_string(),
|
||||
format!(
|
||||
"{}: {}",
|
||||
error_codes::get_i18n_message_default("error.cancel_failed"),
|
||||
e
|
||||
),
|
||||
error_codes::ERR_INTERNAL_SERVER_ERROR.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 4. 等待取消结果(超时 10 秒)
|
||||
match tokio::time::timeout(Duration::from_secs(10), result_rx).await {
|
||||
Ok(Ok(cancel_result)) => {
|
||||
if cancel_result.is_success() {
|
||||
info!(
|
||||
"[ChatHandler] Cancel notification sent successfully, proceeding with new request: project_id={}, session_id={}",
|
||||
project_id, session_id
|
||||
);
|
||||
|
||||
// 🎯 关键设计:cancel 后立即返回,不等待 session 移除
|
||||
//
|
||||
// 上下文连续性保证:
|
||||
// - 不等待 session 移除 → session 保持在 Registry 中
|
||||
// - get_or_create_session → is_channel_closed()=false → 复用同一 session
|
||||
// - 新 prompt 发送到同一 session 的 prompt_tx → 同一 Agent 子进程处理
|
||||
// - Agent 子进程保持存活 → 内存中的对话上下文连续
|
||||
//
|
||||
// 时序:
|
||||
// 1. CancelResult::Success → cancel 通知已发送给 Agent
|
||||
// 2. SACP inner loop 收到 cancel → is_cancelled=true → 等待 Agent 响应或超时
|
||||
// 3. inner loop 退出 → outer loop 继续等待 prompt_rx
|
||||
// 4. 新请求的 prompt 到达 → session_cancelled 重置 → 处理新 prompt
|
||||
// 5. 同一 Agent 子进程处理新 prompt → 上下文连续
|
||||
//
|
||||
// 最坏情况延迟:inner cancel timeout (10s) — Agent 不响应 cancel 时
|
||||
Ok(())
|
||||
} else {
|
||||
let error_msg = cancel_result.error_message().unwrap_or("Unknown error");
|
||||
error!(
|
||||
"[ChatHandler] Cancel failed: project_id={}, error={}",
|
||||
project_id, error_msg
|
||||
);
|
||||
Err(ChatHandlerOutput::error(
|
||||
project_id.to_string(),
|
||||
session_id.to_string(),
|
||||
format!(
|
||||
"{}: {}",
|
||||
error_codes::get_i18n_message_default("error.cancel_failed"),
|
||||
error_msg
|
||||
),
|
||||
error_codes::ERR_AGENT_ERROR.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
error!(
|
||||
"[ChatHandler] Cancel result channel dropped: project_id={}",
|
||||
project_id
|
||||
);
|
||||
Err(ChatHandlerOutput::error(
|
||||
project_id.to_string(),
|
||||
session_id.to_string(),
|
||||
error_codes::get_i18n_message_default("error.cancel_channel_dropped"),
|
||||
error_codes::ERR_INTERNAL_SERVER_ERROR.to_string(),
|
||||
))
|
||||
}
|
||||
Err(_) => {
|
||||
error!(
|
||||
"[ChatHandler] Cancel timeout (10s): project_id={}",
|
||||
project_id
|
||||
);
|
||||
Err(ChatHandlerOutput::error(
|
||||
project_id.to_string(),
|
||||
session_id.to_string(),
|
||||
error_codes::get_i18n_message_default("error.cancel_timeout"),
|
||||
error_codes::ERR_CANCEL_FAILED.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行 Chat 请求的核心逻辑
|
||||
///
|
||||
/// 封装了 chat 请求的完整处理流程:
|
||||
/// 1. Agent Busy 检查
|
||||
/// 2. PendingGuard RAII 状态管理
|
||||
/// 3. Session 清理逻辑
|
||||
/// 4. 目录创建
|
||||
/// 5. ChatPrompt 构建
|
||||
/// 6. API Key 管理
|
||||
/// 7. AgentRequest 创建和发送
|
||||
/// 8. 等待响应
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `input` - Chat 请求输入参数
|
||||
/// * `context` - 运行时上下文依赖
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// 返回统一的 `ChatHandlerOutput` 结果
|
||||
pub async fn handle_chat_core(
|
||||
input: ChatHandlerInput,
|
||||
context: &ChatHandlerContext,
|
||||
) -> ChatHandlerOutput {
|
||||
let project_id = input.project_id.clone();
|
||||
let session_id = input.session_id.clone();
|
||||
let request_id = input.request_id.clone();
|
||||
|
||||
info!(
|
||||
"[ChatHandler] Starting to process request: project_id={}, session_id={:?}, prompt_len={}, has_model_config={}",
|
||||
project_id,
|
||||
session_id,
|
||||
input.prompt.len(),
|
||||
input.model_config.is_some()
|
||||
);
|
||||
|
||||
// ========== 步骤1: 查询现有 Agent 状态 ==========
|
||||
// 优先通过 session_id 查找,回退到 project_id 查找
|
||||
let agent_info_ref = if let Some(ref sid) = session_id {
|
||||
info!(
|
||||
"[ChatHandler] Looking up Agent by session_id: session_id={}",
|
||||
sid
|
||||
);
|
||||
AGENT_REGISTRY.get_agent_info_by_session(sid)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let agent_info_ref = agent_info_ref.or_else(|| {
|
||||
info!(
|
||||
"[ChatHandler] Looking up Agent by project_id: project_id={}",
|
||||
project_id
|
||||
);
|
||||
AGENT_REGISTRY.get_agent_info(&project_id)
|
||||
});
|
||||
|
||||
// ========== 步骤2: 检查 Agent Busy 状态,如果忙则取消当前任务 ==========
|
||||
use crate::model::AgentStatus;
|
||||
if let Some(agent_info) = agent_info_ref {
|
||||
if agent_info.status == AgentStatus::Active || agent_info.status == AgentStatus::Pending {
|
||||
info!(
|
||||
"[ChatHandler] Agent Busy, cancelling current task: project_id={}, status={:?}, session_id={:?}",
|
||||
project_id, agent_info.status, session_id
|
||||
);
|
||||
|
||||
// 获取 cancel_tx 和 session_id,并释放 DashMap 读锁(防死锁)
|
||||
let cancel_tx = agent_info.cancel_tx.clone();
|
||||
// 优先使用请求中的 session_id,如果为空则从 agent_info 中获取
|
||||
let actual_session_id = session_id
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| agent_info.session_id.to_string());
|
||||
drop(agent_info);
|
||||
|
||||
// 取消当前任务
|
||||
if let Err(cancel_error) =
|
||||
cancel_current_task(&cancel_tx, &actual_session_id, &project_id).await
|
||||
{
|
||||
// 取消失败,返回错误
|
||||
error!(
|
||||
"[ChatHandler] Failed to cancel current task: project_id={}, error={:?}",
|
||||
project_id, cancel_error
|
||||
);
|
||||
return cancel_error;
|
||||
}
|
||||
|
||||
info!(
|
||||
"[ChatHandler] Current task cancelled, proceeding with new request: project_id={}",
|
||||
project_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 步骤3: 创建 PendingGuard(RAII)==========
|
||||
// 自动在作用域结束时清理,避免状态泄漏
|
||||
let pending_guard = PendingGuard::new(&AGENT_REGISTRY, &project_id);
|
||||
info!(
|
||||
"[ChatHandler] Created PendingGuard: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
// ========== 步骤4: 清理无效 session ==========
|
||||
// 只在 session 不存在时才清理无效的 session_id
|
||||
if let Some(ref sid) = session_id {
|
||||
let session_exists = AGENT_REGISTRY.contains_session(sid);
|
||||
|
||||
if !session_exists && SESSION_CACHE.remove(sid).is_some() {
|
||||
info!(
|
||||
"[ChatHandler] session does not exist, removing invalid session: session_id={}",
|
||||
sid
|
||||
);
|
||||
} else if session_exists {
|
||||
info!("[ChatHandler] Reusing existing session: session_id={}", sid);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 步骤5: 获取项目工作目录 ==========
|
||||
let project_dir = input.project_dir.clone();
|
||||
info!(
|
||||
"[ChatHandler] Project working directory: {:?}, service_type={:?}",
|
||||
project_dir, input.service_type
|
||||
);
|
||||
|
||||
// 确保目录存在
|
||||
if !project_dir.exists() {
|
||||
if let Err(e) = tokio::fs::create_dir_all(&project_dir).await {
|
||||
error!("[ChatHandler] Failed to create project directory: {}", e);
|
||||
return ChatHandlerOutput::error(
|
||||
project_id,
|
||||
session_id.unwrap_or_default(),
|
||||
format!(
|
||||
"{}: {}",
|
||||
error_codes::get_i18n_message_default("error.create_project_dir_failed"),
|
||||
e
|
||||
),
|
||||
error_codes::ERR_INTERNAL_SERVER_ERROR.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 步骤6: 构建 ChatPrompt 和 PromptMessage ==========
|
||||
let chat_prompt = match ChatPromptBuilder::default()
|
||||
.project_id(project_id.clone())
|
||||
.project_path(project_dir)
|
||||
.session_id(session_id.clone())
|
||||
.prompt(input.prompt)
|
||||
.attachments(input.attachments)
|
||||
.data_source_attachments(input.data_source_attachments)
|
||||
.service_type(input.service_type)
|
||||
.request_id(request_id.clone())
|
||||
.system_prompt_override(input.system_prompt_override)
|
||||
.user_prompt_template_override(input.user_prompt_template_override)
|
||||
.agent_config_override(input.agent_config_override)
|
||||
.build()
|
||||
{
|
||||
Ok(prompt) => prompt,
|
||||
Err(e) => {
|
||||
error!("[ChatHandler] Failed to build ChatPrompt: {}", e);
|
||||
return ChatHandlerOutput::error(
|
||||
project_id,
|
||||
session_id.unwrap_or_default(),
|
||||
format!(
|
||||
"{}: {}",
|
||||
error_codes::get_i18n_message_default("error.build_chat_prompt_failed"),
|
||||
e
|
||||
),
|
||||
error_codes::ERR_INTERNAL_SERVER_ERROR.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 转换为 PromptMessage
|
||||
let prompt_message = agent_abstraction::PromptMessage::from(chat_prompt);
|
||||
|
||||
// ========== 步骤7: 管理 API 密钥配置 ==========
|
||||
let model_provider = input.model_config;
|
||||
|
||||
// 生成唯一的 service UUID(用于 API 密钥管理)
|
||||
let service_uuid = if model_provider.is_some() {
|
||||
Some(uuid::Uuid::new_v4().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 存储 API 配置到共享 DashMap
|
||||
if let (Some(ref provider), Some(ref service_uuid_ref)) =
|
||||
(model_provider.as_ref(), service_uuid.as_ref())
|
||||
{
|
||||
debug!(
|
||||
"[ChatHandler] 使用模型配置: provider={}, model={}, base_url={}, api_protocol={:?}, requires_openai_auth={}, service_uuid={}",
|
||||
provider.name,
|
||||
provider.default_model,
|
||||
provider.base_url,
|
||||
provider.api_protocol,
|
||||
provider.requires_openai_auth,
|
||||
service_uuid_ref
|
||||
);
|
||||
|
||||
// 存储 ModelProviderConfig 到共享 DashMap(使用 UUID 作为 key)
|
||||
context
|
||||
.shared_api_key_manager
|
||||
.insert(service_uuid_ref.to_string(), (*provider).clone());
|
||||
|
||||
// 存储 project_id -> UUID 映射(用于后续清理时查找)
|
||||
context
|
||||
.project_uuid_map
|
||||
.insert(project_id.clone(), service_uuid_ref.to_string());
|
||||
|
||||
info!(
|
||||
"[ChatHandler] Stored API config: service_uuid={}, provider_name={}, base_url={}",
|
||||
service_uuid_ref,
|
||||
provider.name,
|
||||
shared_types::mask_url(&provider.base_url)
|
||||
);
|
||||
} else {
|
||||
warn!("[ChatHandler] No model config provided; falling back to env vars or defaults");
|
||||
}
|
||||
|
||||
// ========== 步骤8: 检查 Worker 状态 ==========
|
||||
match context.agent_runtime.state() {
|
||||
WorkerState::Running => {
|
||||
// 正常操作,继续处理
|
||||
}
|
||||
WorkerState::Starting => {
|
||||
warn!("[ChatHandler] Agent Worker is starting; request may be delayed");
|
||||
}
|
||||
WorkerState::Stopping | WorkerState::Stopped => {
|
||||
// PendingGuard 自动清理(在 drop 时)
|
||||
error!("[ChatHandler] Agent Worker unavailable");
|
||||
return ChatHandlerOutput::error(
|
||||
project_id,
|
||||
session_id.unwrap_or_default(),
|
||||
error_codes::get_i18n_message_default("error.agent_worker_unavailable"),
|
||||
error_codes::ERR_SERVICE_UNAVAILABLE.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 步骤9: 发送任务到 Agent Worker ==========
|
||||
// 创建请求并设置 UUID 和密钥管理器
|
||||
let (agent_request, chat_prompt_rx) = AgentRequest::new(prompt_message, model_provider);
|
||||
let agent_request = agent_request
|
||||
.with_service_uuid(service_uuid)
|
||||
.with_key_manager(Some(context.shared_api_key_manager.clone()))
|
||||
.with_skip_slot_limit(input.skip_slot_limit);
|
||||
|
||||
if let Err(e) = context.agent_runtime.send(agent_request).await {
|
||||
// PendingGuard 自动清理(在 drop 时)
|
||||
error!("[ChatHandler] Failed to send task: {}", e);
|
||||
return ChatHandlerOutput::error(
|
||||
project_id,
|
||||
session_id.unwrap_or_default(),
|
||||
format!(
|
||||
"{}: {}",
|
||||
error_codes::get_i18n_message_default("error.send_task_failed"),
|
||||
e
|
||||
),
|
||||
error_codes::ERR_INTERNAL_SERVER_ERROR.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 步骤10: 等待响应(5 分钟超时)==========
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(300), chat_prompt_rx).await {
|
||||
Ok(Ok(response)) => {
|
||||
let output = ChatHandlerOutput {
|
||||
project_id: response.project_id,
|
||||
session_id: response.session_id,
|
||||
success: response.error.is_none(),
|
||||
error: response.error,
|
||||
error_code: if response.code != error_codes::SUCCESS {
|
||||
Some(response.code)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
request_id: Some(request_id),
|
||||
need_fallback: false,
|
||||
fallback_reason: None,
|
||||
};
|
||||
|
||||
info!(
|
||||
"[ChatHandler] Chat completed: success={}, session_id={}",
|
||||
output.success, output.session_id
|
||||
);
|
||||
|
||||
// 只有请求成功时才提交 PendingGuard 保留 Pending 状态
|
||||
// 失败时 PendingGuard 自动 drop 清理,允许下次请求重新创建 Agent
|
||||
if output.success {
|
||||
pending_guard.commit_success();
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
// PendingGuard 自动清理(在 drop 时)
|
||||
error!("[ChatHandler] Chat response channel dropped: {}", e);
|
||||
ChatHandlerOutput::error(
|
||||
project_id,
|
||||
session_id.unwrap_or_default(),
|
||||
format!(
|
||||
"{}: {}",
|
||||
error_codes::get_i18n_message_default("error.request_processing_failed"),
|
||||
e
|
||||
),
|
||||
error_codes::ERR_INTERNAL_SERVER_ERROR.to_string(),
|
||||
)
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
// PendingGuard 自动清理(在 drop 时)
|
||||
error!("[ChatHandler] ⏰ Chat request timeout (300s): project_id={}", project_id);
|
||||
ChatHandlerOutput::error(
|
||||
project_id,
|
||||
session_id.unwrap_or_default(),
|
||||
error_codes::get_i18n_message_default("error.request_processing_failed")
|
||||
+ ": request timeout (300s)",
|
||||
error_codes::ERR_INTERNAL_SERVER_ERROR.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Agent Runner 本地服务实现
|
||||
//!
|
||||
//! 实现 `AgentHttpService` trait,直接调用本地服务(AGENT_REGISTRY, SESSION_CACHE, handle_chat_core)
|
||||
//! 适用于 Agent Runner 的 HTTP Server 模式
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
use agent_client_protocol::schema::{CancelNotification, SessionId};
|
||||
use shared_types::{
|
||||
agent_http_service::AgentHttpService,
|
||||
rcoder_agent_types::{
|
||||
RcoderAgentCancelRequest, RcoderAgentCancelResponse, RcoderAgentStopRequest,
|
||||
RcoderAgentStopResponse,
|
||||
},
|
||||
AgentStatusResponse, ChatResponse, HttpResult, RcoderChatRequest, ServiceType,
|
||||
};
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::AgentRuntime;
|
||||
use crate::service::{
|
||||
chat_handler::{ChatHandlerContext, ChatHandlerInput},
|
||||
handle_chat_core, AGENT_REGISTRY, SESSION_CACHE, SessionData,
|
||||
};
|
||||
|
||||
/// Agent Runner 本地服务实现
|
||||
///
|
||||
/// 直接调用本地 AGENT_REGISTRY、SESSION_CACHE、handle_chat_core()
|
||||
/// 不需要 gRPC 转发
|
||||
pub struct LocalAgentHttpService {
|
||||
/// Agent 运行时
|
||||
pub agent_runtime: Arc<AgentRuntime>,
|
||||
/// 共享 API Key 管理器
|
||||
pub shared_api_key_manager: Arc<DashMap<String, shared_types::ModelProviderConfig>>,
|
||||
/// project_id -> UUID 映射
|
||||
pub project_uuid_map: Arc<DashMap<String, String>>,
|
||||
/// 项目工作目录根路径
|
||||
pub projects_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl LocalAgentHttpService {
|
||||
/// 创建新的 LocalAgentHttpService
|
||||
pub fn new(
|
||||
agent_runtime: Arc<AgentRuntime>,
|
||||
shared_api_key_manager: Arc<DashMap<String, shared_types::ModelProviderConfig>>,
|
||||
project_uuid_map: Arc<DashMap<String, String>>,
|
||||
projects_dir: PathBuf,
|
||||
) -> Self {
|
||||
Self {
|
||||
agent_runtime,
|
||||
shared_api_key_manager,
|
||||
project_uuid_map,
|
||||
projects_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentHttpService for LocalAgentHttpService {
|
||||
/// Chat 对话请求
|
||||
async fn chat(&self, request: RcoderChatRequest) -> HttpResult<ChatResponse> {
|
||||
// 0. 验证 prompt 不为空
|
||||
if request.prompt.is_empty() {
|
||||
warn!("[LocalAgent] Empty prompt received");
|
||||
return HttpResult::error(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
"Prompt cannot be empty",
|
||||
);
|
||||
}
|
||||
|
||||
info!(
|
||||
"📨 [LocalAgent] Chat request: prompt_len={}, project_id={:?}, session_id={:?}",
|
||||
request.prompt.len(),
|
||||
request.project_id,
|
||||
request.session_id
|
||||
);
|
||||
|
||||
// 1. 生成 project_id(如果未提供)
|
||||
let project_id = request
|
||||
.project_id
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string().replace('-', ""));
|
||||
|
||||
// 2. 自动查找现有 session_id(如果未提供)
|
||||
let session_id = request.session_id.or_else(|| {
|
||||
AGENT_REGISTRY
|
||||
.get_agent_info(&project_id)
|
||||
.map(|info| info.session_id.to_string())
|
||||
});
|
||||
|
||||
// 3. 创建项目工作目录
|
||||
let project_dir = self.projects_dir.join(&project_id);
|
||||
if let Err(e) = tokio::fs::create_dir_all(&project_dir).await {
|
||||
let error_msg = format!("Failed to create project dir: {}", e);
|
||||
warn!("[LocalAgent] {}", error_msg);
|
||||
return HttpResult::error(shared_types::error_codes::ERR_INTERNAL_SERVER_ERROR, &error_msg);
|
||||
}
|
||||
|
||||
// 4. 生成 request_id(如果未提供)
|
||||
let request_id = request
|
||||
.request_id
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
|
||||
// 5. 构建 ChatHandlerInput
|
||||
let input = ChatHandlerInput {
|
||||
project_id: project_id.clone(),
|
||||
project_dir,
|
||||
session_id,
|
||||
prompt: request.prompt,
|
||||
request_id: request_id.clone(),
|
||||
attachments: request.attachments,
|
||||
data_source_attachments: request.data_source_attachments,
|
||||
model_config: request.model_provider,
|
||||
service_type: ServiceType::RCoder,
|
||||
agent_config_override: request.agent_config,
|
||||
system_prompt_override: request.system_prompt,
|
||||
user_prompt_template_override: request.user_prompt,
|
||||
skip_slot_limit: true, // HTTP Server 部署,跳过槽位限制
|
||||
};
|
||||
|
||||
// 6. 构建 ChatHandlerContext
|
||||
let context = ChatHandlerContext {
|
||||
agent_runtime: self.agent_runtime.clone(),
|
||||
shared_api_key_manager: self.shared_api_key_manager.clone(),
|
||||
project_uuid_map: self.project_uuid_map.clone(),
|
||||
};
|
||||
|
||||
// 7. 调用 handle_chat_core
|
||||
let output = handle_chat_core(input, &context).await;
|
||||
|
||||
// 8. 将 session 写入 SESSION_CACHE(SSE 进度流需要)
|
||||
let session_id_str = output.session_id.clone();
|
||||
if SESSION_CACHE.get(&session_id_str).is_none() {
|
||||
let session_data = SessionData::new(1000);
|
||||
SESSION_CACHE.insert(session_id_str, session_data);
|
||||
}
|
||||
|
||||
// 9. 构建响应
|
||||
if output.error.is_some() || !output.success {
|
||||
let error_code = output.error_code.as_deref().unwrap_or(shared_types::error_codes::ERR_INTERNAL_SERVER_ERROR);
|
||||
let error_msg = output.error.unwrap_or_else(|| "Unknown error".to_string());
|
||||
HttpResult::error(error_code, &error_msg)
|
||||
} else {
|
||||
HttpResult::success(ChatResponse {
|
||||
project_id: output.project_id,
|
||||
session_id: output.session_id,
|
||||
error: output.error,
|
||||
request_id: Some(request_id),
|
||||
need_fallback: None,
|
||||
fallback_reason: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询 Agent 状态
|
||||
async fn get_status(&self, project_id: &str) -> HttpResult<AgentStatusResponse> {
|
||||
info!(
|
||||
"🔍 [LocalAgent] Status query: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
if let Some(info) = AGENT_REGISTRY.get_agent_info(project_id) {
|
||||
// Agent 存在且活跃
|
||||
let response = AgentStatusResponse {
|
||||
project_id: project_id.to_string(),
|
||||
is_alive: true,
|
||||
session_id: Some(info.session_id.to_string()),
|
||||
status: Some(info.status.clone()),
|
||||
last_activity: Some(info.last_activity),
|
||||
created_at: Some(info.created_at),
|
||||
model_provider: None, // AgentRegistry 不存储 model_provider
|
||||
};
|
||||
|
||||
info!(
|
||||
"✅ [LocalAgent] Agent status: project_id={}, is_alive=true, status={:?}",
|
||||
project_id,
|
||||
info.status
|
||||
);
|
||||
|
||||
HttpResult::success(response)
|
||||
} else {
|
||||
// Agent 不存在
|
||||
info!(
|
||||
"📭 [LocalAgent] Agent not found: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
let response = AgentStatusResponse {
|
||||
project_id: project_id.to_string(),
|
||||
is_alive: false,
|
||||
session_id: None,
|
||||
status: None,
|
||||
last_activity: None,
|
||||
created_at: None,
|
||||
model_provider: None,
|
||||
};
|
||||
|
||||
HttpResult::success(response)
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止 Agent
|
||||
async fn stop(&self, request: RcoderAgentStopRequest) -> HttpResult<RcoderAgentStopResponse> {
|
||||
info!(
|
||||
"🛑 [LocalAgent] Stop request: project_id={}",
|
||||
request.project_id
|
||||
);
|
||||
|
||||
// 1. 获取 Agent 信息
|
||||
if let Some(agent_info) = AGENT_REGISTRY.get_agent_info(&request.project_id) {
|
||||
let session_id = agent_info.session_id.to_string();
|
||||
let cancel_tx = agent_info.cancel_tx.clone();
|
||||
|
||||
// 释放读锁
|
||||
drop(agent_info);
|
||||
|
||||
// 2. 发送取消信号(如果 channel 仍然打开)
|
||||
if !cancel_tx.is_closed() {
|
||||
let session_id_obj = SessionId::new(Arc::from(session_id.as_str()));
|
||||
let cancel_notification = CancelNotification::new(session_id_obj);
|
||||
|
||||
let (result_tx, _result_rx) = oneshot::channel();
|
||||
let cancel_request = shared_types::CancelNotificationRequestWrapper {
|
||||
cancel_notification,
|
||||
result_tx,
|
||||
};
|
||||
|
||||
match cancel_tx.send(cancel_request).await {
|
||||
Ok(_) => {
|
||||
info!("[LocalAgent] Cancel signal sent: session_id={}", session_id);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [LocalAgent] Failed to send cancel signal: session_id={}, error={}",
|
||||
session_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 从 AGENT_REGISTRY 移除 Agent
|
||||
AGENT_REGISTRY.remove_by_project(&request.project_id);
|
||||
|
||||
info!(
|
||||
"✅ [LocalAgent] Agent stopped: project_id={}",
|
||||
request.project_id
|
||||
);
|
||||
|
||||
HttpResult::success(RcoderAgentStopResponse {
|
||||
success: true,
|
||||
project_id: request.project_id,
|
||||
session_id: Some(session_id),
|
||||
message: "Agent stopped successfully".to_string(),
|
||||
})
|
||||
} else {
|
||||
// Agent 不存在,幂等返回成功
|
||||
info!(
|
||||
"ℹ️ [LocalAgent] Agent not found, returning success idempotently: project_id={}",
|
||||
request.project_id
|
||||
);
|
||||
|
||||
HttpResult::success(RcoderAgentStopResponse {
|
||||
success: true,
|
||||
project_id: request.project_id,
|
||||
session_id: None,
|
||||
message: "Agent not found".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消正在执行的任务
|
||||
async fn cancel(&self, request: RcoderAgentCancelRequest) -> HttpResult<RcoderAgentCancelResponse> {
|
||||
info!(
|
||||
"🚫 [LocalAgent] Cancel request: project_id={}, session_id={:?}",
|
||||
request.project_id, request.session_id
|
||||
);
|
||||
|
||||
// 1. 查找 session_id(如果未提供,从 AGENT_REGISTRY 获取)
|
||||
let session_id = if let Some(sid) = request.session_id {
|
||||
sid
|
||||
} else {
|
||||
match AGENT_REGISTRY.get_agent_info(&request.project_id) {
|
||||
Some(info) => info.session_id.to_string(),
|
||||
None => {
|
||||
// Agent 不存在,幂等返回成功
|
||||
info!(
|
||||
"ℹ️ [LocalAgent] Agent not found, returning success idempotently: project_id={}",
|
||||
request.project_id
|
||||
);
|
||||
return HttpResult::success(RcoderAgentCancelResponse {
|
||||
success: true,
|
||||
session_id: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 获取 Agent 信息并发送取消信号
|
||||
if let Some(agent_info) = AGENT_REGISTRY.get_agent_info(&request.project_id) {
|
||||
let cancel_tx = agent_info.cancel_tx.clone();
|
||||
|
||||
// 释放读锁
|
||||
drop(agent_info);
|
||||
|
||||
// 检查是否已经空闲或停止中(幂等性)
|
||||
if cancel_tx.is_closed() {
|
||||
info!(
|
||||
"ℹ️ [LocalAgent] Agent stopped, cancel channel is closed: session_id={}",
|
||||
session_id
|
||||
);
|
||||
} else {
|
||||
// 创建取消通知
|
||||
let session_id_obj = SessionId::new(Arc::from(session_id.as_str()));
|
||||
let cancel_notification = CancelNotification::new(session_id_obj);
|
||||
|
||||
// 创建 oneshot channel 接收取消结果(HTTP 不等待结果,直接丢弃)
|
||||
let (result_tx, _result_rx) = oneshot::channel();
|
||||
let cancel_request = shared_types::CancelNotificationRequestWrapper {
|
||||
cancel_notification,
|
||||
result_tx,
|
||||
};
|
||||
|
||||
// 发送取消信号(异步)
|
||||
match cancel_tx.send(cancel_request).await {
|
||||
Ok(_) => {
|
||||
info!("[LocalAgent] Cancel signal sent: session_id={}", session_id);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [LocalAgent] Failed to send cancel signal: session_id={}, error={}",
|
||||
session_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Session 不存在,幂等返回成功
|
||||
info!(
|
||||
"ℹ️ [LocalAgent] Agent not found, returning success idempotently: session_id={}",
|
||||
session_id
|
||||
);
|
||||
}
|
||||
|
||||
HttpResult::success(RcoderAgentCancelResponse {
|
||||
success: true,
|
||||
session_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
11
qiming-rcoder/crates/agent_runner/src/service/mod.rs
Normal file
11
qiming-rcoder/crates/agent_runner/src/service/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod agent_registry;
|
||||
pub mod chat_handler;
|
||||
pub mod local_agent_service;
|
||||
mod session_cache;
|
||||
mod session_notifier;
|
||||
mod state_aware_notifier;
|
||||
|
||||
pub use agent_registry::{AGENT_REGISTRY, AgentSessionRegistry, PendingGuard};
|
||||
pub use chat_handler::{ChatHandlerContext, ChatHandlerInput, ChatHandlerOutput, handle_chat_core};
|
||||
pub use session_cache::{SESSION_CACHE, SessionData, push_session_update_with_project};
|
||||
pub use state_aware_notifier::StateAwareNotifier;
|
||||
463
qiming-rcoder/crates/agent_runner/src/service/session_cache.rs
Normal file
463
qiming-rcoder/crates/agent_runner/src/service/session_cache.rs
Normal file
@@ -0,0 +1,463 @@
|
||||
//! 全局Session缓存模块
|
||||
//!
|
||||
//! 使用LazyLock初始化全局DashMap,按session_id分组缓存统一会话消息到ringbuf循环缓冲区
|
||||
|
||||
use crate::{SessionNotify, UnifiedSessionMessage};
|
||||
use anyhow::Result;
|
||||
use dashmap::DashMap;
|
||||
use ringbuf::HeapRb;
|
||||
use ringbuf::traits::{Consumer, Observer, Producer, Split};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::AGENT_REGISTRY;
|
||||
|
||||
/// 日志截取的最大长度默认值
|
||||
const MAX_LOG_TRUNCATE_LEN: usize = 50;
|
||||
|
||||
/// 截取消息内容用于日志打印(防止日志膨胀)
|
||||
///
|
||||
/// 社区常见做法:
|
||||
/// - tracing: 通过 Subscriber 配置限制字段大小
|
||||
/// - serde: 自定义 Serialize 实现截断
|
||||
/// - 简单场景: chars().take() + 长度检查(本实现)
|
||||
fn truncate_message_for_log(data: &serde_json::Value, max_len: usize) -> String {
|
||||
// 边界检查:max_len 为 0 时返回空,避免无效计算
|
||||
if max_len == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// 优先提取原始字符串内容,避免 JSON 序列化后的引号包裹
|
||||
let s = match data.as_str() {
|
||||
Some(inner) => inner.to_string(),
|
||||
None => data.to_string(),
|
||||
};
|
||||
|
||||
// chars().count() 是一次性遍历,可以与 take() 合并优化但牺牲可读性
|
||||
// 当前实现清晰且性能可接受(短字符串场景)
|
||||
let char_count = s.chars().count();
|
||||
if char_count <= max_len {
|
||||
return s;
|
||||
}
|
||||
|
||||
// 使用 chars() 安全截取 UTF-8 字符边界
|
||||
let truncated: String = s.chars().take(max_len).collect();
|
||||
format!("{}... (truncated)", truncated)
|
||||
}
|
||||
|
||||
/// 全局Session缓存 - LazyLock初始化
|
||||
pub static SESSION_CACHE: LazyLock<DashMap<String, Arc<SessionData>>> = LazyLock::new(DashMap::new);
|
||||
|
||||
/// Session数据包装 - 极简版本,专注消息传输
|
||||
pub struct SessionData {
|
||||
command_tx: mpsc::UnboundedSender<SessionCommand>,
|
||||
// 🎯 极简优化:直接存储当前连接,无需命令传递
|
||||
current_sender: Arc<tokio::sync::Mutex<Option<mpsc::Sender<UnifiedSessionMessage>>>>,
|
||||
current_cancel: Arc<tokio::sync::Mutex<Option<CancellationToken>>>,
|
||||
}
|
||||
|
||||
impl SessionData {
|
||||
pub fn new(max_size: usize) -> Arc<Self> {
|
||||
let start_time = std::time::Instant::now();
|
||||
debug!(
|
||||
"⏱️ [SessionData::new] Starting creation, max_size={}",
|
||||
max_size
|
||||
);
|
||||
|
||||
let channel_start = std::time::Instant::now();
|
||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||
debug!(
|
||||
"⏱️ [SessionData::new] Channel creation took: {:?}",
|
||||
channel_start.elapsed()
|
||||
);
|
||||
|
||||
let arc_start = std::time::Instant::now();
|
||||
let session = Arc::new(SessionData {
|
||||
command_tx,
|
||||
current_sender: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
current_cancel: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
});
|
||||
debug!(
|
||||
"⏱️ [SessionData::new] Arc creation took: {:?}",
|
||||
arc_start.elapsed()
|
||||
);
|
||||
|
||||
let spawn_start = std::time::Instant::now();
|
||||
SessionWorker::spawn(
|
||||
max_size,
|
||||
command_rx,
|
||||
session.current_sender.clone(),
|
||||
session.current_cancel.clone(),
|
||||
);
|
||||
debug!(
|
||||
"⏱️ [SessionData::new] SessionWorker::spawn took: {:?}",
|
||||
spawn_start.elapsed()
|
||||
);
|
||||
|
||||
debug!(
|
||||
"⏱️ [SessionData::new] Total creation took: {:?}",
|
||||
start_time.elapsed()
|
||||
);
|
||||
session
|
||||
}
|
||||
|
||||
pub async fn message_count(&self) -> usize {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if self
|
||||
.command_tx
|
||||
.send(SessionCommand::MessageCount { ack: tx })
|
||||
.is_err()
|
||||
{
|
||||
warn!("Failed to send message_count command; worker has exited");
|
||||
return 0;
|
||||
}
|
||||
rx.await.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub async fn create_new_connection(
|
||||
&self,
|
||||
buffer_size: usize,
|
||||
) -> Result<(mpsc::Receiver<UnifiedSessionMessage>, CancellationToken)> {
|
||||
let start_time = std::time::Instant::now();
|
||||
debug!(
|
||||
"⏱️ [create_new_connection] Starting connection creation, buffer_size={}",
|
||||
buffer_size
|
||||
);
|
||||
|
||||
let token_start = std::time::Instant::now();
|
||||
let cancellation_token = CancellationToken::new();
|
||||
debug!(
|
||||
"⏱️ [create_new_connection] CancellationToken creation took: {:?}",
|
||||
token_start.elapsed()
|
||||
);
|
||||
|
||||
let channel_start = std::time::Instant::now();
|
||||
let (tx, rx) = mpsc::channel(buffer_size);
|
||||
debug!(
|
||||
"⏱️ [create_new_connection] mpsc channel creation took: {:?}",
|
||||
channel_start.elapsed()
|
||||
);
|
||||
|
||||
let setup_start = std::time::Instant::now();
|
||||
// 🛡️ 关键修复:使用 lock() 而非 try_lock(),确保连接一定被设置
|
||||
// try_lock() 可能失败导致 current_sender 未设置,造成消息丢失
|
||||
{
|
||||
// 取消之前的连接
|
||||
let mut current_cancel_guard = self.current_cancel.lock().await;
|
||||
if let Some(token) = current_cancel_guard.take() {
|
||||
token.cancel();
|
||||
}
|
||||
// 设置新的取消令牌
|
||||
*current_cancel_guard = Some(cancellation_token.clone());
|
||||
|
||||
// 设置新的发送器
|
||||
let mut current_sender_guard = self.current_sender.lock().await;
|
||||
*current_sender_guard = Some(tx);
|
||||
}
|
||||
debug!(
|
||||
"⏱️ [create_new_connection] Connection state setup took: {:?}",
|
||||
setup_start.elapsed()
|
||||
);
|
||||
|
||||
debug!(
|
||||
"⏱️ [create_new_connection] Total connection creation took: {:?}",
|
||||
start_time.elapsed()
|
||||
);
|
||||
Ok((rx, cancellation_token))
|
||||
}
|
||||
|
||||
pub fn push_message(&self, message: UnifiedSessionMessage) {
|
||||
if self
|
||||
.command_tx
|
||||
.send(SessionCommand::Push { message })
|
||||
.is_err()
|
||||
{
|
||||
warn!("Failed to push message; worker has exited");
|
||||
}
|
||||
}
|
||||
|
||||
/// 主动关闭当前 SSE 连接
|
||||
///
|
||||
/// 当用户取消任务时,需要主动关闭 SSE 连接,而不是让客户端一直等待
|
||||
///
|
||||
/// 关闭机制:
|
||||
/// 1. 触发 CancellationToken,让 SSE 流立即退出循环
|
||||
/// 2. 显式关闭 channel 发送端,让 rx.recv() 立即返回 None
|
||||
/// 3. 清空连接状态,防止新的消息被发送
|
||||
pub async fn close_current_connection(&self) {
|
||||
// 🎯 主动触发取消令牌,关闭 SSE 连接
|
||||
let mut current_cancel_guard = self.current_cancel.lock().await;
|
||||
if let Some(token) = current_cancel_guard.take() {
|
||||
info!("🔌 [SessionData] Triggering CancellationToken to close SSE connection");
|
||||
token.cancel();
|
||||
}
|
||||
drop(current_cancel_guard);
|
||||
|
||||
// 🎯 显式关闭 channel 发送端,让接收端立即感知到连接关闭
|
||||
let mut current_sender_guard = self.current_sender.lock().await;
|
||||
if current_sender_guard.take().is_some() {
|
||||
info!(
|
||||
"🔌 [SessionData] Explicitly closed channel sender; receiver disconnects immediately"
|
||||
);
|
||||
// 当 Sender 被 drop 时,Receiver 的 recv() 会返回 None
|
||||
// 这里通过 take() 将 sender 从 Option 中移除,触发 drop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionWorker {
|
||||
max_size: usize,
|
||||
command_rx: mpsc::UnboundedReceiver<SessionCommand>,
|
||||
// 🎯 极简优化:直接共享连接状态,无需命令传递
|
||||
current_sender: Arc<tokio::sync::Mutex<Option<mpsc::Sender<UnifiedSessionMessage>>>>,
|
||||
current_cancel: Arc<tokio::sync::Mutex<Option<CancellationToken>>>,
|
||||
}
|
||||
|
||||
impl SessionWorker {
|
||||
fn spawn(
|
||||
max_size: usize,
|
||||
command_rx: mpsc::UnboundedReceiver<SessionCommand>,
|
||||
current_sender: Arc<tokio::sync::Mutex<Option<mpsc::Sender<UnifiedSessionMessage>>>>,
|
||||
current_cancel: Arc<tokio::sync::Mutex<Option<CancellationToken>>>,
|
||||
) {
|
||||
let start_time = std::time::Instant::now();
|
||||
debug!(
|
||||
"⏱️ [SessionWorker::spawn] Starting SessionWorker creation, max_size={}",
|
||||
max_size
|
||||
);
|
||||
|
||||
let worker = SessionWorker {
|
||||
max_size,
|
||||
command_rx,
|
||||
current_sender,
|
||||
current_cancel,
|
||||
};
|
||||
|
||||
let spawn_start = std::time::Instant::now();
|
||||
tokio::spawn(worker.run());
|
||||
debug!(
|
||||
"⏱️ [SessionWorker::spawn] tokio::spawn took: {:?}",
|
||||
spawn_start.elapsed()
|
||||
);
|
||||
debug!(
|
||||
"⏱️ [SessionWorker::spawn] Total spawn took: {:?}",
|
||||
start_time.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
async fn run(mut self) {
|
||||
let (mut producer, mut consumer) = HeapRb::new(self.max_size).split();
|
||||
let mut buffered_len = 0usize;
|
||||
|
||||
while let Some(cmd) = self.command_rx.recv().await {
|
||||
match cmd {
|
||||
SessionCommand::Push { message } => {
|
||||
let should_buffer = !matches!(
|
||||
message.message_type,
|
||||
crate::model::SessionMessageType::Heartbeat
|
||||
);
|
||||
|
||||
if should_buffer {
|
||||
if producer.is_full() {
|
||||
let _ = consumer.try_pop();
|
||||
buffered_len = buffered_len.saturating_sub(1);
|
||||
}
|
||||
if producer.try_push(message.clone()).is_ok() {
|
||||
buffered_len += 1;
|
||||
} else {
|
||||
warn!("Ring buffer push failed; real-time delivery only");
|
||||
}
|
||||
}
|
||||
|
||||
// 🛡️ 关键修复:使用 lock().await 确保消息一定被发送
|
||||
// try_lock() 可能失败导致消息丢失,造成 SSE 卡死
|
||||
let mut current_sender_guard = self.current_sender.lock().await;
|
||||
if let Some(sender) = current_sender_guard.as_mut() {
|
||||
if sender.try_send(message.clone()).is_err() {
|
||||
// 如果发送失败,可能是缓冲区满了或连接已关闭
|
||||
// 注意:truncate 在锁内执行,但 50 字符开销可忽略
|
||||
warn!(
|
||||
"⚠️ SSE sender send failed, disabling real-time delivery: message_type={:?}, sub_type={}, data={}",
|
||||
message.message_type,
|
||||
message.sub_type,
|
||||
truncate_message_for_log(&message.data, MAX_LOG_TRUNCATE_LEN)
|
||||
);
|
||||
*current_sender_guard = None;
|
||||
}
|
||||
} else {
|
||||
// 连接不存在,跳过实时推送(记录为 info 级别,便于排查问题)
|
||||
info!(
|
||||
"📭 SSE sender missing, skipping real-time delivery (message buffered in ring buffer): message_type={:?}, sub_type={}, data={}",
|
||||
message.message_type,
|
||||
message.sub_type,
|
||||
truncate_message_for_log(&message.data, MAX_LOG_TRUNCATE_LEN)
|
||||
);
|
||||
}
|
||||
}
|
||||
SessionCommand::Clear { ack } => {
|
||||
let mut cleared = 0usize;
|
||||
while consumer.try_pop().is_some() {
|
||||
cleared += 1;
|
||||
}
|
||||
buffered_len = 0;
|
||||
let _ = ack.send(cleared);
|
||||
}
|
||||
SessionCommand::MessageCount { ack } => {
|
||||
let _ = ack.send(buffered_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("🔚 SessionWorker stopped");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SessionCommand {
|
||||
Push { message: UnifiedSessionMessage },
|
||||
Clear { ack: oneshot::Sender<usize> },
|
||||
MessageCount { ack: oneshot::Sender<usize> },
|
||||
}
|
||||
|
||||
/// 便捷函数:添加SessionNotify消息(自动转换为统一格式)
|
||||
///
|
||||
/// 如果 SESSION_CACHE 中不存在该 session_id 的条目,会自动创建。
|
||||
/// 这解决了 Agent 开始推送消息时 SESSION_CACHE 条目尚未由 HTTP 处理器创建的竞态问题。
|
||||
pub async fn push_session_update(session_id: &str, notify: SessionNotify) -> Result<()> {
|
||||
use dashmap::mapref::entry::Entry;
|
||||
|
||||
// 🛡️ 关键修复:使用 entry API 原子性地获取或创建 SessionData
|
||||
// 之前直接用 get(),如果 session 不存在就丢弃消息。
|
||||
// 竞态场景:Agent 开始推送消息 → push_session_update 查找 SESSION_CACHE → 不存在(因为
|
||||
// handle_chat_core 尚未返回,computer_chat.rs 还未创建条目)→ 消息被丢弃
|
||||
let session_data = {
|
||||
match SESSION_CACHE.entry(session_id.to_string()) {
|
||||
Entry::Occupied(entry) => entry.get().clone(),
|
||||
Entry::Vacant(entry) => {
|
||||
let data = SessionData::new(1000);
|
||||
info!(
|
||||
"📦 [push_session_update] SESSION_CACHE auto-created: session_id={}",
|
||||
session_id
|
||||
);
|
||||
entry.insert(data.clone());
|
||||
data
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let unified_message = notify.to_unified_message();
|
||||
|
||||
debug!(
|
||||
"📥 Pushing message to cache: session_id={}, message_type={:?}, sub_type={}",
|
||||
session_id, unified_message.message_type, unified_message.sub_type
|
||||
);
|
||||
|
||||
session_data.push_message(unified_message);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 便捷函数:添加SessionNotify消息并管理Project-Session映射
|
||||
///
|
||||
/// 这个函数会自动确保project_id只对应一个活跃的session_id
|
||||
///
|
||||
/// 这个函数会自动确保project_id只对应一个活跃的session_id
|
||||
/// 当检测到session_id变化时,会自动清理旧session的数据
|
||||
pub async fn push_session_update_with_project(
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
notify: SessionNotify,
|
||||
) -> Result<()> {
|
||||
// 确保project_id对应正确的session_id,如果变化则清理旧数据
|
||||
let cleared_count = ensure_project_session(project_id, session_id).await;
|
||||
|
||||
if cleared_count > 0 {
|
||||
info!(
|
||||
"📝 [push_session_update_with_project] Session changed, cleaned {} old messages: project_id={}, new_session_id={}",
|
||||
cleared_count, project_id, session_id
|
||||
);
|
||||
}
|
||||
|
||||
// 推送消息到新的session
|
||||
push_session_update(session_id, notify).await
|
||||
}
|
||||
|
||||
/// 确保project_id对应正确的session_id
|
||||
///
|
||||
/// 使用统一的 AGENT_REGISTRY 管理 project-session 映射
|
||||
/// 如果project_id对应的session_id发生变化,会自动清理旧session的数据
|
||||
/// 如果session_id相同,则不做任何操作
|
||||
///
|
||||
/// 参数:
|
||||
/// - project_id: 项目ID
|
||||
/// - session_id: 当前会话ID
|
||||
///
|
||||
/// 返回值: 如果清理了旧数据则返回清理的消息数量,否则返回0
|
||||
pub async fn ensure_project_session(project_id: &str, session_id: &str) -> usize {
|
||||
// 使用统一 Registry 检查当前映射
|
||||
let mapped_session_id = AGENT_REGISTRY.get_session_by_project(project_id);
|
||||
|
||||
match mapped_session_id {
|
||||
Some(mapped_sid) if mapped_sid == session_id => {
|
||||
// session_id 相同,不需要做任何操作
|
||||
debug!(
|
||||
"📋 Project session mapping unchanged: project_id={}, session_id={}",
|
||||
project_id, session_id
|
||||
);
|
||||
0
|
||||
}
|
||||
Some(old_session_id) => {
|
||||
// session_id 发生变化,需要清理旧 session 的数据
|
||||
info!(
|
||||
"🔄 Detected project session change: project_id={}, old_session_id={}, new_session_id={}",
|
||||
project_id, old_session_id, session_id
|
||||
);
|
||||
|
||||
// 🛡️ 关键修复:先主动关闭旧 session 的 SSE 连接,再移除缓存
|
||||
// 之前直接 remove 导致旧 SSE 连接的心跳流继续发送但不再收到业务消息,
|
||||
// 前端如果没有及时关闭旧连接,会看到孤立的心跳流
|
||||
let cleared_count = if let Some((_, old_session_data)) =
|
||||
SESSION_CACHE.remove(&old_session_id)
|
||||
{
|
||||
old_session_data.close_current_connection().await;
|
||||
info!(
|
||||
"🔌 [ensure_project_session] Closed old session SSE connection: old_session_id={}",
|
||||
old_session_id
|
||||
);
|
||||
1 // 移除了1个session
|
||||
} else {
|
||||
0 // session不存在
|
||||
};
|
||||
|
||||
// 更新 AGENT_REGISTRY 中的映射关系
|
||||
let _ = AGENT_REGISTRY.update_session(project_id, session_id);
|
||||
|
||||
if cleared_count > 0 {
|
||||
info!(
|
||||
"🧹 Cleared old session data and updated mapping: project_id={}, old_session_id={}, new_session_id={}, cleared_count={}",
|
||||
project_id, old_session_id, session_id, cleared_count
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"📝 Updated project session mapping: project_id={}, old_session_id={}, new_session_id={}",
|
||||
project_id, old_session_id, session_id
|
||||
);
|
||||
}
|
||||
|
||||
cleared_count
|
||||
}
|
||||
None => {
|
||||
// 第一次建立映射关系(无旧映射)
|
||||
// 注意:此时 AGENT_REGISTRY 中可能还没有这个 project 的记录
|
||||
// 这种情况下不需要调用 update_session,因为 agent 注册时会调用 register
|
||||
info!(
|
||||
"🆕 Project session first seen: project_id={}, session_id={}",
|
||||
project_id, session_id
|
||||
);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! SessionNotifier 实现
|
||||
//!
|
||||
//! 实现 agent_abstraction 定义的 SessionNotifier trait,
|
||||
//! 用于推送 SSE 消息到前端。
|
||||
|
||||
use agent_abstraction::SessionNotifier;
|
||||
use async_trait::async_trait;
|
||||
use shared_types::{
|
||||
AgentSessionUpdate, SessionNotify, SessionPromptEnd, SessionPromptError, SessionPromptStart,
|
||||
};
|
||||
|
||||
use super::push_session_update_with_project;
|
||||
|
||||
/// SSE 消息推送器
|
||||
///
|
||||
/// 实现 SessionNotifier trait,将会话消息推送到 SSE 连接。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SseSessionNotifier;
|
||||
|
||||
impl SseSessionNotifier {
|
||||
/// 创建新的 SSE 消息推送器
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 anyhow::Error 转换为 Box<dyn std::error::Error + Send + Sync>
|
||||
fn convert_error(e: anyhow::Error) -> Box<dyn std::error::Error + Send + Sync> {
|
||||
Box::new(std::io::Error::other(e.to_string()))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SessionNotifier for SseSessionNotifier {
|
||||
async fn notify_prompt_start(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
request_id: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let notify = SessionNotify::SessionPromptStart(SessionPromptStart {
|
||||
session_id: session_id.to_string(),
|
||||
request_id,
|
||||
});
|
||||
|
||||
push_session_update_with_project(project_id, session_id, notify)
|
||||
.await
|
||||
.map_err(convert_error)
|
||||
}
|
||||
|
||||
async fn notify_prompt_end(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
stop_reason: agent_client_protocol::schema::StopReason,
|
||||
error_message: Option<String>,
|
||||
request_id: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let notify = SessionNotify::SessionPromptEnd(SessionPromptEnd {
|
||||
session_id: session_id.to_string(),
|
||||
stop_reason,
|
||||
error_message,
|
||||
request_id,
|
||||
});
|
||||
|
||||
push_session_update_with_project(project_id, session_id, notify)
|
||||
.await
|
||||
.map_err(convert_error)
|
||||
}
|
||||
|
||||
async fn notify_prompt_error(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
error: agent_client_protocol::schema::Error,
|
||||
request_id: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let notify = SessionNotify::SessionPromptError(SessionPromptError {
|
||||
session_id: session_id.to_string(),
|
||||
error,
|
||||
request_id,
|
||||
});
|
||||
|
||||
push_session_update_with_project(project_id, session_id, notify)
|
||||
.await
|
||||
.map_err(convert_error)
|
||||
}
|
||||
|
||||
async fn notify_session_update(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
session_update: agent_client_protocol::schema::SessionUpdate,
|
||||
request_id: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let notify = SessionNotify::AgentSessionUpdate(Box::new(AgentSessionUpdate {
|
||||
session_id: session_id.to_string(),
|
||||
session_update,
|
||||
request_id,
|
||||
}));
|
||||
|
||||
push_session_update_with_project(project_id, session_id, notify)
|
||||
.await
|
||||
.map_err(convert_error)
|
||||
}
|
||||
|
||||
async fn notify(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
notify: SessionNotify,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
push_session_update_with_project(project_id, session_id, notify)
|
||||
.await
|
||||
.map_err(convert_error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! StateAwareNotifier 实现
|
||||
//!
|
||||
//! 状态感知的 SessionNotifier 包装器,在推送 SSE 消息的同时同步更新 Agent 状态。
|
||||
//!
|
||||
//! ## 核心职责
|
||||
//! 1. 委托给 SseSessionNotifier 推送 SSE 消息
|
||||
//! 2. 同步更新 AGENT_REGISTRY 状态
|
||||
//! 3. 保持状态转换的原子性和一致性
|
||||
|
||||
use agent_abstraction::SessionNotifier;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use super::AGENT_REGISTRY;
|
||||
use super::session_notifier::SseSessionNotifier;
|
||||
use shared_types::{AgentStatus, SessionNotify};
|
||||
|
||||
/// 状态感知的 SessionNotifier 包装器
|
||||
///
|
||||
/// 通过委托模式包装 SseSessionNotifier,在推送 SSE 消息的同时同步更新
|
||||
/// AGENT_REGISTRY 中的 Agent 状态。
|
||||
///
|
||||
/// # 设计特点
|
||||
/// - **委托模式**:所有 SSE 推送操作委托给内部的 SseSessionNotifier
|
||||
/// - **原子性状态更新**:使用 AGENT_REGISTRY 确保状态更新的原子性
|
||||
/// - **状态同步顺序**:
|
||||
/// - PromptStart: 先更新状态为 Active,再推送 SSE
|
||||
/// - PromptEnd: 先推送 SSE,再恢复状态为 Idle
|
||||
/// - PromptError: 先推送错误消息,再恢复状态为 Idle
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StateAwareNotifier {
|
||||
/// 内部 SSE 推送器
|
||||
inner: Arc<SseSessionNotifier>,
|
||||
}
|
||||
|
||||
impl StateAwareNotifier {
|
||||
/// 创建新的 StateAwareNotifier 实例
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(SseSessionNotifier::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 P1 修复: 更新 Agent 状态(原子操作)
|
||||
///
|
||||
/// 使用 `try_update_agent_info` 方法实现原子性状态更新,
|
||||
/// 避免 TOCTOU 竞态条件:读锁释放 → 时间窗口 → 写锁更新。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `status`: 新的 Agent 状态
|
||||
fn update_agent_status(&self, project_id: &str, status: AgentStatus) {
|
||||
AGENT_REGISTRY.try_update_agent_info(project_id, |info| {
|
||||
let old_status = info.status;
|
||||
if old_status != status {
|
||||
info.status = status;
|
||||
info.last_activity = chrono::Utc::now();
|
||||
debug!(
|
||||
"🔄 [atomic_status] Project[{}] status: {:?} -> {:?}",
|
||||
project_id, old_status, status
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StateAwareNotifier {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SessionNotifier for StateAwareNotifier {
|
||||
/// 推送会话开始通知
|
||||
///
|
||||
/// 顺序:
|
||||
/// 1. 更新 Agent 状态为 Active
|
||||
/// 2. 推送 SessionPromptStart 到 SSE
|
||||
async fn notify_prompt_start(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
request_id: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
info!(
|
||||
"📨 Project[{}] sending SessionPromptStart notification, session_id={}, request_id={:?}",
|
||||
project_id, session_id, request_id
|
||||
);
|
||||
|
||||
// 1. 更新状态为 Active(在推送 SSE 之前)
|
||||
self.update_agent_status(project_id, AgentStatus::Active);
|
||||
|
||||
// 2. 推送 SSE 消息
|
||||
self.inner
|
||||
.notify_prompt_start(project_id, session_id, request_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 推送会话结束通知
|
||||
///
|
||||
/// 顺序:
|
||||
/// 1. 推送 SessionPromptEnd 到 SSE
|
||||
/// 2. 恢复 Agent 状态为 Idle
|
||||
async fn notify_prompt_end(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
stop_reason: agent_client_protocol::schema::StopReason,
|
||||
error_message: Option<String>,
|
||||
request_id: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
info!(
|
||||
"✅ Project[{}] sending SessionPromptEnd notification, session_id={}, stop_reason={:?}, request_id={:?}",
|
||||
project_id, session_id, stop_reason, request_id
|
||||
);
|
||||
|
||||
// 1. 推送 SSE 消息(在恢复状态之前)
|
||||
let result = self
|
||||
.inner
|
||||
.notify_prompt_end(
|
||||
project_id,
|
||||
session_id,
|
||||
stop_reason,
|
||||
error_message,
|
||||
request_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 2. 恢复状态为 Idle
|
||||
self.update_agent_status(project_id, AgentStatus::Idle);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 推送会话错误通知
|
||||
///
|
||||
/// 顺序:
|
||||
/// 1. 推送 SessionPromptError 到 SSE
|
||||
/// 2. 恢复 Agent 状态为 Idle
|
||||
async fn notify_prompt_error(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
error: agent_client_protocol::schema::Error,
|
||||
request_id: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
error!(
|
||||
"❌ Project[{}] sending SessionPromptError notification, session_id={}, error_code={}, error_message={}, request_id={:?}",
|
||||
project_id, session_id, error.code, error.message, request_id
|
||||
);
|
||||
|
||||
// 1. 推送错误消息(在恢复状态之前)
|
||||
let result = self
|
||||
.inner
|
||||
.notify_prompt_error(project_id, session_id, error, request_id)
|
||||
.await;
|
||||
|
||||
// 2. 恢复状态为 Idle
|
||||
self.update_agent_status(project_id, AgentStatus::Idle);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 推送 Agent 会话更新通知
|
||||
///
|
||||
/// 不更新 Agent 状态,仅推送 SSE 消息。
|
||||
async fn notify_session_update(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
session_update: agent_client_protocol::schema::SessionUpdate,
|
||||
request_id: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
debug!(
|
||||
"🔄 Project[{}] sending SessionUpdate notification, session_id={}",
|
||||
project_id, session_id
|
||||
);
|
||||
|
||||
// 委托给内部 notifier,不更新状态
|
||||
self.inner
|
||||
.notify_session_update(project_id, session_id, session_update, request_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 推送通用会话通知
|
||||
///
|
||||
/// 不更新 Agent 状态,仅推送 SSE 消息。
|
||||
async fn notify(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
notify: SessionNotify,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
debug!(
|
||||
"📢 Project[{}] sending generic session notification, session_id={}",
|
||||
project_id, session_id
|
||||
);
|
||||
|
||||
// 委托给内部 notifier
|
||||
self.inner.notify(project_id, session_id, notify).await
|
||||
}
|
||||
}
|
||||
126
qiming-rcoder/crates/agent_runner/src/testing/blocking.rs
Normal file
126
qiming-rcoder/crates/agent_runner/src/testing/blocking.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
//! 测试用阻塞注入
|
||||
//!
|
||||
//! 用于模拟极端场景 (Agent 阻塞、超时等)
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 阻塞配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlockingConfig {
|
||||
/// 是否阻塞 new_session
|
||||
pub block_new_session: bool,
|
||||
/// 是否阻塞 prompt
|
||||
pub block_prompt: bool,
|
||||
/// 阻塞持续时间
|
||||
pub block_duration: Duration,
|
||||
}
|
||||
|
||||
impl Default for BlockingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
block_new_session: false,
|
||||
block_prompt: false,
|
||||
block_duration: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局阻塞配置 (线程安全)
|
||||
pub static BLOCKING_CONFIG: std::sync::LazyLock<Arc<std::sync::RwLock<BlockingConfig>>> =
|
||||
std::sync::LazyLock::new(|| Arc::new(std::sync::RwLock::new(BlockingConfig::default())));
|
||||
|
||||
/// 注入阻塞 (用于测试)
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use agent_runner::testing::blocking::{BlockingConfig, inject_blocking};
|
||||
/// use std::time::Duration;
|
||||
///
|
||||
/// // 注入 30 秒阻塞
|
||||
/// inject_blocking(BlockingConfig {
|
||||
/// block_prompt: true,
|
||||
/// block_duration: Duration::from_secs(30),
|
||||
/// ..Default::default()
|
||||
/// });
|
||||
/// ```
|
||||
pub fn inject_blocking(config: BlockingConfig) {
|
||||
tracing::warn!("🧪 [TEST] Blocking config updated: {:?}", config);
|
||||
let mut global = BLOCKING_CONFIG.write().unwrap();
|
||||
*global = config;
|
||||
}
|
||||
|
||||
/// 检查并执行阻塞 (在 agent_worker_with_heartbeat 中调用)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `blocking_type` - 阻塞类型: "new_session" 或 "prompt"
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// # use agent_runner::testing::blocking::maybe_block;
|
||||
/// # async fn test() {
|
||||
/// // 在 agent_worker_with_heartbeat 中调用
|
||||
/// maybe_block("prompt").await;
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn maybe_block(blocking_type: &str) {
|
||||
let config = BLOCKING_CONFIG.read().unwrap();
|
||||
|
||||
let should_block = match blocking_type {
|
||||
"new_session" => config.block_new_session,
|
||||
"prompt" => config.block_prompt,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if should_block {
|
||||
tracing::warn!(
|
||||
"🧪 [TEST] Injected blocking: {}, duration: {:?}",
|
||||
blocking_type,
|
||||
config.block_duration
|
||||
);
|
||||
tokio::time::sleep(config.block_duration).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置阻塞配置 (用于测试清理)
|
||||
pub fn reset_blocking() {
|
||||
inject_blocking(BlockingConfig::default());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_blocking_config_default() {
|
||||
let config = BlockingConfig::default();
|
||||
assert!(!config.block_new_session);
|
||||
assert!(!config.block_prompt);
|
||||
assert_eq!(config.block_duration, Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_and_reset_blocking() {
|
||||
// 注入配置
|
||||
inject_blocking(BlockingConfig {
|
||||
block_prompt: true,
|
||||
block_duration: Duration::from_secs(10),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let config = BLOCKING_CONFIG.read().unwrap();
|
||||
assert!(config.block_prompt);
|
||||
assert_eq!(config.block_duration, Duration::from_secs(10));
|
||||
|
||||
// 重置配置
|
||||
drop(config);
|
||||
reset_blocking();
|
||||
|
||||
let config = BLOCKING_CONFIG.read().unwrap();
|
||||
assert!(!config.block_prompt);
|
||||
assert_eq!(config.block_duration, Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
226
qiming-rcoder/crates/agent_runner/src/testing/fixtures.rs
Normal file
226
qiming-rcoder/crates/agent_runner/src/testing/fixtures.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! 测试 Fixtures
|
||||
//!
|
||||
//! 提供测试用的构造器和辅助工具
|
||||
|
||||
use crate::proxy_agent::AgentRequest;
|
||||
use agent_abstraction::PromptMessage;
|
||||
use shared_types::{Attachment, ServiceType};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 测试请求构造器
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use agent_runner::testing::fixtures::TestRequestBuilder;
|
||||
///
|
||||
/// // 创建基础测试请求
|
||||
/// let (req, resp_rx) = TestRequestBuilder::new()
|
||||
/// .project_id("test-project")
|
||||
/// .content("Hello, Agent!")
|
||||
/// .build();
|
||||
/// ```
|
||||
pub struct TestRequestBuilder {
|
||||
project_id: String,
|
||||
content: String,
|
||||
request_id: String,
|
||||
attachments: Vec<Attachment>,
|
||||
session_id: Option<String>,
|
||||
project_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for TestRequestBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TestRequestBuilder {
|
||||
/// 创建新的测试请求构造器
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
project_id: "test-project".to_string(),
|
||||
content: "Hello, Agent!".to_string(),
|
||||
request_id: uuid::Uuid::new_v4().to_string().replace("-", ""),
|
||||
attachments: vec![],
|
||||
session_id: None,
|
||||
project_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 project_id
|
||||
pub fn project_id(mut self, id: &str) -> Self {
|
||||
self.project_id = id.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置请求内容
|
||||
pub fn content(mut self, content: &str) -> Self {
|
||||
self.content = content.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置 request_id
|
||||
pub fn request_id(mut self, request_id: &str) -> Self {
|
||||
self.request_id = request_id.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置 session_id
|
||||
pub fn session_id(mut self, session_id: &str) -> Self {
|
||||
self.session_id = Some(session_id.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置项目路径
|
||||
pub fn project_path(mut self, path: &str) -> Self {
|
||||
self.project_path = Some(PathBuf::from(path));
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加附件
|
||||
pub fn add_attachment(mut self, attachment: Attachment) -> Self {
|
||||
self.attachments.push(attachment);
|
||||
self
|
||||
}
|
||||
|
||||
/// 构建 PromptMessage(用于测试验证)
|
||||
///
|
||||
/// 返回构建的 PromptMessage,可以直接访问字段进行断言验证
|
||||
pub fn build_prompt_message(&self) -> PromptMessage {
|
||||
PromptMessage {
|
||||
content: self.content.clone(),
|
||||
project_id: self.project_id.clone(),
|
||||
project_path: self
|
||||
.project_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp/test")),
|
||||
session_id: self.session_id.clone(),
|
||||
request_id: self.request_id.clone(),
|
||||
attachments: self.attachments.clone(),
|
||||
data_source_attachments: vec![],
|
||||
service_type: ServiceType::RCoder,
|
||||
system_prompt_override: None,
|
||||
user_prompt_template_override: None,
|
||||
agent_config_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建测试请求
|
||||
pub fn build(
|
||||
self,
|
||||
) -> (
|
||||
AgentRequest,
|
||||
tokio::sync::oneshot::Receiver<crate::model::ChatPromptResponse>,
|
||||
) {
|
||||
let prompt_message = self.build_prompt_message();
|
||||
AgentRequest::new(prompt_message, None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_test_request_builder_default() {
|
||||
let builder = TestRequestBuilder::new();
|
||||
let prompt = builder.build_prompt_message();
|
||||
|
||||
// 验证默认值
|
||||
assert_eq!(prompt.project_id, "test-project");
|
||||
assert_eq!(prompt.content, "Hello, Agent!");
|
||||
assert!(!prompt.request_id.is_empty(), "request_id 应该自动生成");
|
||||
assert_eq!(prompt.project_path, PathBuf::from("/tmp/test"));
|
||||
assert!(prompt.session_id.is_none());
|
||||
assert!(prompt.attachments.is_empty());
|
||||
assert_eq!(prompt.service_type, ServiceType::RCoder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_test_request_builder_with_project_id() {
|
||||
let prompt = TestRequestBuilder::new()
|
||||
.project_id("custom-project")
|
||||
.build_prompt_message();
|
||||
|
||||
assert_eq!(prompt.project_id, "custom-project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_test_request_builder_with_content() {
|
||||
let prompt = TestRequestBuilder::new()
|
||||
.content("Custom content")
|
||||
.build_prompt_message();
|
||||
|
||||
assert_eq!(prompt.content, "Custom content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_test_request_builder_with_session_id() {
|
||||
let prompt = TestRequestBuilder::new()
|
||||
.session_id("test-session-123")
|
||||
.build_prompt_message();
|
||||
|
||||
assert_eq!(prompt.session_id, Some("test-session-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_test_request_builder_with_request_id() {
|
||||
let prompt = TestRequestBuilder::new()
|
||||
.request_id("custom-request-id")
|
||||
.build_prompt_message();
|
||||
|
||||
assert_eq!(prompt.request_id, "custom-request-id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_test_request_builder_with_project_path() {
|
||||
let prompt = TestRequestBuilder::new()
|
||||
.project_path("/home/user/project")
|
||||
.build_prompt_message();
|
||||
|
||||
assert_eq!(prompt.project_path, PathBuf::from("/home/user/project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_test_request_builder_chain() {
|
||||
// 验证链式调用和所有字段设置
|
||||
let prompt = TestRequestBuilder::new()
|
||||
.project_id("my-project")
|
||||
.content("Test content")
|
||||
.request_id("custom-request-id")
|
||||
.session_id("session-456")
|
||||
.project_path("/home/user/project")
|
||||
.build_prompt_message();
|
||||
|
||||
assert_eq!(prompt.project_id, "my-project");
|
||||
assert_eq!(prompt.content, "Test content");
|
||||
assert_eq!(prompt.request_id, "custom-request-id");
|
||||
assert_eq!(prompt.session_id, Some("session-456".to_string()));
|
||||
assert_eq!(prompt.project_path, PathBuf::from("/home/user/project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_test_request_builder_build_returns_receiver() {
|
||||
// 验证 build() 返回的接收器可用
|
||||
let (_req, mut rx) = TestRequestBuilder::new().build();
|
||||
|
||||
// 验证接收器尚未收到消息
|
||||
assert!(rx.try_recv().is_err(), "接收器应该为空");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_test_request_builder_request_id_auto_generated() {
|
||||
// 验证每次构建生成不同的 request_id
|
||||
let builder1 = TestRequestBuilder::new();
|
||||
let builder2 = TestRequestBuilder::new();
|
||||
|
||||
let prompt1 = builder1.build_prompt_message();
|
||||
let prompt2 = builder2.build_prompt_message();
|
||||
|
||||
assert_ne!(
|
||||
prompt1.request_id, prompt2.request_id,
|
||||
"每次构建应该生成不同的 request_id"
|
||||
);
|
||||
}
|
||||
}
|
||||
14
qiming-rcoder/crates/agent_runner/src/testing/mod.rs
Normal file
14
qiming-rcoder/crates/agent_runner/src/testing/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
//! 测试辅助模块
|
||||
//!
|
||||
//! 仅在 `testing` feature 启用时编译
|
||||
|
||||
// 阻塞注入模块 (用于极端场景测试)
|
||||
#[cfg(feature = "test-blocking")]
|
||||
pub mod blocking;
|
||||
|
||||
#[cfg(feature = "test-blocking")]
|
||||
pub use blocking::{BlockingConfig, inject_blocking};
|
||||
|
||||
// 测试 Fixtures (通用测试辅助工具)
|
||||
pub mod fixtures;
|
||||
pub use fixtures::TestRequestBuilder;
|
||||
428
qiming-rcoder/crates/agent_runner/src/utils/content_builder.rs
Normal file
428
qiming-rcoder/crates/agent_runner/src/utils/content_builder.rs
Normal file
@@ -0,0 +1,428 @@
|
||||
//! Content Builder 工具
|
||||
//!
|
||||
//! 将 Attachment 转换为 ACP 协议的 ContentBlock
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
use agent_client_protocol::schema::{
|
||||
AudioContent, BlobResourceContents, ContentBlock, EmbeddedResource, EmbeddedResourceResource,
|
||||
ImageContent, ResourceLink, TextContent, TextResourceContents,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::model::{Attachment, AttachmentSource};
|
||||
|
||||
/// Content Builder - 将附件转换为 ACP ContentBlock
|
||||
pub struct ContentBuilder;
|
||||
|
||||
impl ContentBuilder {
|
||||
/// 将单个附件转换为 ContentBlock
|
||||
pub async fn attachment_to_content_block(
|
||||
attachment: &Attachment,
|
||||
project_path: &std::path::Path,
|
||||
) -> Result<Option<ContentBlock>> {
|
||||
match attachment {
|
||||
Attachment::Text(text_attachment) => {
|
||||
Self::text_to_content_block(text_attachment, project_path).await
|
||||
}
|
||||
Attachment::Image(image_attachment) => {
|
||||
Self::image_to_content_block(image_attachment, project_path).await
|
||||
}
|
||||
Attachment::Audio(audio_attachment) => {
|
||||
Self::audio_to_content_block(audio_attachment, project_path).await
|
||||
}
|
||||
Attachment::Document(document_attachment) => {
|
||||
Self::document_to_content_block(document_attachment, project_path).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将多个附件转换为 ContentBlock 列表
|
||||
/// 文件不存在或无法读取的附件会被静默忽略
|
||||
pub async fn attachments_to_content_blocks(
|
||||
attachments: &[Attachment],
|
||||
project_path: &std::path::Path,
|
||||
) -> Result<Vec<ContentBlock>> {
|
||||
let mut content_blocks = Vec::new();
|
||||
|
||||
for attachment in attachments {
|
||||
match Self::attachment_to_content_block(attachment, project_path).await {
|
||||
Ok(Some(content_block)) => {
|
||||
content_blocks.push(content_block);
|
||||
}
|
||||
Ok(None) => {
|
||||
// 文件不存在或无法读取,静默忽略
|
||||
tracing::warn!(
|
||||
"Attachment could not be loaded and was ignored: attachment_id={}",
|
||||
attachment.id()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// 其他错误(如网络错误),记录警告但继续处理
|
||||
tracing::warn!(
|
||||
"⚠️ Attachment conversion failed and was ignored: attachment_id={}, error={:?}",
|
||||
attachment.id(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(content_blocks)
|
||||
}
|
||||
|
||||
/// 文本附件转换为 ContentBlock
|
||||
/// 如果文件不存在或是压缩文件,返回 Ok(None)
|
||||
async fn text_to_content_block(
|
||||
text_attachment: &crate::model::TextAttachment,
|
||||
project_path: &std::path::Path,
|
||||
) -> Result<Option<ContentBlock>> {
|
||||
let text_content = match &text_attachment.source {
|
||||
AttachmentSource::FilePath { path } => {
|
||||
let file_path = project_path.join(path);
|
||||
|
||||
// 检查文件是否存在
|
||||
if !file_path.exists() {
|
||||
tracing::warn!("Attachment file not found, ignored: {:?}", file_path);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 检查是否为二进制文件(常见压缩格式)
|
||||
if let Some(ext) = file_path.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||
if matches!(
|
||||
ext_str.as_str(),
|
||||
"gz" | "zip" | "tar" | "bz2" | "xz" | "7z" | "rar"
|
||||
) {
|
||||
tracing::warn!(
|
||||
"⚠️ Compressed files are not supported as Text attachments, ignored: {:?} (extension: {})",
|
||||
file_path,
|
||||
ext_str
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试读取文本文件
|
||||
let text = match tokio::fs::read_to_string(&file_path).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"⚠️ Failed to read text file, ignored: {:?}, error: {} (may be binary or not UTF-8 encoded)",
|
||||
file_path,
|
||||
e
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
TextContent::new(text)
|
||||
}
|
||||
AttachmentSource::Base64 { data, mime_type: _ } => {
|
||||
let text = String::from_utf8(general_purpose::STANDARD.decode(data)?)?;
|
||||
|
||||
TextContent::new(text)
|
||||
}
|
||||
AttachmentSource::Url { url } => {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client.get(url).send().await?;
|
||||
let text = response.text().await?;
|
||||
|
||||
TextContent::new(text)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(ContentBlock::Text(text_content)))
|
||||
}
|
||||
|
||||
/// 图像附件转换为 ContentBlock
|
||||
/// 如果文件不存在,返回 Ok(None)
|
||||
async fn image_to_content_block(
|
||||
image_attachment: &crate::model::ImageAttachment,
|
||||
project_path: &std::path::Path,
|
||||
) -> Result<Option<ContentBlock>> {
|
||||
let (data, uri) = match &image_attachment.source {
|
||||
AttachmentSource::FilePath { path } => {
|
||||
let file_path = project_path.join(path);
|
||||
|
||||
// 检查文件是否存在
|
||||
if !file_path.exists() {
|
||||
tracing::warn!("Image file not found, ignored: {:?}", file_path);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 尝试读取文件
|
||||
let data = match tokio::fs::read(&file_path).await {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to read image file, ignored: {:?}, error: {}",
|
||||
file_path,
|
||||
e
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let base64_data = general_purpose::STANDARD.encode(data);
|
||||
let uri = file_path.to_string_lossy().to_string();
|
||||
(base64_data, Some(uri))
|
||||
}
|
||||
AttachmentSource::Base64 { data, .. } => (data.clone(), None),
|
||||
AttachmentSource::Url { url } => {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client.get(url).send().await?;
|
||||
let data = response.bytes().await?;
|
||||
let base64_data = general_purpose::STANDARD.encode(data);
|
||||
(base64_data, Some(url.clone()))
|
||||
}
|
||||
};
|
||||
|
||||
let mut image_content = ImageContent::new(data, image_attachment.mime_type.clone());
|
||||
if let Some(u) = uri {
|
||||
image_content = image_content.uri(u);
|
||||
}
|
||||
Ok(Some(ContentBlock::Image(image_content)))
|
||||
}
|
||||
|
||||
/// 音频附件转换为 ContentBlock
|
||||
/// 如果文件不存在,返回 Ok(None)
|
||||
async fn audio_to_content_block(
|
||||
audio_attachment: &crate::model::AudioAttachment,
|
||||
project_path: &std::path::Path,
|
||||
) -> Result<Option<ContentBlock>> {
|
||||
let data = match &audio_attachment.source {
|
||||
AttachmentSource::FilePath { path } => {
|
||||
let file_path = project_path.join(path);
|
||||
|
||||
// 检查文件是否存在
|
||||
if !file_path.exists() {
|
||||
tracing::warn!("Audio file not found, ignored: {:?}", file_path);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 尝试读取文件
|
||||
let data = match tokio::fs::read(&file_path).await {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to read audio file, ignored: {:?}, error: {}",
|
||||
file_path,
|
||||
e
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
general_purpose::STANDARD.encode(data)
|
||||
}
|
||||
AttachmentSource::Base64 { data, .. } => data.clone(),
|
||||
AttachmentSource::Url { url } => {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client.get(url).send().await?;
|
||||
let data = response.bytes().await?;
|
||||
general_purpose::STANDARD.encode(data)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(ContentBlock::Audio(AudioContent::new(
|
||||
data,
|
||||
audio_attachment.mime_type.clone(),
|
||||
))))
|
||||
}
|
||||
|
||||
/// 文档附件转换为 ContentBlock
|
||||
/// 如果文件不存在,返回 Ok(None)
|
||||
async fn document_to_content_block(
|
||||
document_attachment: &crate::model::DocumentAttachment,
|
||||
project_path: &std::path::Path,
|
||||
) -> Result<Option<ContentBlock>> {
|
||||
match &document_attachment.source {
|
||||
AttachmentSource::FilePath { path } => {
|
||||
let file_path = project_path.join(path);
|
||||
|
||||
// 检查文件是否存在
|
||||
if !file_path.exists() {
|
||||
tracing::warn!("Document file not found, ignored: {:?}", file_path);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let uri = file_path.to_string_lossy().to_string();
|
||||
|
||||
// 尝试读取为文本,如果失败则作为二进制处理
|
||||
if document_attachment.mime_type.starts_with("text/") {
|
||||
match tokio::fs::read_to_string(&file_path).await {
|
||||
Ok(text) => {
|
||||
let mut text_contents = TextResourceContents::new(text, uri);
|
||||
if let Some(mime_type) = Some(document_attachment.mime_type.clone()) {
|
||||
text_contents = text_contents.mime_type(mime_type);
|
||||
}
|
||||
Ok(Some(ContentBlock::Resource(EmbeddedResource::new(
|
||||
EmbeddedResourceResource::TextResourceContents(text_contents),
|
||||
))))
|
||||
}
|
||||
Err(_) => {
|
||||
// 文本读取失败,尝试作为二进制处理
|
||||
Self::handle_binary_document(
|
||||
&file_path,
|
||||
&document_attachment.mime_type,
|
||||
&uri,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Self::handle_binary_document(&file_path, &document_attachment.mime_type, &uri)
|
||||
.await
|
||||
}
|
||||
}
|
||||
AttachmentSource::Base64 { data, mime_type } => {
|
||||
let uri = format!("base64://{}", document_attachment.id);
|
||||
|
||||
if mime_type.starts_with("text/") {
|
||||
match String::from_utf8(general_purpose::STANDARD.decode(data)?) {
|
||||
Ok(text) => {
|
||||
let mut text_contents = TextResourceContents::new(text, uri);
|
||||
text_contents = text_contents.mime_type(mime_type.clone());
|
||||
Ok(Some(ContentBlock::Resource(EmbeddedResource::new(
|
||||
EmbeddedResourceResource::TextResourceContents(text_contents),
|
||||
))))
|
||||
}
|
||||
Err(_) => {
|
||||
// 文本解码失败,作为二进制处理
|
||||
let mut blob_contents = BlobResourceContents::new(data.clone(), uri);
|
||||
blob_contents = blob_contents.mime_type(mime_type.clone());
|
||||
Ok(Some(ContentBlock::Resource(EmbeddedResource::new(
|
||||
EmbeddedResourceResource::BlobResourceContents(blob_contents),
|
||||
))))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut blob_contents = BlobResourceContents::new(data.clone(), uri);
|
||||
blob_contents = blob_contents.mime_type(mime_type.clone());
|
||||
Ok(Some(ContentBlock::Resource(EmbeddedResource::new(
|
||||
EmbeddedResourceResource::BlobResourceContents(blob_contents),
|
||||
))))
|
||||
}
|
||||
}
|
||||
AttachmentSource::Url { url } => {
|
||||
// URL 资源作为 ResourceLink 处理
|
||||
let name = document_attachment
|
||||
.filename
|
||||
.clone()
|
||||
.unwrap_or_else(|| "document".to_string());
|
||||
let mut resource_link = ResourceLink::new(name, url.clone());
|
||||
resource_link = resource_link.mime_type(document_attachment.mime_type.clone());
|
||||
|
||||
// 只有当值存在时才设置
|
||||
if let Some(description) = &document_attachment.description {
|
||||
resource_link = resource_link.description(description.clone());
|
||||
}
|
||||
if let Some(size) = document_attachment.size {
|
||||
resource_link = resource_link.size(size as i64);
|
||||
}
|
||||
if let Some(filename) = &document_attachment.filename {
|
||||
resource_link = resource_link.title(filename.clone());
|
||||
}
|
||||
Ok(Some(ContentBlock::ResourceLink(resource_link)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理二进制文档
|
||||
/// 如果文件无法读取,返回 Ok(None)
|
||||
async fn handle_binary_document(
|
||||
file_path: &Path,
|
||||
mime_type: &str,
|
||||
uri: &str,
|
||||
) -> Result<Option<ContentBlock>> {
|
||||
let data = match tokio::fs::read(file_path).await {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"⚠️ Cannot read binary document, ignored: {:?}, error: {}",
|
||||
file_path,
|
||||
e
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let blob = general_purpose::STANDARD.encode(data);
|
||||
|
||||
let mut blob_contents = BlobResourceContents::new(blob, uri.to_string());
|
||||
blob_contents = blob_contents.mime_type(mime_type.to_string());
|
||||
Ok(Some(ContentBlock::Resource(EmbeddedResource::new(
|
||||
EmbeddedResourceResource::BlobResourceContents(blob_contents),
|
||||
))))
|
||||
}
|
||||
|
||||
/// 从文件扩展名推断 MIME 类型
|
||||
pub fn infer_mime_type_from_extension(filename: &str) -> &'static str {
|
||||
let path = std::path::Path::new(filename);
|
||||
match path.extension().and_then(|ext| ext.to_str()) {
|
||||
Some("txt") => "text/plain",
|
||||
Some("md") => "text/markdown",
|
||||
Some("json") => "application/json",
|
||||
Some("xml") => "application/xml",
|
||||
Some("html") => "text/html",
|
||||
Some("css") => "text/css",
|
||||
Some("js") => "application/javascript",
|
||||
Some("ts") => "application/typescript",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("png") => "image/png",
|
||||
Some("gif") => "image/gif",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("webp") => "image/webp",
|
||||
Some("mp3") => "audio/mpeg",
|
||||
Some("wav") => "audio/wav",
|
||||
Some("ogg") => "audio/ogg",
|
||||
Some("m4a") => "audio/mp4",
|
||||
Some("mp4") => "video/mp4",
|
||||
Some("webm") => "video/webm",
|
||||
Some("zip") => "application/zip",
|
||||
Some("tar") => "application/x-tar",
|
||||
Some("gz") => "application/gzip",
|
||||
Some("rar") => "application/x-rar-compressed",
|
||||
Some("7z") => "application/x-7z-compressed",
|
||||
Some("doc") => "application/msword",
|
||||
Some("docx") => {
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
}
|
||||
Some("xls") => "application/vnd.ms-excel",
|
||||
Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
Some("ppt") => "application/vnd.ms-powerpoint",
|
||||
Some("pptx") => {
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
}
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_infer_mime_type() {
|
||||
assert_eq!(
|
||||
ContentBuilder::infer_mime_type_from_extension("test.txt"),
|
||||
"text/plain"
|
||||
);
|
||||
assert_eq!(
|
||||
ContentBuilder::infer_mime_type_from_extension("image.jpg"),
|
||||
"image/jpeg"
|
||||
);
|
||||
assert_eq!(
|
||||
ContentBuilder::infer_mime_type_from_extension("audio.mp3"),
|
||||
"audio/mpeg"
|
||||
);
|
||||
assert_eq!(
|
||||
ContentBuilder::infer_mime_type_from_extension("document.pdf"),
|
||||
"application/pdf"
|
||||
);
|
||||
assert_eq!(
|
||||
ContentBuilder::infer_mime_type_from_extension("unknown.xyz"),
|
||||
"application/octet-stream"
|
||||
);
|
||||
}
|
||||
}
|
||||
370
qiming-rcoder/crates/agent_runner/src/utils/file_utils.rs
Normal file
370
qiming-rcoder/crates/agent_runner/src/utils/file_utils.rs
Normal file
@@ -0,0 +1,370 @@
|
||||
//! 文件处理工具函数
|
||||
//!
|
||||
//! 提供文件读取、验证、转换等实用功能
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tracing::warn;
|
||||
|
||||
use super::ContentBuilder;
|
||||
use shared_types::{Attachment, AttachmentError, AttachmentSource};
|
||||
|
||||
/// 文件处理配置
|
||||
pub struct FileConfig {
|
||||
/// 最大文件大小(字节)
|
||||
pub max_file_size: u64,
|
||||
/// 允许的文件扩展名
|
||||
pub allowed_extensions: Vec<String>,
|
||||
/// 临时文件目录
|
||||
pub temp_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for FileConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_file_size: 50 * 1024 * 1024, // 50MB
|
||||
allowed_extensions: vec![
|
||||
"txt", "md", "json", "xml", "html", "css", "js", "ts", // 文本文件
|
||||
"jpg", "jpeg", "png", "gif", "svg", "webp", // 图像文件
|
||||
"mp3", "wav", "ogg", "m4a", // 音频文件
|
||||
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", // 文档文件
|
||||
"zip", "tar", "gz", "rar", "7z", // 压缩文件
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
temp_dir: std::env::temp_dir(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件处理工具
|
||||
pub struct FileUtils {
|
||||
config: FileConfig,
|
||||
}
|
||||
|
||||
impl FileUtils {
|
||||
/// 创建新的文件处理工具实例
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: FileConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用自定义配置创建文件处理工具
|
||||
pub fn with_config(config: FileConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// 验证文件扩展名是否允许
|
||||
pub fn validate_extension(&self, filename: &str) -> Result<()> {
|
||||
let path = Path::new(filename);
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("File has no extension"))?;
|
||||
|
||||
if !self
|
||||
.config
|
||||
.allowed_extensions
|
||||
.contains(&extension.to_lowercase())
|
||||
{
|
||||
return Err(anyhow::anyhow!("Unsupported file extension: {}", extension));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证文件大小
|
||||
pub async fn validate_file_size(&self, file_path: &Path) -> Result<()> {
|
||||
let metadata = fs::metadata(file_path)
|
||||
.await
|
||||
.context("unable to get file metadata")?;
|
||||
|
||||
if metadata.len() > self.config.max_file_size {
|
||||
return Err(AttachmentError::FileSizeExceeded(metadata.len()).into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 读取文件内容为 base64
|
||||
pub async fn read_file_as_base64(&self, file_path: &Path) -> Result<String> {
|
||||
self.validate_file_size(file_path).await?;
|
||||
|
||||
let content = fs::read(file_path).await.context("Failed to read file")?;
|
||||
|
||||
Ok(general_purpose::STANDARD.encode(content))
|
||||
}
|
||||
|
||||
/// 读取文本文件
|
||||
pub async fn read_text_file(&self, file_path: &Path) -> Result<String> {
|
||||
self.validate_file_size(file_path).await?;
|
||||
|
||||
let content = fs::read_to_string(file_path)
|
||||
.await
|
||||
.context("Failed to read text file")?;
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// 从文件路径创建附件
|
||||
pub async fn create_attachment_from_file(
|
||||
&self,
|
||||
file_path: &Path,
|
||||
project_path: &Path,
|
||||
description: Option<String>,
|
||||
) -> Result<Attachment> {
|
||||
// 验证文件扩展名
|
||||
let filename = file_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid filename"))?;
|
||||
|
||||
self.validate_extension(filename)?;
|
||||
|
||||
// 获取相对于项目路径的路径
|
||||
let relative_path = file_path
|
||||
.strip_prefix(project_path)
|
||||
.unwrap_or(file_path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// 获取 MIME 类型
|
||||
let mime_type = ContentBuilder::infer_mime_type_from_extension(filename);
|
||||
|
||||
// 根据文件类型创建相应的附件
|
||||
let attachment = if mime_type.starts_with("image/") {
|
||||
Attachment::new_image(
|
||||
AttachmentSource::FilePath {
|
||||
path: relative_path,
|
||||
},
|
||||
mime_type.to_string(),
|
||||
)
|
||||
} else if mime_type.starts_with("audio/") {
|
||||
Attachment::new_audio(
|
||||
AttachmentSource::FilePath {
|
||||
path: relative_path,
|
||||
},
|
||||
mime_type.to_string(),
|
||||
)
|
||||
} else if mime_type.starts_with("text/") || mime_type == "application/json" {
|
||||
let mut attachment = Attachment::new_text(AttachmentSource::FilePath {
|
||||
path: relative_path,
|
||||
});
|
||||
if let Attachment::Text(ref mut text_attachment) = attachment {
|
||||
text_attachment.description = description;
|
||||
}
|
||||
attachment
|
||||
} else {
|
||||
Attachment::new_document(
|
||||
AttachmentSource::FilePath {
|
||||
path: relative_path,
|
||||
},
|
||||
mime_type.to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
Ok(attachment)
|
||||
}
|
||||
|
||||
/// 从 base64 数据创建附件
|
||||
pub fn create_attachment_from_base64(
|
||||
&self,
|
||||
data: String,
|
||||
mime_type: String,
|
||||
filename: Option<String>,
|
||||
description: Option<String>,
|
||||
) -> Result<Attachment> {
|
||||
// 验证数据大小
|
||||
let decoded_size = general_purpose::STANDARD.decode(&data)?.len() as u64;
|
||||
if decoded_size > self.config.max_file_size {
|
||||
return Err(AttachmentError::FileSizeExceeded(decoded_size).into());
|
||||
}
|
||||
|
||||
let source = AttachmentSource::Base64 {
|
||||
data,
|
||||
mime_type: mime_type.clone(),
|
||||
};
|
||||
|
||||
let attachment = if mime_type.starts_with("image/") {
|
||||
let mut attachment = Attachment::new_image(source, mime_type);
|
||||
if let Attachment::Image(ref mut image_attachment) = attachment {
|
||||
image_attachment.filename = filename;
|
||||
image_attachment.description = description;
|
||||
}
|
||||
attachment
|
||||
} else if mime_type.starts_with("audio/") {
|
||||
let mut attachment = Attachment::new_audio(source, mime_type);
|
||||
if let Attachment::Audio(ref mut audio_attachment) = attachment {
|
||||
audio_attachment.filename = filename;
|
||||
audio_attachment.description = description;
|
||||
}
|
||||
attachment
|
||||
} else if mime_type.starts_with("text/") || mime_type == "application/json" {
|
||||
let mut attachment = Attachment::new_text(source);
|
||||
if let Attachment::Text(ref mut text_attachment) = attachment {
|
||||
text_attachment.filename = filename;
|
||||
text_attachment.description = description;
|
||||
}
|
||||
attachment
|
||||
} else {
|
||||
let mut attachment = Attachment::new_document(source, mime_type);
|
||||
if let Attachment::Document(ref mut doc_attachment) = attachment {
|
||||
doc_attachment.filename = filename;
|
||||
doc_attachment.description = description;
|
||||
doc_attachment.size = Some(decoded_size);
|
||||
}
|
||||
attachment
|
||||
};
|
||||
|
||||
Ok(attachment)
|
||||
}
|
||||
|
||||
/// 从 URL 创建附件
|
||||
pub async fn create_attachment_from_url(
|
||||
&self,
|
||||
url: &str,
|
||||
filename: Option<String>,
|
||||
description: Option<String>,
|
||||
) -> Result<Attachment> {
|
||||
// 获取 URL 头信息以确定文件类型和大小
|
||||
let client = reqwest::Client::new();
|
||||
let response = client.head(url).send().await?;
|
||||
|
||||
// 检查文件大小
|
||||
if let Some(content_length) = response.headers().get(reqwest::header::CONTENT_LENGTH) {
|
||||
let length: u64 = content_length.to_str()?.parse()?;
|
||||
if length > self.config.max_file_size {
|
||||
return Err(AttachmentError::FileSizeExceeded(length).into());
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 MIME 类型
|
||||
let mime_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("application/octet-stream");
|
||||
|
||||
let source = AttachmentSource::Url {
|
||||
url: url.to_string(),
|
||||
};
|
||||
|
||||
let attachment = if mime_type.starts_with("image/") {
|
||||
let mut attachment = Attachment::new_image(source, mime_type.to_string());
|
||||
if let Attachment::Image(ref mut image_attachment) = attachment {
|
||||
image_attachment.filename = filename;
|
||||
image_attachment.description = description;
|
||||
}
|
||||
attachment
|
||||
} else if mime_type.starts_with("audio/") {
|
||||
let mut attachment = Attachment::new_audio(source, mime_type.to_string());
|
||||
if let Attachment::Audio(ref mut audio_attachment) = attachment {
|
||||
audio_attachment.filename = filename;
|
||||
audio_attachment.description = description;
|
||||
}
|
||||
attachment
|
||||
} else if mime_type.starts_with("text/") || mime_type == "application/json" {
|
||||
let mut attachment = Attachment::new_text(source);
|
||||
if let Attachment::Text(ref mut text_attachment) = attachment {
|
||||
text_attachment.filename = filename;
|
||||
text_attachment.description = description;
|
||||
}
|
||||
attachment
|
||||
} else {
|
||||
let mut attachment = Attachment::new_document(source, mime_type.to_string());
|
||||
if let Attachment::Document(ref mut doc_attachment) = attachment {
|
||||
doc_attachment.filename = filename;
|
||||
doc_attachment.description = description;
|
||||
}
|
||||
attachment
|
||||
};
|
||||
|
||||
Ok(attachment)
|
||||
}
|
||||
|
||||
/// 批量处理文件创建附件
|
||||
pub async fn create_attachments_from_files(
|
||||
&self,
|
||||
file_paths: &[PathBuf],
|
||||
project_path: &Path,
|
||||
) -> Result<Vec<Attachment>> {
|
||||
let mut attachments = Vec::new();
|
||||
|
||||
for file_path in file_paths {
|
||||
match self
|
||||
.create_attachment_from_file(file_path, project_path, None)
|
||||
.await
|
||||
{
|
||||
Ok(attachment) => attachments.push(attachment),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Skipping file {:?}; failed to create attachment: {}",
|
||||
file_path, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(attachments)
|
||||
}
|
||||
|
||||
/// 清理临时文件
|
||||
pub async fn cleanup_temp_files(&self, temp_files: &[PathBuf]) -> Result<()> {
|
||||
for temp_file in temp_files {
|
||||
if temp_file.exists() {
|
||||
fs::remove_file(temp_file)
|
||||
.await
|
||||
.with_context(|| format!("failed to delete temp file: {:?}", temp_file))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FileUtils {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_extension() {
|
||||
let file_utils = FileUtils::new();
|
||||
|
||||
// 测试允许的扩展名
|
||||
assert!(file_utils.validate_extension("test.txt").is_ok());
|
||||
assert!(file_utils.validate_extension("image.jpg").is_ok());
|
||||
assert!(file_utils.validate_extension("audio.mp3").is_ok());
|
||||
|
||||
// 测试不允许的扩展名
|
||||
assert!(file_utils.validate_extension("test.exe").is_err());
|
||||
assert!(file_utils.validate_extension("test").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_file_as_base64() {
|
||||
let file_utils = FileUtils::new();
|
||||
|
||||
// 创建临时文件
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
writeln!(temp_file, "Hello, World!").unwrap();
|
||||
let temp_path = temp_file.path();
|
||||
|
||||
let base64_content = file_utils.read_file_as_base64(temp_path).await.unwrap();
|
||||
|
||||
// 验证 base64 编码是否正确
|
||||
let decoded = general_purpose::STANDARD.decode(base64_content).unwrap();
|
||||
let decoded_text = String::from_utf8(decoded).unwrap();
|
||||
assert_eq!(decoded_text.trim(), "Hello, World!");
|
||||
}
|
||||
}
|
||||
6
qiming-rcoder/crates/agent_runner/src/utils/mod.rs
Normal file
6
qiming-rcoder/crates/agent_runner/src/utils/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
//! 工具函数模块
|
||||
|
||||
mod content_builder;
|
||||
mod file_utils;
|
||||
|
||||
pub use content_builder::*;
|
||||
Reference in New Issue
Block a user