pub mod actions;
use crate::llm::action_helper::call_llm;
use crate::llm::OllamaClient;
use crate::protocol::Event;
use crate::server::accept_bounded::{
accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
};
use crate::server::connection::ConnectionId;
use crate::server::rss::actions::{RssProtocol, RSS_FEED_REQUESTED_EVENT};
use crate::state::app_state::AppState;
use crate::state::server::ServerId;
use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
use crate::utils::WireFailure;
use anyhow::Result;
use hyper::body::Bytes;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender;
use tracing::{debug, error, warn};
use crate::logging::emit::Log;
const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
pub struct RssServer;
impl RssServer {
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: UnboundedSender<String>,
server_id: ServerId,
) -> Result<SocketAddr> {
let listener =
crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
let local_addr = listener.local_addr()?;
Log::new(Some(&status_tx)).info(format!("RSS server listening on {}", local_addr));
let llm_client = Arc::new(llm_client);
let protocol = Arc::new(RssProtocol::new());
let task_registrar = app_state.clone();
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
let accept_handle = tokio::spawn(async move {
loop {
match accept_bounded(
&listener,
&limiter,
CONNECTION_CAP_REFUSAL,
"RSS",
Some(&status_tx),
)
.await
{
Ok((stream, peer_addr, permit)) => {
debug!("RSS connection from {}", peer_addr);
let connection_id =
ConnectionId::new(app_state.get_next_unified_id().await);
let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
let now = crate::utils::clock::Instant::now();
app_state
.add_connection_to_server(
server_id,
ConnectionState {
id: connection_id,
remote_addr: peer_addr,
local_addr: local_addr_conn,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::empty(),
},
)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
let llm = Arc::clone(&llm_client);
let state = Arc::clone(&app_state);
let status = status_tx.clone();
let proto = Arc::clone(&protocol);
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let conn_state = Arc::clone(&state);
let _permit = permit;
let spoke = matches!(
tokio::time::timeout(
FIRST_BYTE_READ_TIMEOUT,
stream.peek(&mut [0u8; 1]),
)
.await,
Ok(Ok(n)) if n > 0
);
if spoke {
let io = TokioIo::new(stream);
let activity = Arc::new(ConnectionActivity::new());
let activity_for_service = Arc::clone(&activity);
let service =
service_fn(move |req: Request<hyper::body::Incoming>| {
let llm = Arc::clone(&llm);
let state = Arc::clone(&state);
let status = status.clone();
let proto = Arc::clone(&proto);
let activity = Arc::clone(&activity_for_service);
async move {
let _busy = activity.busy();
Self::handle_request(
req,
llm,
state,
status,
server_id,
connection_id,
proto,
)
.await
}
});
let conn = http1::Builder::new().serve_connection(io, service);
tokio::pin!(conn);
tokio::select! {
result = &mut conn => {
if let Err(e) = result {
error!("RSS connection error: {}", e);
}
}
_ = watch_idle(
Arc::clone(&activity),
IDLE_BETWEEN_REQUESTS_TIMEOUT,
) => {
debug!(
"RSS connection {} idle for {}s; closing",
connection_id,
IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
);
}
}
} else {
debug!(
"RSS peer {} sent no request within {}s; closing \
decision=fail_closed_first_byte_timeout",
peer_addr,
FIRST_BYTE_READ_TIMEOUT.as_secs()
);
}
conn_state
.remove_connection_from_server(server_id, connection_id)
.await;
})
.await;
}
Err(e) => {
error!("RSS accept failed, stopping loop: {}", e);
Log::new(Some(&status_tx))
.error(format!("RSS accept failed, stopping loop: {}", e));
break;
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
#[allow(clippy::too_many_arguments)]
async fn handle_request(
req: Request<hyper::body::Incoming>,
llm_client: Arc<OllamaClient>,
app_state: Arc<AppState>,
status_tx: UnboundedSender<String>,
server_id: ServerId,
connection_id: ConnectionId,
protocol: Arc<RssProtocol>,
) -> Result<Response<http_body_util::Full<Bytes>>, hyper::Error> {
let method = req.method().clone();
let uri = req.uri().clone();
let path = uri.path().to_string();
let headers_map = req.headers().clone();
Log::new(Some(&status_tx)).debug(format!("RSS request: {} {}", method, path));
let bytes_in = method.as_str().len()
+ uri.to_string().len()
+ headers_map
.iter()
.map(|(k, v)| k.as_str().len() + v.len() + 4)
.sum::<usize>();
app_state
.update_connection_stats(
server_id,
connection_id,
Some(bytes_in as u64),
None,
Some(1),
None,
)
.await;
if method != hyper::Method::GET {
return Ok(Self::counted(
&app_state,
server_id,
connection_id,
StatusCode::METHOD_NOT_ALLOWED,
None,
Bytes::from("Method Not Allowed"),
)
.await);
}
let mut headers = std::collections::HashMap::new();
for (key, value) in headers_map.iter() {
if let Ok(value_str) = value.to_str() {
headers.insert(key.as_str().to_lowercase(), value_str.to_string());
}
}
let event = Event::new(
&RSS_FEED_REQUESTED_EVENT,
serde_json::json!({
"path": path,
"headers": headers,
}),
);
Log::new(Some(&status_tx)).debug(format!("RSS calling LLM for feed: {}", path));
match call_llm(
&llm_client,
&app_state,
server_id,
None,
&event,
protocol.as_ref(),
)
.await
{
Ok(execution_result) => {
let log = Log::new(Some(&status_tx));
for message in &execution_result.messages {
log.info(message);
}
let mut build_error: Option<String> = execution_result
.failures
.first()
.map(|f| format!("{}: {}", f.action, f.error));
for protocol_result in execution_result.protocol_results {
if let crate::llm::actions::protocol_trait::ActionResult::Custom {
name,
data,
} = protocol_result
{
if name == "generate_rss_feed" {
match Self::build_rss_from_llm_data(data) {
Ok(xml) => {
let xml_bytes = xml.into_bytes();
Log::new(Some(&status_tx)).debug(format!(
"RSS {} decision=model_feed path={} ({} bytes)",
connection_id,
path,
xml_bytes.len()
));
return Ok(Self::counted(
&app_state,
server_id,
connection_id,
StatusCode::OK,
Some("application/rss+xml; charset=utf-8"),
Bytes::from(xml_bytes),
)
.await);
}
Err(e) => {
build_error = Some(e.to_string());
}
}
}
}
}
if let Some(reason) = build_error {
warn!(
"RSS {} decision=fail_closed_unusable_feed: {}",
connection_id, reason
);
Log::new(Some(&status_tx)).warn(format!(
"RSS {} decision=fail_closed_unusable_feed: {}",
connection_id, reason
));
return Ok(Self::counted(
&app_state,
server_id,
connection_id,
StatusCode::INTERNAL_SERVER_ERROR,
Some("text/plain; charset=utf-8"),
Bytes::from(WireFailure::Unavailable.prefixed_text()),
)
.await);
}
debug!("RSS {} decision=model_no_feed path={}", connection_id, path);
Log::new(Some(&status_tx)).debug(format!(
"RSS {} decision=model_no_feed path={}",
connection_id, path
));
Ok(Self::counted(
&app_state,
server_id,
connection_id,
StatusCode::NOT_FOUND,
None,
Bytes::from("Feed Not Found"),
)
.await)
}
Err(e) => {
let failure = WireFailure::classify(&e);
warn!(
"RSS {} decision=fail_closed_llm_error ({:?}): {}",
connection_id, failure, e
);
Log::new(Some(&status_tx)).warn(format!(
"RSS {} decision=fail_closed_llm_error: {}",
connection_id, e
));
let (status, retry_after) = if failure.is_overloaded() {
(StatusCode::SERVICE_UNAVAILABLE, Some("5"))
} else {
(StatusCode::INTERNAL_SERVER_ERROR, None)
};
let body = Bytes::from(failure.prefixed_text());
let sent = body.len() as u64;
app_state
.update_connection_stats(
server_id,
connection_id,
None,
Some(sent),
None,
Some(1),
)
.await;
let mut builder = Response::builder()
.status(status)
.header("Content-Type", "text/plain; charset=utf-8");
if let Some(seconds) = retry_after {
builder = builder.header("Retry-After", seconds);
}
Ok(builder
.body(http_body_util::Full::new(body))
.unwrap_or_else(|_| {
Response::new(http_body_util::Full::new(Bytes::from_static(b"")))
}))
}
}
}
async fn counted(
app_state: &Arc<AppState>,
server_id: ServerId,
connection_id: ConnectionId,
status: StatusCode,
content_type: Option<&str>,
body: Bytes,
) -> Response<http_body_util::Full<Bytes>> {
let sent = body.len() as u64;
app_state
.update_connection_stats(server_id, connection_id, None, Some(sent), None, Some(1))
.await;
let mut builder = Response::builder().status(status);
if let Some(ct) = content_type {
builder = builder.header("Content-Type", ct);
}
builder
.body(http_body_util::Full::new(body))
.unwrap_or_else(|_| Response::new(http_body_util::Full::new(Bytes::from_static(b""))))
}
fn build_rss_from_llm_data(data: serde_json::Value) -> Result<String> {
use rss::{CategoryBuilder, ChannelBuilder, ItemBuilder};
actions::validate_feed_data(&data)?;
let title = xml_safe(data["title"].as_str().unwrap_or_default());
let link = xml_safe(data["link"].as_str().unwrap_or_default());
let description = xml_safe(data["description"].as_str().unwrap_or_default());
let mut channel_builder = ChannelBuilder::default();
channel_builder
.title(title)
.link(link)
.description(description);
if let Some(language) = data["language"].as_str() {
channel_builder.language(Some(xml_safe(language)));
}
if let Some(ttl_str) = data["ttl"].as_str() {
channel_builder.ttl(Some(xml_safe(ttl_str)));
} else if let Some(ttl_num) = data["ttl"].as_u64() {
channel_builder.ttl(Some(ttl_num.to_string()));
} else if let Some(ttl_num) = data["ttl"].as_i64() {
channel_builder.ttl(Some(ttl_num.to_string()));
}
if let Some(last_build_date) = data["last_build_date"].as_str() {
channel_builder.last_build_date(Some(xml_safe(last_build_date)));
}
let mut items = Vec::new();
if let Some(items_array) = data["items"].as_array() {
for item_data in items_array {
let mut item_builder = ItemBuilder::default();
if let Some(title) = item_data["title"].as_str() {
item_builder.title(Some(xml_safe(title)));
}
if let Some(link) = item_data["link"].as_str() {
item_builder.link(Some(xml_safe(link)));
}
if let Some(description) = item_data["description"].as_str() {
item_builder.description(Some(xml_safe(description)));
}
if let Some(author) = item_data["author"].as_str() {
item_builder.author(Some(xml_safe(author)));
}
if let Some(pub_date) = item_data["pub_date"].as_str() {
item_builder.pub_date(Some(xml_safe(pub_date)));
}
if let Some(guid) = item_data["guid"].as_str() {
item_builder.guid(Some(
rss::GuidBuilder::default().value(xml_safe(guid)).build(),
));
}
if let Some(categories_array) = item_data["categories"].as_array() {
let categories: Vec<rss::Category> = categories_array
.iter()
.filter_map(|cat| {
if let Some(cat_str) = cat.as_str() {
Some(CategoryBuilder::default().name(xml_safe(cat_str)).build())
} else if let Some(cat_obj) = cat.as_object() {
let name = xml_safe(cat_obj.get("name")?.as_str()?);
let mut builder = CategoryBuilder::default();
builder.name(name);
if let Some(domain) = cat_obj.get("domain").and_then(|v| v.as_str())
{
builder.domain(Some(xml_safe(domain)));
}
Some(builder.build())
} else {
None
}
})
.collect();
item_builder.categories(categories);
}
items.push(item_builder.build());
}
}
channel_builder.items(items);
let channel = channel_builder.build();
Ok(channel.to_string())
}
}
fn xml_safe(text: &str) -> String {
if text
.chars()
.all(|c| !matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}'))
{
return text.to_string();
}
text.chars()
.filter(|c| !matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}'))
.collect()
}