flashmind-types 0.3.1

Shared types for the Flashmind AI agent framework
Documentation
//! Telemetry and control events for the agent turn loop.
//!
//! This module defines the event types and input structures that flow through
//! the agent runtime during each turn.
//!
//! # Key types
//!
//! | Type | Role |
//! |------|------|
//! | [`AgentEvent`] | Stream of events emitted by the agent loop (text deltas, tool results, status…) |
//! | [`AgentInput`] | Input that starts or resumes an agent session |
//! | [`TurnStatus`] | Result of one iteration: `Done`, `ToolCalls`, `Continue`, or `Interrupted` |
//! | [`TurnUsage`] | Token usage reported at the end of an LLM call |
//! | [`Source`] | Web source attached to a tool result (URL + optional title) |
//!
//! # Event flow
//!
//! 1. Text arrives incrementally as [`AgentEvent::TextDelta`] (and [`AgentEvent::ReasoningDelta`])
//! 2. Tool execution produces [`AgentEvent::ToolStart`] → [`AgentEvent::ToolResult`]
//! 3. File modifications produce [`AgentEvent::FileDiff`]
//! 4. The turn ends with [`AgentEvent::Done`] or [`AgentEvent::Error`]
//!
//! Listeners (REPL, Telegram, Slack, etc.) receive the event stream and render
//! incrementally. Unknown variants should be silently ignored for forward compatibility.

use std::path::PathBuf;
use std::sync::Arc;

use serde::{Deserialize, Serialize};

use crate::ToolCall;
use crate::llm::TokenUsage;
use crate::message::ContentPart;
use crate::model::Model;
use crate::tool::BackgroundRequest;
use crate::tool::InterruptPayload;

/// Input that starts or resumes an agent session.
///
/// The default variant is [`Resume`](Self::Resume), which re-processes
/// the conversation without injecting new content.
#[derive(Debug, Clone, Default)]
pub enum AgentInput {
    /// A new user message to process.
    User {
        /// The text content of the user message.
        content: String,
        /// Additional system-level context injected before the user message.
        context: Option<String>,
        /// Multimodal attachments (images, documents, etc.).
        parts: Option<Vec<ContentPart>>,
    },
    /// Resume a paused conversation without injecting new content.
    /// Used after tool interrupts or compaction retries.
    #[default]
    Resume,
}

impl AgentInput {
    /// Short-hand to construct a simple text-only [`AgentInput::User`].
    pub fn user(content: impl Into<String>) -> Self {
        Self::User {
            content: content.into(),
            context: None,
            parts: None,
        }
    }
}

/// Token usage reported at the end of an LLM call.
#[derive(Debug, Clone, Copy, Default)]
pub struct TurnUsage {
    /// Number of tokens consumed by the prompt (input messages).
    pub prompt_tokens: u32,
    /// Number of tokens generated by the model (output/response).
    pub completion_tokens: u32,
    /// Prompt tokens served from the provider's cache.
    pub cache_read_tokens: u32,
    /// Prompt tokens written to the provider's cache.
    pub cache_creation_tokens: u32,
}

/// Why compaction is needed after a turn.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum CompactionReason {
    /// The model hit max output length without explicit `max_tokens`.
    OutputLength,
    /// Prompt tokens exceeded 90% of the context window.
    ContextThreshold(u32),
}

/// Result of a single agent turn after the LLM has responded.
#[derive(Debug)]
pub enum TurnStatus {
    /// No tool calls but the turn should continue (e.g. empty response retry).
    Continue { content: String, usage: TurnUsage },
    /// No tool calls and no more content expected — this session turn is done.
    Done { content: String, usage: TurnUsage },
    /// The LLM returned tool calls — caller is responsible for executing them.
    ToolCalls {
        content: String,
        tool_calls: Vec<ToolCall>,
        usage: TurnUsage,
    },
    /// A tool requested interactive user input. The agent loop has stopped;
    /// the caller should collect the response, add a `ConversationEntry::tool`
    /// with the result, and resume with `AgentInput::Resume`.
    Interrupted {
        tool_call_id: String,
        tool_name: String,
        output: String,
        payload: Option<Arc<dyn InterruptPayload>>,
        content: String,
        usage: TurnUsage,
    },
    /// The conversation needs compaction before continuing.
    CompactionNeeded {
        content: String,
        usage: TurnUsage,
        reason: CompactionReason,
    },
}

impl TurnStatus {
    /// Extract the content and usage from this turn status, if available.
    pub fn content_and_usage(&self) -> (&str, &TurnUsage) {
        match self {
            Self::Continue { content, usage }
            | Self::Done { content, usage }
            | Self::ToolCalls { content, usage, .. }
            | Self::Interrupted { content, usage, .. }
            | Self::CompactionNeeded { content, usage, .. } => (content, usage),
        }
    }
}

/// Alias for the final result of a turn.
pub type TurnResult = anyhow::Result<TurnStatus>;

/// A web source attached to a tool result (e.g. search result link).
///
/// Used by search tools to provide attribution URLs alongside results.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Source {
    /// URL of the source page.
    pub url: String,
    /// Optional human-readable title of the source.
    pub title: Option<String>,
}

