nolgia 0.1.0

Official Rust SDK for the Nolgia API: image, video, audio and agent generation, generated from the OpenAPI spec
Documentation
//! Hand-written. One agent turn, submitted and awaited (the Rust twin of the
//! Go `RunAgent`, TypeScript `runAgent` and Python `run`).

use std::{collections::HashSet, fmt, sync::Arc, time::Duration};

use serde_json::json;
use tokio::time::Instant;
use uuid::Uuid;

use crate::{
    Client, Typed,
    api::types,
    error::{Error, Result},
};

/// Answers the agent's closing question; `None` ends the run with
/// [`AgentRunStatus::AwaitingInput`].
pub type QuestionHandler = Arc<dyn Fn(&types::AgentQuestion) -> Option<String> + Send + Sync>;

/// Options for [`Client::run_agent`].
#[derive(Clone)]
#[non_exhaustive]
pub struct RunAgentOptions {
    /// What to say.
    pub message: String,
    /// Continue this session; `None` starts a new one.
    pub session_id: Option<Uuid>,
    /// The whole run, questions included. Default 10 minutes.
    pub timeout: Duration,
    /// The first poll interval; grows 1.5x per poll. Default 1 s.
    pub poll_interval: Duration,
    /// The longest poll interval. Default 10 s.
    pub max_poll_interval: Duration,
    /// Answers the agent's closing questions within the same run.
    pub on_question: Option<QuestionHandler>,
}

impl fmt::Debug for RunAgentOptions {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("RunAgentOptions")
            .field("message", &self.message)
            .field("session_id", &self.session_id)
            .field("timeout", &self.timeout)
            .field("on_question", &self.on_question.is_some())
            .finish_non_exhaustive()
    }
}

impl RunAgentOptions {
    /// Say `message` in a new session.
    pub fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            session_id: None,
            timeout: Duration::from_secs(600),
            poll_interval: Duration::from_secs(1),
            max_poll_interval: Duration::from_secs(10),
            on_question: None,
        }
    }

    /// Continue an existing session.
    pub fn session(mut self, session_id: Uuid) -> Self {
        self.session_id = Some(session_id);
        self
    }

    /// Bound the whole run.
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Answer closing questions with `handler`.
    pub fn on_question(
        mut self,
        handler: impl Fn(&types::AgentQuestion) -> Option<String> + Send + Sync + 'static,
    ) -> Self {
        self.on_question = Some(Arc::new(handler));
        self
    }
}

/// How an agent run ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AgentRunStatus {
    /// The agent replied.
    Complete,
    /// The agent asked a question nobody answered.
    AwaitingInput,
    /// The turn failed; see `error_code`.
    Failed,
    /// The turn was interrupted.
    Interrupted,
    /// The timeout passed. The turn may still finish on the server.
    Timeout,
}

/// The outcome of [`Client::run_agent`].
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct AgentRunResult {
    /// How the run ended.
    pub status: AgentRunStatus,
    /// The reply, or the error text of a failed or interrupted turn.
    pub text: String,
    /// Assets the session produced during this run.
    pub asset_ids: Vec<Uuid>,
    /// The session (new or continued).
    pub session_id: Option<Uuid>,
    /// The final agent reply's id.
    pub agent_message_id: Option<Uuid>,
    /// The unanswered closing question, on `AwaitingInput`.
    pub question: Option<types::AgentQuestion>,
    /// `session_busy`, `insufficient_credits`, ... on a failed turn.
    pub error_code: Option<String>,
}

impl Client {
    /// Send the agent a message and wait for its reply.
    ///
    /// An agent turn is billed (a flat per-turn rate for the agent's brain,
    /// metered above it on actual cost); see the pricing page.
    ///
    /// ```no_run
    /// # async fn run(nolgia: nolgia::Client) -> nolgia::Result<()> {
    /// use nolgia::RunAgentOptions;
    ///
    /// let reply = nolgia.run_agent(RunAgentOptions::new("Make me a 5 second clip of a fox")).await?;
    /// println!("{:?}: {}", reply.status, reply.text);
    /// # Ok(()) }
    /// ```
    pub async fn run_agent(&self, options: RunAgentOptions) -> Result<AgentRunResult> {
        let deadline = Instant::now() + options.timeout;
        let mut session_id = options.session_id;
        let mut message_id: Option<Uuid> = None;
        let mut first_created: Option<chrono::DateTime<chrono::Utc>> = None;
        let mut content = options.message.clone();
        let timed_out = |session_id, message_id| AgentRunResult {
            status: AgentRunStatus::Timeout,
            text: String::new(),
            asset_ids: Vec::new(),
            session_id,
            agent_message_id: message_id,
            question: None,
            error_code: None,
        };

        loop {
            if Instant::now() >= deadline {
                return Ok(timed_out(session_id, message_id));
            }
            let mut body = json!({ "content": content, "steering": false });
            match session_id {
                Some(id) => body["session_id"] = json!(id),
                None => body["new_session"] = json!({}),
            }
            let accepted: types::SubmitAgentMessageAccepted = self
                .send_json(
                    self.http().post(self.url("/agent/messages")).json(&body),
                    "submit_agent_message",
                )
                .await?;
            session_id = accepted.session_id.or(session_id);
            message_id = accepted.agent_message_id;
            let Some(reply_id) = message_id else {
                return Err(Error::decode(
                    Some(202),
                    "the agent accepted the message without a reply id",
                ));
            };

            let mut interval = options.poll_interval.min(options.max_poll_interval);
            let reply = loop {
                let reply = self.get_agent_message().id(reply_id).send().typed().await?;
                first_created.get_or_insert(reply.created_at);
                if reply.status != "pending" {
                    break reply;
                }
                let now = Instant::now();
                if now >= deadline {
                    return Ok(timed_out(session_id, message_id));
                }
                tokio::time::sleep(interval.min(deadline - now)).await;
                interval = interval.mul_f64(1.5).min(options.max_poll_interval);
            };

            let status = match reply.status.as_str() {
                "complete" => {
                    if let (Some(question), Some(handler)) = (&reply.question, &options.on_question)
                        && let Some(answer) = handler(question)
                    {
                        content = answer;
                        continue;
                    }
                    if reply.question.is_some() {
                        AgentRunStatus::AwaitingInput
                    } else {
                        AgentRunStatus::Complete
                    }
                }
                "failed" => AgentRunStatus::Failed,
                "interrupted" => AgentRunStatus::Interrupted,
                other => {
                    return Err(Error::decode(
                        None,
                        format!("unexpected agent reply status {other:?}"),
                    ));
                }
            };

            let mut asset_ids = Vec::new();
            if let Some(session) = session_id {
                let cutoff = first_created.unwrap_or(reply.created_at);
                let assets = self
                    .list_agent_session_assets()
                    .id(session)
                    .send()
                    .typed()
                    .await?;
                let mut seen = HashSet::new();
                asset_ids = assets
                    .assets
                    .into_iter()
                    .filter(|a| a.created_at.is_some_and(|at| at >= cutoff))
                    .map(|a| a.id)
                    .filter(|id| seen.insert(*id))
                    .collect();
            }
            let text = match status {
                AgentRunStatus::Failed | AgentRunStatus::Interrupted => {
                    reply.error.clone().unwrap_or_default()
                }
                _ => reply.content.clone(),
            };
            return Ok(AgentRunResult {
                status,
                text,
                asset_ids,
                session_id,
                agent_message_id: message_id,
                question: reply.question.clone(),
                error_code: reply.error_code.clone(),
            });
        }
    }
}