chimera-core 0.2.0

Shared traits, types, and transport for the chimera AI agent SDK
Documentation
use bon::Builder;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;

/// A backend-agnostic MCP server descriptor for stdio-based servers.
///
/// Each chimera backend translates this into its own wire format:
/// - Claude: `McpServerConfig { name, command, args, env }`
/// - OpenCode: `OpenCodeMcpServer { name, command: [binary] + args, env }`
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct McpServer {
    pub name: String,
    pub command: String,
    pub args: Vec<String>,
    pub env: HashMap<String, String>,
}

/// Controls whether backend-supplied MCP servers are merged with ambient config
/// or treated as the explicit MCP set for the session.
///
/// Backends differ in how faithfully they can implement `ExplicitOnly`:
/// - Claude maps to the native `--strict-mcp-config` flag.
/// - OpenCode isolates ambient config roots and supplies only the Chimera
///   config blob; auth/session data remains visible.
/// - Codex uses an isolated `CODEX_HOME` plus explicit overrides, but upstream
///   still merges repo-local `.codex` config, so this is best-effort there.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum McpConfigMode {
    #[default]
    Merge,
    ExplicitOnly,
}

#[derive(Debug, Clone, Builder, Serialize, Deserialize)]
pub struct SessionConfig<C> {
    #[builder(into)]
    pub model: Option<String>,

    pub cwd: Option<PathBuf>,

    #[builder(default)]
    pub additional_dirs: Vec<PathBuf>,

    #[builder(default)]
    pub env: HashMap<String, String>,

    /// Inherited environment variables to remove from the subprocess.
    /// Only meaningful when the subprocess inherits the parent environment.
    #[builder(default)]
    pub env_remove: HashSet<String>,

    pub max_turns: Option<u32>,

    #[builder(into)]
    pub system_prompt: Option<String>,

    pub backend: C,
}

impl<C> SessionConfig<C> {
    /// Swap the backend config type, preserving shared fields.
    pub fn with_backend<D>(self, backend: D) -> SessionConfig<D> {
        self.map_backend(|_| backend)
    }

    /// Transform the backend config, preserving shared fields.
    pub fn map_backend<D>(self, f: impl FnOnce(C) -> D) -> SessionConfig<D> {
        let SessionConfig {
            backend,
            model,
            cwd,
            additional_dirs,
            env,
            env_remove,
            max_turns,
            system_prompt,
        } = self;
        SessionConfig {
            backend: f(backend),
            model,
            cwd,
            additional_dirs,
            env,
            env_remove,
            max_turns,
            system_prompt,
        }
    }

    /// Transform the backend config fallibly, preserving shared fields.
    pub fn try_map_backend<D, E>(
        self,
        f: impl FnOnce(C) -> std::result::Result<D, E>,
    ) -> std::result::Result<SessionConfig<D>, E> {
        let SessionConfig {
            backend,
            model,
            cwd,
            additional_dirs,
            env,
            env_remove,
            max_turns,
            system_prompt,
        } = self;
        Ok(SessionConfig {
            backend: f(backend)?,
            model,
            cwd,
            additional_dirs,
            env,
            env_remove,
            max_turns,
            system_prompt,
        })
    }
}

/// Backend-agnostic tool definition (maps to OpenAI function-calling or equivalent).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
    /// Function name the model must call.
    pub name: String,
    /// Human-readable description shown to the model.
    pub description: String,
    /// JSON Schema object describing the function's parameters.
    pub parameters: serde_json::Value,
}

/// Controls which tool (if any) the model is required to call.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolChoice {
    /// Model decides whether to call a tool.
    Auto,
    /// Model must call at least one tool.
    Required,
    /// Model must not call any tools.
    None,
    /// Model must call this specific function.
    Function(String),
}

