roder-api 0.1.0

Agentic software development tools and SDKs for Roder.
Documentation
use std::sync::Arc;

use serde::{Deserialize, Serialize};
use time::OffsetDateTime;

use crate::events::{ThreadId, TurnId};

pub type ContextArtifactId = String;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ContextArtifactKind {
    ToolOutput,
    CommandStdout,
    CommandStderr,
    TerminalTranscript,
    ChatHistory,
    CompactionSource,
    ContextProviderDump,
}

impl ContextArtifactKind {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::ToolOutput => "tool_output",
            Self::CommandStdout => "command_stdout",
            Self::CommandStderr => "command_stderr",
            Self::TerminalTranscript => "terminal_transcript",
            Self::ChatHistory => "chat_history",
            Self::CompactionSource => "compaction_source",
            Self::ContextProviderDump => "context_provider_dump",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ContextArtifact {
    pub id: ContextArtifactId,
    pub kind: ContextArtifactKind,
    pub thread_id: ThreadId,
    pub turn_id: TurnId,
    pub byte_count: u64,
    pub line_count: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source_tool_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    pub store_path: String,
    #[serde(
        default,
        with = "time::serde::rfc3339::option",
        skip_serializing_if = "Option::is_none"
    )]
    pub retention_expires_at: Option<OffsetDateTime>,
    #[serde(with = "time::serde::rfc3339")]
    pub created_at: OffsetDateTime,
    #[serde(default = "default_roder_owned")]
    pub roder_owned: bool,
}

impl ContextArtifact {
    pub fn descriptor(&self) -> ContextArtifactDescriptor {
        ContextArtifactDescriptor {
            id: self.id.clone(),
            kind: self.kind.clone(),
            thread_id: self.thread_id.clone(),
            turn_id: self.turn_id.clone(),
            byte_count: self.byte_count,
            line_count: self.line_count,
            source_tool_id: self.source_tool_id.clone(),
            label: self.label.clone(),
            retention_expires_at: self.retention_expires_at,
            created_at: self.created_at,
        }
    }
}

fn default_roder_owned() -> bool {
    true
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ContextArtifactDescriptor {
    pub id: ContextArtifactId,
    pub kind: ContextArtifactKind,
    pub thread_id: ThreadId,
    pub turn_id: TurnId,
    pub byte_count: u64,
    pub line_count: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source_tool_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    #[serde(
        default,
        with = "time::serde::rfc3339::option",
        skip_serializing_if = "Option::is_none"
    )]
    pub retention_expires_at: Option<OffsetDateTime>,
    #[serde(with = "time::serde::rfc3339")]
    pub created_at: OffsetDateTime,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactReadPage {
    pub artifact: ContextArtifactDescriptor,
    pub text: String,
    pub start_line: usize,
    pub limit: usize,
    pub shown: usize,
    pub total_lines: usize,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub next_start_line: Option<usize>,
    pub truncated: bool,
}

#[derive(Debug, Clone)]
pub struct CreateArtifactRequest<'a> {
    pub kind: ContextArtifactKind,
    pub thread_id: &'a ThreadId,
    pub turn_id: &'a TurnId,
    pub source_tool_id: Option<&'a str>,
    pub label: Option<&'a str>,
    pub bytes: &'a [u8],
}

#[derive(Clone)]
pub struct ContextArtifactStore {
    backend: Arc<dyn ContextArtifactAccess>,
}

impl ContextArtifactStore {
    pub fn new(backend: Arc<dyn ContextArtifactAccess>) -> Self {
        Self { backend }
    }

    pub fn backend(&self) -> Arc<dyn ContextArtifactAccess> {
        Arc::clone(&self.backend)
    }

    pub fn create(&self, request: CreateArtifactRequest<'_>) -> anyhow::Result<ContextArtifact> {
        self.backend.create_artifact(request)
    }

    pub fn append(
        &self,
        thread_id: &ThreadId,
        artifact_id: &ContextArtifactId,
        bytes: &[u8],
    ) -> anyhow::Result<ContextArtifact> {
        self.backend.append_artifact(thread_id, artifact_id, bytes)
    }

    pub fn list_artifacts(&self, thread_id: &ThreadId) -> anyhow::Result<Vec<ContextArtifact>> {
        self.backend.list_artifacts(thread_id)
    }

    pub fn read_artifact(
        &self,
        thread_id: &ThreadId,
        artifact_id: &ContextArtifactId,
        start_line: usize,
        limit: usize,
    ) -> anyhow::Result<ArtifactReadPage> {
        self.backend
            .read_artifact(thread_id, artifact_id, start_line, limit)
    }

    pub fn grep_artifact(
        &self,
        thread_id: &ThreadId,
        artifact_id: &ContextArtifactId,
        query: &str,
        offset: usize,
        limit: usize,
    ) -> anyhow::Result<ArtifactGrepPage> {
        self.backend
            .grep_artifact(thread_id, artifact_id, query, offset, limit)
    }

