agents-kit 0.0.1

Rust agent control Kit for Codex, OpenCode, and provider-backed Custom runtimes
Documentation
use std::path::PathBuf;

use crate::backends::codex::adapters::conversation::CodexThread;
use crate::core::state::{ConversationRecord, WorkspaceRecord};
use crate::{ConversationId, KitError, WorkspaceId};

const CODEX_THREAD_BINDING_VERSION: u32 = 2;
const CODEX_FOREGROUND_POLICY_VERSION: u32 = 1;

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CodexThreadBinding {
    version: u32,
    thread_id: String,
    conversation_id: ConversationId,
    workspace_id: WorkspaceId,
    attachment: CodexThreadAttachment,
    cwd: PathBuf,
    instructions: Option<String>,
    foreground_policy_version: u32,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
enum CodexThreadAttachment {
    Materialized {
        workspace_revision: u64,
    },
    RebindRequired {
        attached_revision: u64,
        target_revision: u64,
    },
}

impl CodexThreadBinding {
    pub(crate) fn new(
        thread: &CodexThread,
        conversation_id: ConversationId,
        workspace_id: WorkspaceId,
        workspace_revision: u64,
        instructions: Option<&str>,
        thread_source: &str,
    ) -> Result<Self, KitError> {
        thread.require_thread_source(thread_source)?;
        let binding = Self {
            version: CODEX_THREAD_BINDING_VERSION,
            thread_id: thread.id().to_owned(),
            conversation_id,
            workspace_id,
            attachment: CodexThreadAttachment::Materialized { workspace_revision },
            cwd: thread.cwd().to_path_buf(),
            instructions: instructions.map(str::to_owned),
            foreground_policy_version: CODEX_FOREGROUND_POLICY_VERSION,
        };
        binding.validate()?;
        Ok(binding)
    }

    pub(crate) fn thread_id(&self) -> &str {
        &self.thread_id
    }

    pub(crate) fn conversation_id(&self) -> &ConversationId {
        &self.conversation_id
    }

    pub(crate) fn workspace_id(&self) -> &WorkspaceId {
        &self.workspace_id
    }

    pub(crate) fn workspace_revision(&self) -> u64 {
        match self.attachment {
            CodexThreadAttachment::Materialized { workspace_revision } => workspace_revision,
            CodexThreadAttachment::RebindRequired {
                target_revision, ..
            } => target_revision,
        }
    }

    pub(crate) fn requires_rebind(&self) -> bool {
        matches!(
            self.attachment,
            CodexThreadAttachment::RebindRequired { .. }
        )
    }

    pub(crate) fn require_materialized(&self) -> Result<(), KitError> {
        if self.requires_rebind() {
            Err(KitError::conflict(
                "conversation attachment requires workspace-policy refresh",
            ))
        } else {
            Ok(())
        }
    }

    pub(crate) fn with_rebind_required(&self, target_revision: u64) -> Result<Self, KitError> {
        let attached_revision = match self.attachment {
            CodexThreadAttachment::Materialized { workspace_revision } => workspace_revision,
            CodexThreadAttachment::RebindRequired {
                attached_revision, ..
            } => attached_revision,
        };
        if target_revision <= self.workspace_revision() || target_revision <= attached_revision {
            return Err(KitError::invalid_state(
                "conversation attachment target must advance its workspace revision",
            ));
        }
        let mut binding = self.clone();
        binding.attachment = CodexThreadAttachment::RebindRequired {
            attached_revision,
            target_revision,
        };
        binding.validate()?;
        Ok(binding)
    }

    pub(crate) fn with_materialized_thread(
        &self,
        thread: &CodexThread,
        target_revision: u64,
    ) -> Result<Self, KitError> {
        if thread.id() != self.thread_id {
            return Err(KitError::invalid_state(
                "rebound conversation contradicts its durable identity",
            ));
        }
        match self.attachment {
            CodexThreadAttachment::Materialized { workspace_revision }
                if workspace_revision == target_revision => {}
            CodexThreadAttachment::RebindRequired {
                target_revision: expected,
                ..
            } if expected == target_revision => {}
            _ => {
                return Err(KitError::conflict(
                    "conversation attachment changed during workspace-policy refresh",
                ));
            }
        }
        let mut binding = self.clone();
        binding.attachment = CodexThreadAttachment::Materialized {
            workspace_revision: target_revision,
        };
        binding.cwd = thread.cwd().to_path_buf();
        binding.validate()?;
        Ok(binding)
    }

    pub(crate) fn verify_native(&self, thread: &CodexThread) -> Result<(), KitError> {
        if thread.id() != self.thread_id || (!self.requires_rebind() && thread.cwd() != self.cwd) {
            return Err(KitError::invalid_state(
                "conversation does not match its durable attachment",
            ));
        }
        Ok(())
    }

    pub(crate) fn verify_owner(
        &self,
        conversation: &ConversationRecord,
        workspace: &WorkspaceRecord,
        instructions: Option<&str>,
    ) -> Result<(), KitError> {
        if &self.conversation_id != conversation.id()
            || &self.workspace_id != conversation.workspace_id()
            || &self.workspace_id != workspace.id()
            || self.workspace_revision() != conversation.workspace_revision()
            || self.workspace_revision() != workspace.revision()
            || self.instructions.as_deref() != instructions
        {
            return Err(KitError::invalid_state(
                "conversation attachment contradicts its portable owner",
            ));
        }
        Ok(())
    }

    pub(crate) fn validate(&self) -> Result<(), KitError> {
        let attachment_valid = match self.attachment {
            CodexThreadAttachment::Materialized { workspace_revision } => workspace_revision > 0,
            CodexThreadAttachment::RebindRequired {
                attached_revision,
                target_revision,
            } => attached_revision > 0 && target_revision > attached_revision,
        };
        if self.version != CODEX_THREAD_BINDING_VERSION
            || self.foreground_policy_version != CODEX_FOREGROUND_POLICY_VERSION
            || self.thread_id.is_empty()
            || self.thread_id.trim() != self.thread_id
            || !attachment_valid
            || !self.cwd.is_absolute()
            || self.instructions.as_deref().is_some_and(|instructions| {
                instructions.is_empty() || instructions.trim() != instructions
            })
        {
            return Err(KitError::invalid_state(
                "stored conversation attachment is malformed",
            ));
        }
        Ok(())
    }
}