nanocodex-agent 0.3.0

Owned OpenAI agent lifecycle for Nanocodex
Documentation
use std::path::{Path, PathBuf};

use tracing::error;

use crate::{
    NanocodexError, Result,
    rollout::{RolloutConfig, RolloutInfo, RolloutOrigin, RolloutRecorder, RolloutTurn},
    session::CommittedSession,
};

#[derive(Clone, Default)]
pub(crate) struct DurabilityConfig {
    rollout: Option<RolloutConfig>,
}

impl DurabilityConfig {
    pub(crate) fn set_rollout(&mut self, rollout: RolloutConfig) {
        self.rollout = Some(rollout);
    }

    pub(crate) fn for_new_thread(&self) -> Self {
        Self {
            rollout: self.rollout.as_ref().map(RolloutConfig::for_new_thread),
        }
    }

    #[allow(clippy::too_many_arguments)]
    pub(crate) fn start(
        &self,
        session_id: &str,
        workspace: Option<&str>,
        instructions: &str,
        origin_kind: &'static str,
        parent_session_id: Option<&str>,
        resume_history_len: Option<usize>,
    ) -> Result<Durability> {
        let Some(config) = &self.rollout else {
            return Ok(Durability::default());
        };
        let runtime = tokio::runtime::Handle::try_current()
            .map_err(|_| NanocodexError::TokioRuntimeUnavailable)?;
        let cwd =
            rollout_workspace(workspace).map_err(|source| NanocodexError::InitializeRollout {
                codex_home: config.codex_home().to_path_buf(),
                source,
            })?;
        let recorder = RolloutRecorder::create(
            &runtime,
            config,
            session_id,
            &cwd,
            instructions,
            RolloutOrigin {
                kind: origin_kind,
                parent_thread_id: parent_session_id,
            },
            resume_history_len,
        )
        .map_err(|source| NanocodexError::InitializeRollout {
            codex_home: config.codex_home().to_path_buf(),
            source,
        })?;
        Ok(Durability {
            recorder: Some(recorder),
        })
    }
}

#[derive(Clone, Default)]
pub(crate) struct Durability {
    recorder: Option<RolloutRecorder>,
}

impl Durability {
    pub(crate) const fn info(&self) -> Option<&RolloutInfo> {
        match &self.recorder {
            Some(recorder) => Some(recorder.info()),
            None => None,
        }
    }

    pub(crate) fn start_turn(&self, prompt: &nanocodex_oai_api::Prompt) -> DurabilityTurn {
        DurabilityTurn(self.recorder.as_ref().map(|_| RolloutTurn::started(prompt)))
    }

    pub(crate) fn start_compaction(&self) -> DurabilityTurn {
        DurabilityTurn(
            self.recorder
                .as_ref()
                .map(|_| RolloutTurn::compaction_started()),
        )
    }

    pub(crate) async fn persist(&self, checkpoint: &CommittedSession, turn: DurabilityTurn) {
        let (Some(recorder), Some(turn)) = (&self.recorder, turn.0) else {
            return;
        };
        if let Err(source) = recorder.persist(checkpoint, turn).await {
            error!(
                target: "nanocodex",
                rollout_path = %recorder.info().path().display(),
                error = %source,
                "failed to persist Codex rollout"
            );
        }
    }

    pub(crate) async fn persist_compaction(
        &self,
        checkpoint: &CommittedSession,
        turn: DurabilityTurn,
    ) {
        let (Some(recorder), Some(turn)) = (&self.recorder, turn.0) else {
            return;
        };
        if let Err(source) = recorder.persist_compaction(checkpoint, turn).await {
            error!(
                target: "nanocodex",
                rollout_path = %recorder.info().path().display(),
                error = %source,
                "failed to persist Codex compaction boundary"
            );
        }
    }

    pub(crate) async fn flush(&self) -> Result<()> {
        let Some(recorder) = &self.recorder else {
            return Ok(());
        };
        recorder
            .flush()
            .await
            .map_err(|source| NanocodexError::PersistRollout {
                path: recorder.info().path().to_path_buf(),
                source,
            })
    }

    pub(crate) async fn shutdown(&self) -> Result<()> {
        let Some(recorder) = &self.recorder else {
            return Ok(());
        };
        recorder
            .shutdown()
            .await
            .map_err(|source| NanocodexError::PersistRollout {
                path: recorder.info().path().to_path_buf(),
                source,
            })
    }
}

pub(crate) struct DurabilityTurn(Option<RolloutTurn>);

impl DurabilityTurn {
    pub(crate) fn completed(self, final_message: String) -> Self {
        Self(self.0.map(|turn| turn.completed(final_message)))
    }

    pub(crate) fn completed_without_message(self) -> Self {
        Self(self.0.map(RolloutTurn::completed_without_message))
    }

    pub(crate) fn interrupted(self) -> Self {
        Self(self.0.map(RolloutTurn::interrupted))
    }

    pub(crate) fn replaced(self) -> Self {
        Self(self.0.map(RolloutTurn::replaced))
    }

    pub(crate) fn failed(self) -> Self {
        Self(self.0.map(RolloutTurn::failed))
    }
}

fn rollout_workspace(workspace: Option<&str>) -> std::io::Result<PathBuf> {
    let current = std::env::current_dir()?;
    let Some(workspace) = workspace else {
        return Ok(current);
    };
    let workspace = Path::new(workspace);
    if workspace.is_absolute() {
        Ok(workspace.to_path_buf())
    } else {
        Ok(current.join(workspace))
    }
}