/// Events emitted by the agent turn loop as a `Stream<Item = AgentEvent>`.
///
/// Listeners (REPL, Slack, Telegram, etc.) receive these and render them
/// incrementally. Consumers should handle all variants; unknown variants
/// should be silently ignored so forward-compatibility is preserved.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum AgentEvent {
    /// Incremental reasoning token (for models that surface internal thinking).
    ReasoningDelta(String),
    /// Incremental text token from the assistant message.
    TextDelta(String),
    /// A tool call has started execution.
    ToolStart {
        name: String,
        id: String,
        /// Human-readable summary of the arguments (for display in TUI/cards).
        humanized: String,
    },
    /// A running tool accepts a user request to continue in the background.
    ToolBackgroundReady {
        id: String,
        #[serde(skip)]
        request: Option<BackgroundRequest>,
    },
    /// A tool has finished. The `output` may be truncated for display.
    ToolResult {
        name: String,
        id: String,
        output: String,
        success: bool,
        elapsed_ms: u64,
        sources: Vec<Source>,
    },
    /// Media a finished tool call returned or names on disk, sent just before
    /// its `ToolResult` so clients can show it.
    ToolMedia {
        id: String,
        /// The non-text parts of the result, such as base64 images.
        parts: Vec<ContentPart>,
        /// Local files the result shows.
        files: Vec<PathBuf>,
    },
    /// A file the model produced in its reply, such as a generated image.
    Attachment {
        filename: String,
        media_type: String,
        data: Vec<u8>,
    },
    /// Incremental audio output chunk (raw bytes) for real-time playback.
    AudioChunk { data: Vec<u8>, format: String },
    /// Informational status message (compaction progress, retry attempts, etc.).
    Status(String),
    /// Conversation was compacted; payload is the summary text.
    Compacted(String),
    /// A file was modified by a tool.
    FileDiff {
        path: String,
        diff: Vec<crate::tool::DiffLine>,
    },
    /// Token usage telemetry from the provider.
    Usage(TokenUsage),
    /// Final terminal event — processing complete with `String` as the full response.
    Done(String),
    /// The user cancelled the running turn.
    Cancelled,
    /// Terminal error event.
    Error(String),
    /// Incremental output from a running tool (e.g. stdout lines from bash).
    ToolProgress { id: String, line: String },
    /// A spawned agent produced an event while running in parallel.
    SpawnedEvent {
        id: String,
        task: String,
        model: Option<Model>,
        event: Box<AgentEvent>,
    },
    /// A tool requested interactive input. Emitted when a turn ends with
    /// `TurnStatus::Interrupted`. The caller should show the appropriate
    /// picker, inject the tool result, and resume.
    Interrupted {
        tool_call_id: String,
        tool_name: String,
        output: String,
        #[serde(skip)]
        payload: Option<Arc<dyn InterruptPayload>>,
    },
    /// The conversation needs compaction but `auto_compact` is off.
    /// The caller should compact and call `agent.start()` with `AgentInput::Resume`.
    CompactionNeeded { reason: CompactionReason },
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn recorded_tool_events_do_not_restore_live_background_controls() {
        let event = AgentEvent::ToolBackgroundReady {
            id: "command".into(),
            request: Some(BackgroundRequest::default()),
        };
        let serialized = serde_json::to_string(&event).unwrap();
        let replayed: AgentEvent = serde_json::from_str(&serialized).unwrap();
        assert!(matches!(
            replayed,
            AgentEvent::ToolBackgroundReady { request: None, .. }
        ));
    }

    #[test]
    fn agent_input_user_shorthand() {
        let input = AgentInput::user("hello");
        match input {
            AgentInput::User {
                content,
                context,
                parts,
            } => {
                assert_eq!(content, "hello");
                assert!(context.is_none());
                assert!(parts.is_none());
            }
            _ => panic!("expected User variant"),
        }
    }

    #[test]
    fn agent_input_default_is_resume() {
        let input = AgentInput::default();
        assert!(matches!(input, AgentInput::Resume));
    }

    #[test]
    fn turn_status_content_and_usage_done() {
        let status = TurnStatus::Done {
            content: "response".into(),
            usage: TurnUsage {
                prompt_tokens: 10,
                completion_tokens: 20,
                ..TurnUsage::default()
            },
        };
        let (content, usage) = status.content_and_usage();
        assert_eq!(content, "response");
        assert_eq!(usage.prompt_tokens, 10);
        assert_eq!(usage.completion_tokens, 20);
    }

    #[test]
    fn turn_status_content_and_usage_tool_calls() {
        let status = TurnStatus::ToolCalls {
            content: "thinking".into(),
            tool_calls: vec![],
            usage: TurnUsage {
                prompt_tokens: 5,
                completion_tokens: 15,
                ..TurnUsage::default()
            },
        };
        let (content, usage) = status.content_and_usage();
        assert_eq!(content, "thinking");
        assert_eq!(usage.completion_tokens, 15);
    }

    #[test]
    fn turn_usage_default_is_zero() {
        let usage = TurnUsage::default();
        assert_eq!(usage.prompt_tokens, 0);
        assert_eq!(usage.completion_tokens, 0);
    }
}