#[derive(Debug, Clone, Default, Builder, Serialize, Deserialize)]
pub struct TurnOptions {
    pub output_schema: Option<serde_json::Value>,
    /// Maximum duration for this turn. If exceeded, the subprocess is killed
    /// and `AgentError::Timeout` is returned.
    #[serde(default)]
    pub timeout: Option<std::time::Duration>,
    /// Tools available to the model for this turn.
    #[builder(default)]
    #[serde(default)]
    pub tools: Vec<ToolDefinition>,
    /// Tool selection policy. `None` lets the backend decide.
    pub tool_choice: Option<ToolChoice>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Input {
    Text(String),
    Structured(Vec<InputPart>),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum InputPart {
    Text(String),
    Image(PathBuf),
}

impl From<String> for Input {
    fn from(s: String) -> Self {
        Input::Text(s)
    }
}

impl From<&str> for Input {
    fn from(s: &str) -> Self {
        Input::Text(s.to_owned())
    }
}

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

    #[test]
    fn session_config_builder() {
        let config = SessionConfig::builder()
            .model("gpt-4")
            .cwd(PathBuf::from("/tmp"))
            .backend("test-backend")
            .build();

        assert_eq!(config.model.as_deref(), Some("gpt-4"));
        assert_eq!(config.cwd, Some(PathBuf::from("/tmp")));
        assert_eq!(config.backend, "test-backend");
        assert!(config.additional_dirs.is_empty());
        assert!(config.env.is_empty());
    }

    #[test]
    fn with_backend_preserves_shared_fields() {
        let config = SessionConfig::builder()
            .model("gpt-4")
            .cwd(PathBuf::from("/tmp"))
            .max_turns(5)
            .backend("original")
            .build();

        let swapped = config.with_backend(42u32);

        assert_eq!(swapped.model.as_deref(), Some("gpt-4"));
        assert_eq!(swapped.cwd, Some(PathBuf::from("/tmp")));
        assert_eq!(swapped.max_turns, Some(5));
        assert_eq!(swapped.backend, 42);
    }

    #[test]
    fn map_backend_transforms() {
        let config = SessionConfig::builder()
            .model("gpt-4")
            .backend("hello")
            .build();

        let mapped = config.map_backend(|s: &str| s.len());

        assert_eq!(mapped.model.as_deref(), Some("gpt-4"));
        assert_eq!(mapped.backend, 5);
    }

    #[test]
    fn mcp_config_mode_defaults_to_merge() {
        assert_eq!(McpConfigMode::default(), McpConfigMode::Merge);
    }

    #[test]
    fn try_map_backend_ok() {
        let mut env = HashMap::new();
        env.insert("KEY".into(), "VAL".into());

        let config = SessionConfig::builder()
            .model("gpt-4")
            .cwd(PathBuf::from("/tmp"))
            .additional_dirs(vec![PathBuf::from("/extra")])
            .env(env)
            .max_turns(10)
            .system_prompt("be helpful")
            .backend(42u32)
            .build();

        let result: std::result::Result<SessionConfig<String>, &str> =
            config.try_map_backend(|n| Ok(n.to_string()));

        let mapped = result.unwrap();
        assert_eq!(mapped.backend, "42");
        assert_eq!(mapped.model.as_deref(), Some("gpt-4"));
        assert_eq!(mapped.cwd, Some(PathBuf::from("/tmp")));
        assert_eq!(mapped.additional_dirs, vec![PathBuf::from("/extra")]);
        assert_eq!(mapped.env.get("KEY").unwrap(), "VAL");
        assert_eq!(mapped.max_turns, Some(10));
        assert_eq!(mapped.system_prompt.as_deref(), Some("be helpful"));
    }

    #[test]
    fn try_map_backend_err() {
        let config = SessionConfig::builder().backend(42u32).build();

        let result: std::result::Result<SessionConfig<String>, &str> =
            config.try_map_backend(|_| Err("mismatch"));

        assert_eq!(result.unwrap_err(), "mismatch");
    }

    #[test]
    fn env_remove_preserved_through_map_backend() {
        let config = SessionConfig::builder()
            .env_remove(HashSet::from(["SECRET".into()]))
            .backend("original")
            .build();

        let mapped = config.map_backend(|s: &str| s.len());

        assert!(mapped.env_remove.contains("SECRET"));
        assert_eq!(mapped.backend, 8);
    }

    #[test]
    fn env_remove_preserved_through_try_map_backend() {
        let config = SessionConfig::builder()
            .env_remove(HashSet::from(["KEY".into()]))
            .backend(42u32)
            .build();

        let result: std::result::Result<SessionConfig<String>, &str> =
            config.try_map_backend(|n| Ok(n.to_string()));

        let mapped = result.unwrap();
        assert!(mapped.env_remove.contains("KEY"));
    }

    #[test]
    fn input_from_str() {
        let input: Input = "hello".into();
        match input {
            Input::Text(s) => assert_eq!(s, "hello"),
            _ => panic!("expected Text variant"),
        }
    }

    #[test]
    fn input_from_string() {
        let input: Input = String::from("hello").into();
        match input {
            Input::Text(s) => assert_eq!(s, "hello"),
            _ => panic!("expected Text variant"),
        }
    }
}