aether-agent-core 0.6.23

A minimal Rust library for building AI agents with MCP tool integration
Documentation
use crate::events::{AgentEvent, ContextEvent, LlmCallOutcome, MessageEvent, ModelEvent, ToolEvent, TurnEvent};
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SessionMeta {
    pub session_id: String,
    pub cwd: PathBuf,
    pub model: String,
    #[serde(default)]
    pub selected_mode: Option<String>,
    pub created_at: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum UserEvent {
    Message { content: Vec<llm::ContentBlock> },
    ClearContext,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum SessionControlEvent {
    AgentSwitched { from: Option<String>, to: Option<String> },
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data", rename_all = "camelCase")]
#[allow(clippy::large_enum_variant)]
pub enum SessionEvent {
    User(UserEvent),
    Agent(AgentEvent),
    Control(SessionControlEvent),
}

impl SessionEvent {
    pub fn content(&self) -> Option<String> {
        self.user_content().or_else(|| match self {
            Self::Agent(event) => event.content(),
            Self::User(_) | Self::Control(_) => None,
        })
    }

    pub fn user_content(&self) -> Option<String> {
        match self {
            Self::User(UserEvent::Message { content }) => {
                let text = llm::ContentBlock::join_text(content);
                (!text.is_empty()).then_some(text)
            }
            Self::User(UserEvent::ClearContext) | Self::Agent(_) | Self::Control(_) => None,
        }
    }

    pub fn is_persisted(&self) -> bool {
        match self {
            Self::User(_) | Self::Control(_) => true,
            Self::Agent(event) => match event {
                AgentEvent::Message(
                    MessageEvent::Text { is_complete, .. } | MessageEvent::Thought { is_complete, .. },
                ) => *is_complete,
                AgentEvent::Tool(ToolEvent::Call { .. } | ToolEvent::Result { .. } | ToolEvent::Error { .. })
                | AgentEvent::Turn(
                    TurnEvent::RetryScheduled { .. }
                    | TurnEvent::AutoContinue { .. }
                    | TurnEvent::Ended { .. }
                    | TurnEvent::LlmCallEnded { outcome: LlmCallOutcome::Failed { .. }, .. },
                )
                | AgentEvent::Context(
                    ContextEvent::CompactionStarted { .. }
                    | ContextEvent::CompactionEnded { .. }
                    | ContextEvent::CompactionResult { .. }
                    | ContextEvent::UsageUpdated { .. }
                    | ContextEvent::Cleared,
                )
                | AgentEvent::Model(ModelEvent::Switched { .. }) => true,
                AgentEvent::Tool(
                    ToolEvent::CallUpdate { .. }
                    | ToolEvent::ExecutionStarted { .. }
                    | ToolEvent::Progress { .. }
                    | ToolEvent::DefinitionsUpdated { .. },
                )
                | AgentEvent::Turn(
                    TurnEvent::Started { .. }
                    | TurnEvent::LlmCallStarted { .. }
                    | TurnEvent::LlmCallEnded {
                        outcome: LlmCallOutcome::Completed { .. } | LlmCallOutcome::Cancelled,
                        ..
                    },
                ) => false,
            },
        }
    }
}

#[derive(Debug, Clone)]
pub struct SessionLine {
    pub line_number: usize,
    pub bytes_read: usize,
    pub raw: String,
}

#[derive(Debug)]
pub enum SessionLogEntry {
    Persisted { line: SessionLine, event: Box<SessionEvent> },
    Transient { line: SessionLine },
    Malformed { line: SessionLine, error: serde_json::Error },
}

impl SessionLogEntry {
    pub fn line(&self) -> &SessionLine {
        match self {
            Self::Persisted { line, .. } | Self::Transient { line } | Self::Malformed { line, .. } => line,
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum SessionLogError {
    #[error(transparent)]
    Io(#[from] std::io::Error),
    #[error("missing session metadata line")]
    MissingMetadata,
    #[error("invalid session metadata on line {line_number}: {source}")]
    InvalidMetadata { line_number: usize, source: serde_json::Error },
}

pub struct SessionLog<T: BufRead> {
    reader: T,
    pub meta: SessionMeta,
    line_number: usize,
}

impl SessionLog<BufReader<File>> {
    pub fn open(path: impl AsRef<Path>) -> Result<Self, SessionLogError> {
        Self::from_reader(BufReader::new(File::open(path.as_ref())?))
    }
}

impl<T: BufRead> SessionLog<T> {
    pub fn from_reader(mut reader: T) -> Result<Self, SessionLogError> {
        let mut line = String::new();
        let mut line_number = 0;
        loop {
            line.clear();
            if reader.read_line(&mut line)? == 0 {
                return Err(SessionLogError::MissingMetadata);
            }
            line_number += 1;
            if !line.trim().is_empty() {
                break;
            }
        }
        let meta = serde_json::from_str(line.trim())
            .map_err(|source| SessionLogError::InvalidMetadata { line_number, source })?;
        Ok(Self { reader, meta, line_number })
    }

    pub fn next_entry(&mut self) -> std::io::Result<Option<SessionLogEntry>> {
        let Some(line) = self.next_line()? else {
            return Ok(None);
        };
        let entry = match serde_json::from_str::<SessionEvent>(&line.raw) {
            Ok(event) if event.is_persisted() => SessionLogEntry::Persisted { line, event: Box::new(event) },
            Ok(_) => SessionLogEntry::Transient { line },
            Err(error) => SessionLogEntry::Malformed { line, error },
        };
        Ok(Some(entry))
    }

    fn next_line(&mut self) -> std::io::Result<Option<SessionLine>> {
        let mut line = String::new();
        loop {
            line.clear();
            let bytes_read = self.reader.read_line(&mut line)?;
            if bytes_read == 0 {
                return Ok(None);
            }
            self.line_number += 1;
            let trimmed = line.trim();
            if !trimmed.is_empty() {
                return Ok(Some(SessionLine { line_number: self.line_number, bytes_read, raw: trimmed.to_string() }));
            }
        }
    }
}

pub fn last_agent_from_events(initial: Option<String>, events: &[SessionEvent]) -> Option<String> {
    events
        .iter()
        .rev()
        .find_map(|event| match event {
            SessionEvent::Control(SessionControlEvent::AgentSwitched { to, .. }) => Some(to.clone()),
            _ => None,
        })
        .unwrap_or(initial)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::events::{LlmCallPurpose, TurnOutcome};

    fn agent(event: AgentEvent) -> SessionEvent {
        SessionEvent::Agent(event)
    }

    #[test]
    fn persistence_policy_covers_every_event_variant() {
        let retry = agent(AgentEvent::Turn(TurnEvent::RetryScheduled {
            purpose: LlmCallPurpose::Chat,
            attempt: 1,
            max_attempts: 3,
            delay_ms: 10,
        }));
        let cancelled = agent(AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Cancelled }));
        let partial = agent(AgentEvent::text("m", "partial", false));
        let compaction_ended = agent(AgentEvent::Context(ContextEvent::CompactionEnded {
            outcome: crate::events::CompactionOutcome::Completed,
        }));

        assert!(retry.is_persisted());
        assert!(cancelled.is_persisted());
        assert!(compaction_ended.is_persisted());
        assert!(!partial.is_persisted());
    }
}