funera-core 0.3.0

Core LLM agent engine — ReAct loop, providers, tools, skills, middleware, security
Documentation
use async_openai::error::OpenAIError;
use async_openai::types::stream::StreamResponse;
use futures::StreamExt;
use tokio::sync::broadcast;

use crate::provider::StreamChunkExt;

#[derive(Debug, Clone)]
pub enum TokenEvent {
    Text(String),
    ToolDelta {
        index: usize,
        call_id: String,
        name: Option<String>,
        args_chunk: Option<String>,
    },
    Finish(async_openai::types::chat::FinishReason),
    Reasoning(String),
}

pub struct TokenBus<C: StreamChunkExt> {
    token_tx: broadcast::Sender<TokenEvent>,
    raw_response_stream: StreamResponse<C>,
}

impl<C: StreamChunkExt> TokenBus<C> {
    pub fn new(stream: StreamResponse<C>) -> Self {
        let (token_tx, _) = broadcast::channel(50);
        Self {
            token_tx,
            raw_response_stream: stream,
        }
    }

    pub fn with_sender(token_tx: broadcast::Sender<TokenEvent>, stream: StreamResponse<C>) -> Self {
        Self {
            token_tx,
            raw_response_stream: stream,
        }
    }

    pub fn subscribe(&self) -> broadcast::Receiver<TokenEvent> {
        self.token_tx.subscribe()
    }

    pub async fn recv(&mut self) -> Option<Result<Vec<TokenEvent>, OpenAIError>> {
        match self.raw_response_stream.next().await {
            None => None,
            Some(Err(e)) => Some(Err(e)),
            Some(Ok(chunk)) => {
                let events = chunk.extract_events();
                for event in &events {
                    self.token_tx.send(event.clone()).ok();
                }
                Some(Ok(events))
            }
        }
    }

    pub async fn send(&self, event: TokenEvent) -> anyhow::Result<usize> {
        self.token_tx.send(event).map_err(|e| anyhow::anyhow!(e))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use async_openai::types::chat::CreateChatCompletionStreamResponse;

    fn make_bus() -> TokenBus<CreateChatCompletionStreamResponse> {
        let stream = Box::pin(futures::stream::empty::<
            Result<CreateChatCompletionStreamResponse, async_openai::error::OpenAIError>,
        >());
        TokenBus::new(stream)
    }

    #[tokio::test]
    async fn token_bus_send_and_receive() {
        let bus = make_bus();
        let mut rx = bus.subscribe();

        bus.send(TokenEvent::Text("hello".into())).await.unwrap();
        assert!(matches!(rx.try_recv(), Ok(TokenEvent::Text(t)) if t == "hello"));
    }

    #[tokio::test]
    async fn token_bus_multiple_subscribers() {
        let bus = make_bus();
        let mut rx1 = bus.subscribe();
        let mut rx2 = bus.subscribe();

        bus.send(TokenEvent::Text("broadcast".into()))
            .await
            .unwrap();
        assert!(rx1.try_recv().is_ok());
        assert!(rx2.try_recv().is_ok());
    }

    #[tokio::test]
    async fn token_bus_event_types() {
        let bus = make_bus();
        let mut rx = bus.subscribe();

        bus.send(TokenEvent::Reasoning("thinking...".into()))
            .await
            .unwrap();
        bus.send(TokenEvent::Finish(
            async_openai::types::chat::FinishReason::Stop,
        ))
        .await
        .unwrap();
        bus.send(TokenEvent::ToolDelta {
            index: 0,
            call_id: "call_1".into(),
            name: Some("tool".into()),
            args_chunk: Some("{}".into()),
        })
        .await
        .unwrap();

        let events: Vec<TokenEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
        assert_eq!(events.len(), 3);
        assert!(matches!(events[0], TokenEvent::Reasoning(_)));
        assert!(matches!(events[1], TokenEvent::Finish(_)));
        assert!(matches!(events[2], TokenEvent::ToolDelta { .. }));
    }

    #[tokio::test]
    async fn token_bus_with_sender() {
        let (tx, _rx) = tokio::sync::broadcast::channel(50);
        let stream = Box::pin(futures::stream::empty::<
            Result<CreateChatCompletionStreamResponse, async_openai::error::OpenAIError>,
        >());
        let bus = TokenBus::<CreateChatCompletionStreamResponse>::with_sender(tx, stream);

        let mut rx = bus.subscribe();
        bus.send(TokenEvent::Text("custom sender".into()))
            .await
            .unwrap();
        assert!(matches!(rx.try_recv(), Ok(TokenEvent::Text(t)) if t == "custom sender"));
    }
}