mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 23:05:08 +00:00
Open-source Agent OS built in Rust. - 14 crates, 1,767+ tests, zero clippy warnings - 7 autonomous Hands (Clip, Lead, Collector, Predictor, Researcher, Twitter, Browser) - 16 security systems (WASM sandbox, Merkle audit trail, taint tracking, Ed25519 signing, SSRF protection, secret zeroization, HMAC-SHA256 mutual auth, and more) - 30 pre-built agents across 4 performance tiers - 40 channel adapters (Telegram, Discord, Slack, WhatsApp, Teams, and 35 more) - 38 built-in tools + MCP client/server + A2A protocol - 26 LLM providers with intelligent routing and cost tracking - 60+ bundled skills with FangHub marketplace - Tauri 2.0 native desktop app - 140+ REST/WS/SSE API endpoints with Alpine.js dashboard - OpenAI-compatible /v1/chat/completions endpoint - One-command install, production-ready
212 lines
6.3 KiB
Rust
212 lines
6.3 KiB
Rust
//! Auto-reply background engine — trigger-driven background replies with concurrency control.
|
|
|
|
use openfang_types::agent::AgentId;
|
|
use openfang_types::config::AutoReplyConfig;
|
|
use std::sync::Arc;
|
|
use tokio::sync::Semaphore;
|
|
use tracing::{debug, info, warn};
|
|
|
|
/// Where to deliver the auto-reply result.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AutoReplyChannel {
|
|
/// Channel type string (e.g., "telegram", "discord").
|
|
pub channel_type: String,
|
|
/// Peer/user ID to send the reply to.
|
|
pub peer_id: String,
|
|
/// Optional thread ID for threaded replies.
|
|
pub thread_id: Option<String>,
|
|
}
|
|
|
|
/// Auto-reply engine with concurrency limits and suppression patterns.
|
|
pub struct AutoReplyEngine {
|
|
config: AutoReplyConfig,
|
|
semaphore: Arc<Semaphore>,
|
|
}
|
|
|
|
impl AutoReplyEngine {
|
|
/// Create a new auto-reply engine from configuration.
|
|
pub fn new(config: AutoReplyConfig) -> Self {
|
|
let permits = config.max_concurrent.max(1);
|
|
Self {
|
|
semaphore: Arc::new(Semaphore::new(permits)),
|
|
config,
|
|
}
|
|
}
|
|
|
|
/// Check if a message should trigger auto-reply.
|
|
/// Returns `None` if suppressed or disabled, `Some(agent_id)` if should auto-reply.
|
|
pub fn should_reply(
|
|
&self,
|
|
message: &str,
|
|
_channel_type: &str,
|
|
agent_id: AgentId,
|
|
) -> Option<AgentId> {
|
|
if !self.config.enabled {
|
|
return None;
|
|
}
|
|
|
|
// Check suppression patterns
|
|
let lower = message.to_lowercase();
|
|
for pattern in &self.config.suppress_patterns {
|
|
if lower.contains(&pattern.to_lowercase()) {
|
|
debug!(pattern = %pattern, "Auto-reply suppressed by pattern");
|
|
return None;
|
|
}
|
|
}
|
|
|
|
Some(agent_id)
|
|
}
|
|
|
|
/// Execute an auto-reply in the background.
|
|
/// Returns a JoinHandle for the spawned task.
|
|
///
|
|
/// The `send_fn` is called with the agent response to deliver it back to the channel.
|
|
pub async fn execute_reply<F>(
|
|
&self,
|
|
kernel_handle: Arc<dyn openfang_runtime::kernel_handle::KernelHandle>,
|
|
agent_id: AgentId,
|
|
message: String,
|
|
reply_channel: AutoReplyChannel,
|
|
send_fn: F,
|
|
) -> Result<tokio::task::JoinHandle<()>, String>
|
|
where
|
|
F: Fn(String, AutoReplyChannel) -> futures::future::BoxFuture<'static, ()>
|
|
+ Send
|
|
+ Sync
|
|
+ 'static,
|
|
{
|
|
// Try to acquire a semaphore permit
|
|
let permit = match self.semaphore.clone().try_acquire_owned() {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
return Err(format!(
|
|
"Auto-reply concurrency limit reached ({} max)",
|
|
self.config.max_concurrent
|
|
));
|
|
}
|
|
};
|
|
|
|
let timeout_secs = self.config.timeout_secs;
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let _permit = permit; // Hold permit until task completes
|
|
|
|
info!(
|
|
agent = %agent_id,
|
|
channel = %reply_channel.channel_type,
|
|
peer = %reply_channel.peer_id,
|
|
"Starting auto-reply"
|
|
);
|
|
|
|
let result = tokio::time::timeout(
|
|
std::time::Duration::from_secs(timeout_secs),
|
|
kernel_handle.send_to_agent(&agent_id.to_string(), &message),
|
|
)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok(response)) => {
|
|
send_fn(response, reply_channel).await;
|
|
}
|
|
Ok(Err(e)) => {
|
|
warn!(agent = %agent_id, error = %e, "Auto-reply agent error");
|
|
}
|
|
Err(_) => {
|
|
warn!(agent = %agent_id, timeout = timeout_secs, "Auto-reply timed out");
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(handle)
|
|
}
|
|
|
|
/// Check if auto-reply is enabled.
|
|
pub fn is_enabled(&self) -> bool {
|
|
self.config.enabled
|
|
}
|
|
|
|
/// Get the current configuration (read-only).
|
|
pub fn config(&self) -> &AutoReplyConfig {
|
|
&self.config
|
|
}
|
|
|
|
/// Get available permits (for monitoring).
|
|
pub fn available_permits(&self) -> usize {
|
|
self.semaphore.available_permits()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_config(enabled: bool) -> AutoReplyConfig {
|
|
AutoReplyConfig {
|
|
enabled,
|
|
max_concurrent: 3,
|
|
timeout_secs: 120,
|
|
suppress_patterns: vec!["/stop".to_string(), "/pause".to_string()],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_disabled_engine() {
|
|
let engine = AutoReplyEngine::new(test_config(false));
|
|
let agent_id = AgentId::new();
|
|
assert!(engine.should_reply("hello", "telegram", agent_id).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_enabled_engine_allows() {
|
|
let engine = AutoReplyEngine::new(test_config(true));
|
|
let agent_id = AgentId::new();
|
|
let result = engine.should_reply("hello there", "telegram", agent_id);
|
|
assert_eq!(result, Some(agent_id));
|
|
}
|
|
|
|
#[test]
|
|
fn test_suppression_patterns() {
|
|
let engine = AutoReplyEngine::new(test_config(true));
|
|
let agent_id = AgentId::new();
|
|
|
|
// Should be suppressed
|
|
assert!(engine.should_reply("/stop", "telegram", agent_id).is_none());
|
|
assert!(engine
|
|
.should_reply("please /pause this", "telegram", agent_id)
|
|
.is_none());
|
|
|
|
// Not suppressed
|
|
assert!(engine.should_reply("hello", "telegram", agent_id).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_concurrency_limit() {
|
|
let config = AutoReplyConfig {
|
|
enabled: true,
|
|
max_concurrent: 2,
|
|
timeout_secs: 120,
|
|
suppress_patterns: Vec::new(),
|
|
};
|
|
let engine = AutoReplyEngine::new(config);
|
|
assert_eq!(engine.available_permits(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_enabled() {
|
|
let on = AutoReplyEngine::new(test_config(true));
|
|
assert!(on.is_enabled());
|
|
|
|
let off = AutoReplyEngine::new(test_config(false));
|
|
assert!(!off.is_enabled());
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_config() {
|
|
let config = AutoReplyConfig::default();
|
|
assert!(!config.enabled);
|
|
assert_eq!(config.max_concurrent, 3);
|
|
assert_eq!(config.timeout_secs, 120);
|
|
assert!(config.suppress_patterns.contains(&"/stop".to_string()));
|
|
}
|
|
}
|