Adapter Development Guide

Guide for creating custom adapters for Paladin's ports (interfaces).

Table of Contents

Overview

Paladin uses Hexagonal Architecture (Ports and Adapters) to enable pluggable implementations for external systems.

Core Concepts

┌─────────────────────────────────────────┐
│         Application Core                │
│  ┌──────────────────────────────────┐  │
│  │      Domain Logic (Core)          │  │
│  │  - Paladin, Battalion, etc.       │  │
│  └──────────────────────────────────┘  │
│               ▲                          │
│               │ Uses                     │
│  ┌──────────────────────────────────┐  │
│  │      Ports (Interfaces)           │  │
│  │  - LlmPort, GarrisonPort, etc.    │  │
│  └──────────────────────────────────┘  │
└─────────────────────────────────────────┘
                │ Implemented by
                ▼
┌─────────────────────────────────────────┐
│         Adapters (Infrastructure)        │
│  - OpenAI, DeepSeek, Anthropic           │
│  - SQLite, Redis, PostgreSQL             │
│  - MCP, Custom Tools                     │
└─────────────────────────────────────────┘

Adapter Lifecycle

  1. Define Port Trait (application layer)
  2. Implement Adapter (infrastructure layer)
  3. Register Adapter (dependency injection)
  4. Test Adapter (unit + integration tests)
  5. Document Adapter (usage examples)

Port Architecture

Existing Ports

PortLocationPurpose
LlmPortapplication/ports/output/llm_port.rsLLM provider abstraction
GarrisonPortapplication/ports/output/garrison_port.rsMemory storage
ArsenalPortapplication/ports/output/arsenal_port.rsTool execution
CitadelPortapplication/ports/output/citadel_port.rsState persistence
FileStoragePortapplication/ports/output/file_storage_port.rsFile storage
NotificationPortapplication/ports/output/notification_port.rsNotifications

Port Requirements

All ports must be:

  • Send + Sync: Thread-safe for async
  • Async: Use #[async_trait]
  • Error handling: Return Result<T, SpecificError>
  • Well documented: Rustdoc comments with examples

LLM Adapter Development

1. Define Custom LLM Provider

#![allow(unused)]
fn main() {
// src/infrastructure/adapters/llm/custom_llm_adapter.rs

use async_trait::async_trait;
use crate::paladin_ports::output::llm_port::{LlmPort, Message, LlmResponse};
use crate::core::platform::container::paladin::PaladinError;

pub struct CustomLlmAdapter {
    api_key: String,
    base_url: String,
    client: reqwest::Client,
}

impl CustomLlmAdapter {
    pub fn new(api_key: String, base_url: String) -> Self {
        Self {
            api_key,
            base_url,
            client: reqwest::Client::new(),
        }
    }
}

#[async_trait]
impl LlmPort for CustomLlmAdapter {
    async fn generate(
        &self,
        messages: &[Message],
        config: &LlmConfig,
    ) -> Result<LlmResponse, PaladinError> {
        // 1. Transform messages to provider format
        let request_body = self.build_request(messages, config)?;

        // 2. Make API call
        let response = self.client
            .post(format!("{}/chat/completions", self.base_url))
            .header("Authorization", format!("Bearer {}", self.api_key))
            .json(&request_body)
            .send()
            .await
            .map_err(|e| PaladinError::LlmError(e.to_string()))?;

        // 3. Parse response
        let response_data: CustomApiResponse = response
            .json()
            .await
            .map_err(|e| PaladinError::LlmError(e.to_string()))?;

        // 4. Transform to LlmResponse
        Ok(LlmResponse {
            content: response_data.message.content,
            model: response_data.model,
            usage: response_data.usage.into(),
            tool_calls: self.parse_tool_calls(&response_data),
        })
    }

    async fn generate_stream(
        &self,
        messages: &[Message],
        config: &LlmConfig,
    ) -> Result<Pin<Box<dyn Stream<Item = Result<LlmChunk>>>>, PaladinError> {
        // Implement streaming if supported
        todo!("Streaming implementation")
    }

    fn validate_model(&self, model: &str) -> Result<(), PaladinError> {
        const SUPPORTED_MODELS: &[&str] = &[
            "custom-model-v1",
            "custom-model-v2",
        ];

        if SUPPORTED_MODELS.contains(&model) {
            Ok(())
        } else {
            Err(PaladinError::ConfigurationError(
                format!("Unsupported model: {}", model)
            ))
        }
    }
}

impl CustomLlmAdapter {
    fn build_request(
        &self,
        messages: &[Message],
        config: &LlmConfig,
    ) -> Result<serde_json::Value, PaladinError> {
        // Provider-specific request format
        Ok(serde_json::json!({
            "model": config.model,
            "messages": messages,
            "temperature": config.temperature,
            "max_tokens": config.max_tokens,
        }))
    }

    fn parse_tool_calls(&self, response: &CustomApiResponse) -> Vec<ToolCall> {
        // Extract tool calls if provider supports them
        vec![]
    }
}
}