    pub fn tail_artifact(
        &self,
        thread_id: &ThreadId,
        artifact_id: &ContextArtifactId,
        lines: usize,
    ) -> anyhow::Result<ArtifactTailPage> {
        self.backend.tail_artifact(thread_id, artifact_id, lines)
    }

    pub fn delete_artifact(
        &self,
        thread_id: &ThreadId,
        artifact_id: &ContextArtifactId,
    ) -> anyhow::Result<bool> {
        self.backend.delete_artifact(thread_id, artifact_id)
    }
}

impl std::fmt::Debug for ContextArtifactStore {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ContextArtifactStore")
            .finish_non_exhaustive()
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactGrepPage {
    pub artifact: ContextArtifactDescriptor,
    pub query: String,
    pub text: String,
    pub offset: usize,
    pub limit: usize,
    pub shown: usize,
    pub total_matches: usize,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub next_offset: Option<usize>,
    pub truncated: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactTailPage {
    pub artifact: ContextArtifactDescriptor,
    pub text: String,
    pub start_line: usize,
    pub lines: usize,
    pub shown: usize,
    pub total_lines: usize,
    pub truncated: bool,
}

pub trait ContextArtifactAccess: Send + Sync + 'static {
    fn create_artifact(
        &self,
        request: CreateArtifactRequest<'_>,
    ) -> anyhow::Result<ContextArtifact>;
    fn append_artifact(
        &self,
        thread_id: &ThreadId,
        artifact_id: &ContextArtifactId,
        bytes: &[u8],
    ) -> anyhow::Result<ContextArtifact>;
    fn list_artifacts(&self, thread_id: &ThreadId) -> anyhow::Result<Vec<ContextArtifact>>;
    fn read_artifact(
        &self,
        thread_id: &ThreadId,
        artifact_id: &ContextArtifactId,
        start_line: usize,
        limit: usize,
    ) -> anyhow::Result<ArtifactReadPage>;
    fn grep_artifact(
        &self,
        thread_id: &ThreadId,
        artifact_id: &ContextArtifactId,
        query: &str,
        offset: usize,
        limit: usize,
    ) -> anyhow::Result<ArtifactGrepPage>;
    fn tail_artifact(
        &self,
        thread_id: &ThreadId,
        artifact_id: &ContextArtifactId,
        lines: usize,
    ) -> anyhow::Result<ArtifactTailPage>;
    fn delete_artifact(
        &self,
        thread_id: &ThreadId,
        artifact_id: &ContextArtifactId,
    ) -> anyhow::Result<bool>;
}

pub fn format_artifact_reference(artifact: &ContextArtifact, label: impl AsRef<str>) -> String {
    let label = label.as_ref();
    let label = if label.is_empty() { "content" } else { label };
    format!(
        "[artifact: {} {label} lines={} bytes={} id={}]\nUse read_artifact, grep_artifact, or tail_artifact with artifact_id \"{}\" to inspect more.",
        artifact.kind.as_str(),
        artifact.line_count,
        artifact.byte_count,
        artifact.id,
        artifact.id
    )
}

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

    #[test]
    fn artifact_descriptor_hides_store_path() {
        let artifact = ContextArtifact {
            id: "artifact-1".to_string(),
            kind: ContextArtifactKind::ToolOutput,
            thread_id: "thread-a".to_string(),
            turn_id: "turn-a".to_string(),
            byte_count: 10,
            line_count: 2,
            source_tool_id: Some("call-1".to_string()),
            label: Some("stdout".to_string()),
            store_path: "/tmp/private/artifact-1.txt".to_string(),
            retention_expires_at: None,
            created_at: OffsetDateTime::UNIX_EPOCH,
            roder_owned: true,
        };

        let value = serde_json::to_value(artifact.descriptor()).unwrap();

        assert_eq!(value["kind"], "tool_output");
        assert_eq!(value["sourceToolId"], "call-1");
        assert!(value.get("storePath").is_none());
    }

    #[test]
    fn artifact_reference_names_follow_up_tools() {
        let artifact = ContextArtifact {
            id: "artifact-1".to_string(),
            kind: ContextArtifactKind::CommandStdout,
            thread_id: "app-server".to_string(),
            turn_id: "process-1".to_string(),
            byte_count: 12,
            line_count: 1,
            source_tool_id: Some("process-1".to_string()),
            label: Some("stdout".to_string()),
            store_path: "/tmp/private/artifact-1.txt".to_string(),
            retention_expires_at: None,
            created_at: OffsetDateTime::UNIX_EPOCH,
            roder_owned: true,
        };

        let reference = format_artifact_reference(&artifact, "stdout");

        assert!(
            reference.contains("[artifact: command_stdout stdout lines=1 bytes=12 id=artifact-1]")
        );
        assert!(reference.contains("read_artifact"));
        assert!(reference.contains("grep_artifact"));
    }
}