Skip to main content

chimera_core/
config.rs

1use bon::Builder;
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use std::path::PathBuf;
5
6/// A backend-agnostic MCP server descriptor for stdio-based servers.
7///
8/// Each chimera backend translates this into its own wire format:
9/// - Claude: `McpServerConfig { name, command, args, env }`
10/// - OpenCode: `OpenCodeMcpServer { name, command: [binary] + args, env }`
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct McpServer {
13    pub name: String,
14    pub command: String,
15    pub args: Vec<String>,
16    pub env: HashMap<String, String>,
17}
18
19/// Controls whether backend-supplied MCP servers are merged with ambient config
20/// or treated as the explicit MCP set for the session.
21///
22/// Backends differ in how faithfully they can implement `ExplicitOnly`:
23/// - Claude maps to the native `--strict-mcp-config` flag.
24/// - OpenCode isolates ambient config roots and supplies only the Chimera
25///   config blob; auth/session data remains visible.
26/// - Codex uses an isolated `CODEX_HOME` plus explicit overrides, but upstream
27///   still merges repo-local `.codex` config, so this is best-effort there.
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum McpConfigMode {
31    #[default]
32    Merge,
33    ExplicitOnly,
34}
35
36#[derive(Debug, Clone, Builder, Serialize, Deserialize)]
37pub struct SessionConfig<C> {
38    #[builder(into)]
39    pub model: Option<String>,
40
41    pub cwd: Option<PathBuf>,
42
43    #[builder(default)]
44    pub additional_dirs: Vec<PathBuf>,
45
46    #[builder(default)]
47    pub env: HashMap<String, String>,
48
49    /// Inherited environment variables to remove from the subprocess.
50    /// Only meaningful when the subprocess inherits the parent environment.
51    #[builder(default)]
52    pub env_remove: HashSet<String>,
53
54    pub max_turns: Option<u32>,
55
56    #[builder(into)]
57    pub system_prompt: Option<String>,
58
59    pub backend: C,
60}
61
62impl<C> SessionConfig<C> {
63    /// Swap the backend config type, preserving shared fields.
64    pub fn with_backend<D>(self, backend: D) -> SessionConfig<D> {
65        self.map_backend(|_| backend)
66    }
67
68    /// Transform the backend config, preserving shared fields.
69    pub fn map_backend<D>(self, f: impl FnOnce(C) -> D) -> SessionConfig<D> {
70        let SessionConfig {
71            backend,
72            model,
73            cwd,
74            additional_dirs,
75            env,
76            env_remove,
77            max_turns,
78            system_prompt,
79        } = self;
80        SessionConfig {
81            backend: f(backend),
82            model,
83            cwd,
84            additional_dirs,
85            env,
86            env_remove,
87            max_turns,
88            system_prompt,
89        }
90    }
91
92    /// Transform the backend config fallibly, preserving shared fields.
93    pub fn try_map_backend<D, E>(
94        self,
95        f: impl FnOnce(C) -> std::result::Result<D, E>,
96    ) -> std::result::Result<SessionConfig<D>, E> {
97        let SessionConfig {
98            backend,
99            model,
100            cwd,
101            additional_dirs,
102            env,
103            env_remove,
104            max_turns,
105            system_prompt,
106        } = self;
107        Ok(SessionConfig {
108            backend: f(backend)?,
109            model,
110            cwd,
111            additional_dirs,
112            env,
113            env_remove,
114            max_turns,
115            system_prompt,
116        })
117    }
118}
119
120/// Backend-agnostic tool definition (maps to OpenAI function-calling or equivalent).
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ToolDefinition {
123    /// Function name the model must call.
124    pub name: String,
125    /// Human-readable description shown to the model.
126    pub description: String,
127    /// JSON Schema object describing the function's parameters.
128    pub parameters: serde_json::Value,
129}
130
131/// Controls which tool (if any) the model is required to call.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum ToolChoice {
135    /// Model decides whether to call a tool.
136    Auto,
137    /// Model must call at least one tool.
138    Required,
139    /// Model must not call any tools.
140    None,
141    /// Model must call this specific function.
142    Function(String),
143}
144
145#[derive(Debug, Clone, Default, Builder, Serialize, Deserialize)]
146pub struct TurnOptions {
147    pub output_schema: Option<serde_json::Value>,
148    /// Maximum duration for this turn. If exceeded, the subprocess is killed
149    /// and `AgentError::Timeout` is returned.
150    #[serde(default)]
151    pub timeout: Option<std::time::Duration>,
152    /// Tools available to the model for this turn.
153    #[builder(default)]
154    #[serde(default)]
155    pub tools: Vec<ToolDefinition>,
156    /// Tool selection policy. `None` lets the backend decide.
157    pub tool_choice: Option<ToolChoice>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161#[non_exhaustive]
162pub enum Input {
163    Text(String),
164    Structured(Vec<InputPart>),
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[non_exhaustive]
169pub enum InputPart {
170    Text(String),
171    Image(PathBuf),
172}
173
174impl From<String> for Input {
175    fn from(s: String) -> Self {
176        Input::Text(s)
177    }
178}
179
180impl From<&str> for Input {
181    fn from(s: &str) -> Self {
182        Input::Text(s.to_owned())
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn session_config_builder() {
192        let config = SessionConfig::builder()
193            .model("gpt-4")
194            .cwd(PathBuf::from("/tmp"))
195            .backend("test-backend")
196            .build();
197
198        assert_eq!(config.model.as_deref(), Some("gpt-4"));
199        assert_eq!(config.cwd, Some(PathBuf::from("/tmp")));
200        assert_eq!(config.backend, "test-backend");
201        assert!(config.additional_dirs.is_empty());
202        assert!(config.env.is_empty());
203    }
204
205    #[test]
206    fn with_backend_preserves_shared_fields() {
207        let config = SessionConfig::builder()
208            .model("gpt-4")
209            .cwd(PathBuf::from("/tmp"))
210            .max_turns(5)
211            .backend("original")
212            .build();
213
214        let swapped = config.with_backend(42u32);
215
216        assert_eq!(swapped.model.as_deref(), Some("gpt-4"));
217        assert_eq!(swapped.cwd, Some(PathBuf::from("/tmp")));
218        assert_eq!(swapped.max_turns, Some(5));
219        assert_eq!(swapped.backend, 42);
220    }
221
222    #[test]
223    fn map_backend_transforms() {
224        let config = SessionConfig::builder()
225            .model("gpt-4")
226            .backend("hello")
227            .build();
228
229        let mapped = config.map_backend(|s: &str| s.len());
230
231        assert_eq!(mapped.model.as_deref(), Some("gpt-4"));
232        assert_eq!(mapped.backend, 5);
233    }
234
235    #[test]
236    fn mcp_config_mode_defaults_to_merge() {
237        assert_eq!(McpConfigMode::default(), McpConfigMode::Merge);
238    }
239
240    #[test]
241    fn try_map_backend_ok() {
242        let mut env = HashMap::new();
243        env.insert("KEY".into(), "VAL".into());
244
245        let config = SessionConfig::builder()
246            .model("gpt-4")
247            .cwd(PathBuf::from("/tmp"))
248            .additional_dirs(vec![PathBuf::from("/extra")])
249            .env(env)
250            .max_turns(10)
251            .system_prompt("be helpful")
252            .backend(42u32)
253            .build();
254
255        let result: std::result::Result<SessionConfig<String>, &str> =
256            config.try_map_backend(|n| Ok(n.to_string()));
257
258        let mapped = result.unwrap();
259        assert_eq!(mapped.backend, "42");
260        assert_eq!(mapped.model.as_deref(), Some("gpt-4"));
261        assert_eq!(mapped.cwd, Some(PathBuf::from("/tmp")));
262        assert_eq!(mapped.additional_dirs, vec![PathBuf::from("/extra")]);
263        assert_eq!(mapped.env.get("KEY").unwrap(), "VAL");
264        assert_eq!(mapped.max_turns, Some(10));
265        assert_eq!(mapped.system_prompt.as_deref(), Some("be helpful"));
266    }
267
268    #[test]
269    fn try_map_backend_err() {
270        let config = SessionConfig::builder().backend(42u32).build();
271
272        let result: std::result::Result<SessionConfig<String>, &str> =
273            config.try_map_backend(|_| Err("mismatch"));
274
275        assert_eq!(result.unwrap_err(), "mismatch");
276    }
277
278    #[test]
279    fn env_remove_preserved_through_map_backend() {
280        let config = SessionConfig::builder()
281            .env_remove(HashSet::from(["SECRET".into()]))
282            .backend("original")
283            .build();
284
285        let mapped = config.map_backend(|s: &str| s.len());
286
287        assert!(mapped.env_remove.contains("SECRET"));
288        assert_eq!(mapped.backend, 8);
289    }
290
291    #[test]
292    fn env_remove_preserved_through_try_map_backend() {
293        let config = SessionConfig::builder()
294            .env_remove(HashSet::from(["KEY".into()]))
295            .backend(42u32)
296            .build();
297
298        let result: std::result::Result<SessionConfig<String>, &str> =
299            config.try_map_backend(|n| Ok(n.to_string()));
300
301        let mapped = result.unwrap();
302        assert!(mapped.env_remove.contains("KEY"));
303    }
304
305    #[test]
306    fn input_from_str() {
307        let input: Input = "hello".into();
308        match input {
309            Input::Text(s) => assert_eq!(s, "hello"),
310            _ => panic!("expected Text variant"),
311        }
312    }
313
314    #[test]
315    fn input_from_string() {
316        let input: Input = String::from("hello").into();
317        match input {
318            Input::Text(s) => assert_eq!(s, "hello"),
319            _ => panic!("expected Text variant"),
320        }
321    }
322}