2. Handle Tool Calling

#![allow(unused)]
fn main() {
#[derive(Debug, Deserialize)]
struct CustomToolCall {
    id: String,
    function: FunctionCall,
}

#[derive(Debug, Deserialize)]
struct FunctionCall {
    name: String,
    arguments: String,
}

impl CustomLlmAdapter {
    fn parse_tool_calls(&self, response: &CustomApiResponse) -> Vec<ToolCall> {
        response.tool_calls
            .iter()
            .map(|tc| ToolCall {
                id: tc.id.clone(),
                name: tc.function.name.clone(),
                arguments: serde_json::from_str(&tc.function.arguments)
                    .unwrap_or_default(),
            })
            .collect()
    }
}
}

3. Configuration

# config.yml
llm:
  provider: "custom"
  custom:
    api_key: "${CUSTOM_API_KEY}"
    base_url: "https://api.custom-provider.com/v1"
    default_model: "custom-model-v1"
    timeout: 30s

4. Registration

#![allow(unused)]
fn main() {
// src/infrastructure/adapters/llm/mod.rs

pub fn create_llm_adapter(config: &LlmConfig) -> Result<Arc<dyn LlmPort>> {
    match config.provider.as_str() {
        "openai" => Ok(Arc::new(OpenAiAdapter::new(config)?)),
        "deepseek" => Ok(Arc::new(DeepSeekAdapter::new(config)?)),
        "anthropic" => Ok(Arc::new(AnthropicAdapter::new(config)?)),
        "custom" => Ok(Arc::new(CustomLlmAdapter::new(
            config.custom.api_key.clone(),
            config.custom.base_url.clone(),
        ))),
        _ => Err(Error::UnsupportedProvider(config.provider.clone())),
    }
}
}

Garrison Adapter Development

1. Implement Custom Storage Backend

#![allow(unused)]
fn main() {
// src/infrastructure/adapters/garrison/redis_garrison.rs

use async_trait::async_trait;
use redis::AsyncCommands;
use crate::paladin_ports::output::garrison_port::GarrisonPort;

pub struct RedisGarrison {
    client: redis::Client,
    prefix: String,
}

impl RedisGarrison {
    pub fn new(redis_url: &str, prefix: &str) -> Result<Self> {
        Ok(Self {
            client: redis::Client::open(redis_url)?,
            prefix: prefix.to_string(),
        })
    }

    fn make_key(&self, session_id: &Uuid) -> String {
        format!("{}:garrison:{}", self.prefix, session_id)
    }
}

#[async_trait]
impl GarrisonPort for RedisGarrison {
    async fn add_entry(
        &self,
        session_id: Uuid,
        entry: GarrisonEntry,
    ) -> Result<(), GarrisonError> {
        let mut conn = self.client.get_async_connection().await?;
        let key = self.make_key(&session_id);

        // Serialize entry
        let value = serde_json::to_string(&entry)?;

        // Add to list
        conn.rpush(key, value).await?;

        // Set expiration
        conn.expire(key, 3600).await?;

        Ok(())
    }

    async fn get_entries(
        &self,
        session_id: Uuid,
        limit: Option<usize>,
    ) -> Result<Vec<GarrisonEntry>, GarrisonError> {
        let mut conn = self.client.get_async_connection().await?;
        let key = self.make_key(&session_id);

        // Get entries
        let values: Vec<String> = if let Some(limit) = limit {
            conn.lrange(key, -(limit as isize), -1).await?
        } else {
            conn.lrange(key, 0, -1).await?
        };

        // Deserialize
        values.iter()
            .map(|v| serde_json::from_str(v).map_err(Into::into))
            .collect()
    }

    async fn search(
        &self,
        session_id: Uuid,
        query: &str,
    ) -> Result<Vec<GarrisonEntry>, GarrisonError> {
        // Implement semantic search using Redis Search module
        // or fallback to simple filtering
        let entries = self.get_entries(session_id, None).await?;
        Ok(entries.into_iter()
            .filter(|e| e.content.contains(query))
            .collect())
    }

    async fn clear(&self, session_id: Uuid) -> Result<(), GarrisonError> {
        let mut conn = self.client.get_async_connection().await?;
        let key = self.make_key(&session_id);
        conn.del(key).await?;
        Ok(())
    }
}
}

2. Add Vector Search Support

#![allow(unused)]
fn main() {
use crate::infrastructure::embeddings::EmbeddingProvider;

pub struct VectorGarrison {
    storage: Arc<dyn GarrisonPort>,
    embeddings: Arc<dyn EmbeddingProvider>,
}

#[async_trait]
impl GarrisonPort for VectorGarrison {
    async fn search(
        &self,
        session_id: Uuid,
        query: &str,
    ) -> Result<Vec<GarrisonEntry>, GarrisonError> {
        // 1. Generate query embedding
        let query_embedding = self.embeddings.embed(query).await?;

        // 2. Get all entries
        let entries = self.storage.get_entries(session_id, None).await?;

        // 3. Compute similarity scores
        let mut scored: Vec<_> = entries.into_iter()
            .map(|entry| {
                let score = cosine_similarity(&query_embedding, &entry.embedding);
                (entry, score)
            })
            .collect();

        // 4. Sort by relevance
        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());

        // 5. Return top results
        Ok(scored.into_iter()
            .take(10)
            .map(|(entry, _)| entry)
            .collect())
    }
}
}

Arsenal Adapter Development

1. Create Custom Tool

#![allow(unused)]
fn main() {
// src/infrastructure/adapters/arsenal/weather_tool.rs

use async_trait::async_trait;
use crate::paladin_ports::output::arsenal_port::{ArsenalPort, ToolDefinition};

pub struct WeatherTool {
    api_key: String,
    client: reqwest::Client,
}

impl WeatherTool {
    pub fn new(api_key: String) -> Self {
        Self {
            api_key,
            client: reqwest::Client::new(),
        }
    }
}

#[async_trait]
impl ArsenalPort for WeatherTool {
    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: "get_weather".into(),
            description: "Get current weather for a location".into(),
            parameters: serde_json::json!({
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "City name or coordinates"
                    }
                },
                "required": ["location"]
            }),
        }
    }

    async fn execute(
        &self,
        arguments: serde_json::Value,
    ) -> Result<ToolResult, ArsenalError> {
        // 1. Parse arguments
        let location = arguments["location"]
            .as_str()
            .ok_or(ArsenalError::InvalidArguments)?;

        // 2. Call weather API
        let response = self.client
            .get("https://api.weather.com/v1/current")
            .query(&[
                ("location", location),
                ("apikey", &self.api_key),
            ])
            .send()
            .await?;

        // 3. Parse response
        let weather: WeatherData = response.json().await?;

        // 4. Return result
        Ok(ToolResult {
            content: serde_json::to_string(&weather)?,
            metadata: Some(serde_json::json!({
                "provider": "weather.com",
                "location": location,
            })),
        })
    }
}
}

2. Implement MCP Tool Wrapper

#![allow(unused)]
fn main() {
// src/infrastructure/adapters/arsenal/mcp_wrapper.rs

pub struct McpToolWrapper {
    server_url: String,
    tool_name: String,
    client: reqwest::Client,
}

#[async_trait]
impl ArsenalPort for McpToolWrapper {
    fn definition(&self) -> ToolDefinition {
        // Fetch tool definition from MCP server
        // Cache for performance
        todo!()
    }

    async fn execute(
        &self,
        arguments: serde_json::Value,
    ) -> Result<ToolResult, ArsenalError> {
        // Forward to MCP server
        let response = self.client
            .post(format!("{}/tools/{}/execute", self.server_url, self.tool_name))
            .json(&arguments)
            .send()
            .await?;

        let result: McpToolResult = response.json().await?;
        Ok(result.into())
    }
}
}

Citadel Adapter Development

1. Implement Custom Persistence

#![allow(unused)]
fn main() {
// src/infrastructure/adapters/citadel/s3_citadel.rs

use async_trait::async_trait;
use crate::paladin_ports::output::citadel_port::CitadelPort;

pub struct S3Citadel {
    bucket: String,
    client: aws_sdk_s3::Client,
}

impl S3Citadel {
    pub async fn new(bucket: String) -> Result<Self> {
        let config = aws_config::load_from_env().await;
        let client = aws_sdk_s3::Client::new(&config);
        Ok(Self { bucket, client })
    }
}

#[async_trait]
impl CitadelPort for S3Citadel {
    async fn save_state(
        &self,
        session_id: Uuid,
        state: PaladinState,
    ) -> Result<(), CitadelError> {
        let key = format!("paladin-state/{}.json", session_id);
        let body = serde_json::to_vec(&state)?;

        self.client
            .put_object()
            .bucket(&self.bucket)
            .key(key)
            .body(body.into())
            .send()
            .await?;

        Ok(())
    }

    async fn load_state(
        &self,
        session_id: Uuid,
    ) -> Result<Option<PaladinState>, CitadelError> {
        let key = format!("paladin-state/{}.json", session_id);

        match self.client
            .get_object()
            .bucket(&self.bucket)
            .key(key)
            .send()
            .await
        {
            Ok(output) => {
                let bytes = output.body.collect().await?.into_bytes();
                let state = serde_json::from_slice(&bytes)?;
                Ok(Some(state))
            }
            Err(_) => Ok(None),
        }
    }
}
}

Testing Adapters

Unit Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_custom_llm_adapter() {
        let adapter = CustomLlmAdapter::new(
            "test-key".into(),
            "http://localhost:8080".into(),
        );

        let messages = vec![Message::user("Hello")];
        let config = LlmConfig::default();

        let response = adapter.generate(&messages, &config).await;
        assert!(response.is_ok());
    }

    #[test]
    fn test_model_validation() {
        let adapter = CustomLlmAdapter::new(
            "test-key".into(),
            "http://localhost".into(),
        );

        assert!(adapter.validate_model("custom-model-v1").is_ok());
        assert!(adapter.validate_model("invalid-model").is_err());
    }
}
}

Integration Tests

#![allow(unused)]
fn main() {
#[tokio::test]
async fn test_garrison_roundtrip() {
    let garrison = RedisGarrison::new("redis://localhost:6379", "test").unwrap();
    let session_id = Uuid::new_v4();

    // Add entry
    let entry = GarrisonEntry {
        role: "user".into(),
        content: "Test message".into(),
        timestamp: Utc::now(),
    };
    garrison.add_entry(session_id, entry.clone()).await.unwrap();

    // Retrieve
    let entries = garrison.get_entries(session_id, None).await.unwrap();
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].content, "Test message");

    // Clear
    garrison.clear(session_id).await.unwrap();
    let entries = garrison.get_entries(session_id, None).await.unwrap();
    assert_eq!(entries.len(), 0);
}
}

Publishing Adapters

1. Create Separate Crate

# Cargo.toml for adapter crate
[package]
name = "paladin-custom-llm"
version = "0.1.0"
edition = "2021"

[dependencies]
paladin = { version = "0.1", default-features = false }
async-trait = "0.1"
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

2. Documentation

#![allow(unused)]
fn main() {
//! # Custom LLM Adapter for Paladin
//!
//! This adapter provides integration with CustomProvider's LLM API.
//!
//! ## Installation
//!
//! ```toml
//! [dependencies]
//! paladin-custom-llm = "0.1"
//! ```
//!
//! ## Usage
//!
//! ```rust
//! use paladin_custom_llm::CustomLlmAdapter;
//!
//! let adapter = CustomLlmAdapter::new(api_key, base_url);
//! let paladin = PaladinBuilder::new(Arc::new(adapter))
//!     .build()?;
//! ```
}

3. Examples

Provide complete working examples in examples/ directory.

Next Steps