capo-agent 0.10.1

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

//! Structured user settings (`~/.capo/agent/settings.toml`).
//!
//! TOML is the current on-disk format for capo's editable settings. The
//! loader still accepts legacy `settings.json` when no TOML file exists.
//! Sections beyond the Phase 0 baseline (e.g. `ui.theme` variants,
//! `logging` rotation) are reserved for M4+ and intentionally minimal here.

mod cli;
mod load;

pub use cli::CliOverrides;
pub use load::{load, load_with};

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Settings {
    #[serde(default)]
    pub model: ModelSettings,
    #[serde(default)]
    pub anthropic: AnthropicSettings,
    #[serde(default)]
    pub ui: UiSettings,
    #[serde(default)]
    pub session: SessionSettings,
    #[serde(default)]
    pub logging: LoggingSettings,
    /// v0.10 Phase A: permission mode picker default. Top-level
    /// `[permissions]` section in settings.toml. See spec §3.
    #[serde(default)]
    pub permissions: PermissionsSettings,
}

impl Settings {
    pub fn load(cli: &CliOverrides) -> crate::Result<Self> {
        load::load(cli)
    }

    pub fn load_with<F>(
        agent_dir: &std::path::Path,
        cli: &CliOverrides,
        env_lookup: F,
    ) -> crate::Result<Self>
    where
        F: Fn(&str) -> Option<String>,
    {
        load::load_with(agent_dir, cli, env_lookup)
    }
}

/// Supported LLM providers. `Settings::model.provider` is a string for
/// JSON-friendliness; this enum is the parsed form.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmProviderKind {
    /// Direct HTTPS call to `api.anthropic.com`. Requires an API key
    /// (env `ANTHROPIC_API_KEY` or `auth.json::anthropic.key`).
    Anthropic,
    /// Shells out to the locally-installed `claude` binary
    /// (Claude Code CLI). The CLI handles its own authentication;
    /// capo's `Auth` is not consulted. Requires `claude` on `$PATH`.
    ClaudeCode,
    /// Shells out to the locally-installed `codex` binary
    /// (OpenAI Codex CLI). The CLI handles its own authentication;
    /// capo's `Auth` is not consulted. Requires `codex` on `$PATH`.
    CodexCli,
    /// Direct HTTPS call to OpenAI's API. Requires an API key
    /// (env `OPENAI_API_KEY` or `auth.json::openai.key`).
    Openai,
    /// Direct HTTPS call to Google's Gemini REST API. Requires an API key
    /// (env `GEMINI_API_KEY` or `auth.json::gemini.key`).
    Gemini,
    /// Shells out to the locally-installed `gemini` binary (Google's
    /// Gemini CLI). The CLI handles its own authentication; capo's `Auth`
    /// is not consulted. Requires `gemini` on `$PATH`.
    GeminiCli,
}

impl LlmProviderKind {
    /// Parse a `Settings::model.provider` string. Case-insensitive.
    pub fn parse(s: &str) -> Result<Self, String> {
        match s.trim().to_ascii_lowercase().as_str() {
            "anthropic" => Ok(Self::Anthropic),
            "claude-code" | "claude_code" => Ok(Self::ClaudeCode),
            "codex-cli" | "codex_cli" => Ok(Self::CodexCli),
            "openai" => Ok(Self::Openai),
            "gemini" => Ok(Self::Gemini),
            "gemini-cli" | "gemini_cli" => Ok(Self::GeminiCli),
            other => Err(format!(
                "unknown LLM provider {other:?}; expected one of: anthropic, claude-code, codex-cli, openai, gemini, gemini-cli"
            )),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelSettings {
    /// Provider tag. One of: `"anthropic"`, `"claude-code"`, `"codex-cli"`,
    /// `"openai"`, `"gemini"`, `"gemini-cli"`.
    /// See [`LlmProviderKind`] for semantics. Validated at LLM construction
    /// time (not at settings deserialize) so a forgotten provider only
    /// blocks the user when they actually launch capo.
    pub provider: String,
    pub name: String,
    pub max_tokens: u32,
}

impl Default for ModelSettings {
    fn default() -> Self {
        Self {
            provider: "anthropic".to_string(),
            name: "claude-sonnet-4-6".to_string(),
            max_tokens: 8192,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AnthropicSettings {
    /// Base URL for the Anthropic HTTP API. Defaults to the public endpoint;
    /// override via `CAPO_ANTHROPIC_BASE_URL` env or `anthropic.base_url` in
    /// `settings.json` to route through a corporate proxy or test server.
    /// Only consulted when `model.provider == "anthropic"` (CLI providers
    /// shell out to their own binaries and ignore this).
    pub base_url: String,
}

impl Default for AnthropicSettings {
    fn default() -> Self {
        Self {
            base_url: "https://api.anthropic.com".to_string(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UiSettings {
    pub theme: String,
    pub streaming_throttle_ms: u32,
    pub footer_show_cost: bool,
    /// v0.9 Phase A: hide MessageBlock::Thinking entirely from rendering.
    /// Default true — most users don't want to read the model's reasoning.
    #[serde(default = "default_hide_thinking_blocks")]
    pub hide_thinking_blocks: bool,
    /// v0.9 Phase A: collapse ToolResult output to "tool ✓ (N lines)" 1-liner.
    /// Default true — keeps transcript scannable.
    #[serde(default = "default_collapse_tool_output_by_default")]
    pub collapse_tool_output_by_default: bool,
    /// v0.9 Phase A: collapse tool-call args when pretty-printed > N lines.
    /// 0 disables. Default 2.
    #[serde(default = "default_collapse_tool_args_max_lines")]
    pub collapse_tool_args_max_lines: u32,
    /// v0.9 Phase A: collapse assistant messages older than N from the end.
    /// 0 disables (opt-in). Default 0.
    #[serde(default = "default_collapse_history_after")]
    pub collapse_history_after: u32,
}

fn default_hide_thinking_blocks() -> bool {
    true
}

fn default_collapse_tool_output_by_default() -> bool {
    true
}

fn default_collapse_tool_args_max_lines() -> u32 {
    2
}

fn default_collapse_history_after() -> u32 {
    0
}

impl Default for UiSettings {
    fn default() -> Self {
        Self {
            theme: "dark".to_string(),
            streaming_throttle_ms: 50,
            footer_show_cost: true,
            hide_thinking_blocks: default_hide_thinking_blocks(),
            collapse_tool_output_by_default: default_collapse_tool_output_by_default(),
            collapse_tool_args_max_lines: default_collapse_tool_args_max_lines(),
            collapse_history_after: default_collapse_history_after(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionSettings {
    pub autosave: bool,
    /// Fraction of context window (0.0..=1.0) at which autocompact triggers.
    /// Name keeps `_pct` for spec/JSON-config compatibility, but the value is
    /// always interpreted as a 0.0–1.0 fraction. `0.0` disables autocompact.
    pub compact_at_context_pct: f32,
    /// Total LLM context window in tokens. Used by `AutocompactExtension`
    /// to compute the absolute compaction threshold. Default 200_000
    /// matches motosan-agent-loop's `AutocompactConfig::default`. **Override
    /// for newer models** (Sonnet 4.5+ supports 1_000_000).
    pub max_context_tokens: usize,
    /// Number of recent user-turn boundaries kept verbatim during compaction.
    pub keep_turns: usize,
}

impl Default for SessionSettings {
    fn default() -> Self {
        Self {
            autosave: true,
            compact_at_context_pct: 0.85,
            max_context_tokens: 1_000_000,
            keep_turns: 3,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PermissionsSettings {
    /// Permission mode for new sessions. One of `"bypass"`, `"accept-edits"`,
    /// `"prompt"`. Mismatched values fall through to `Settings::default()`
    /// handling (the binary surfaces a parse error at load time, then
    /// falls back to the type-level default — `"prompt"`).
    ///
    /// Edit via `/settings` or directly via `~/.capo/agent/settings.toml`.
    ///
    /// **NOT to be confused with** `~/.capo/agent/permissions.toml` (a
    /// separate file holding the `[bash]`/`[write]`/`[edit]` allowlist —
    /// covered by the existing `Policy` loader). The `[permissions]`
    /// section here ONLY carries `default_mode`.
    #[serde(default = "default_permission_mode_str")]
    pub default_mode: String,
}

impl Default for PermissionsSettings {
    fn default() -> Self {
        Self {
            default_mode: default_permission_mode_str(),
        }
    }
}

fn default_permission_mode_str() -> String {
    "prompt".to_string()
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LoggingSettings {
    pub level: String,
    pub file: String,
}

impl Default for LoggingSettings {
    fn default() -> Self {
        Self {
            level: "info".to_string(),
            file: "~/.capo/agent/capo.log".to_string(),
        }
    }
}

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

    #[test]
    fn settings_default_matches_documented_baseline() {
        let s = Settings::default();
        assert_eq!(s.model.provider, "anthropic");
        assert_eq!(s.model.name, "claude-sonnet-4-6");
        assert_eq!(s.model.max_tokens, 8192);
        assert_eq!(s.anthropic.base_url, "https://api.anthropic.com");
        assert_eq!(s.ui.theme, "dark");
        assert_eq!(s.ui.streaming_throttle_ms, 50);
        assert!(s.ui.footer_show_cost);
        assert!(s.session.autosave);
        assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
        assert_eq!(s.session.max_context_tokens, 1_000_000);
        assert_eq!(s.session.keep_turns, 3);
        assert_eq!(s.logging.level, "info");
        assert_eq!(s.logging.file, "~/.capo/agent/capo.log");
    }

    #[test]
    fn settings_serde_round_trips() {
        let original = Settings::default();
        let json = match serde_json::to_string_pretty(&original) {
            Ok(json) => json,
            Err(err) => panic!("serialize failed: {err}"),
        };
        let back: Settings = match serde_json::from_str(&json) {
            Ok(settings) => settings,
            Err(err) => panic!("deserialize failed: {err}"),
        };
        assert_eq!(original, back);
    }

    #[test]
    fn settings_loads_partial_json_with_defaults_for_missing_sections() {
        let json = r#"{ "model": { "provider": "anthropic", "name": "x", "max_tokens": 4096 } }"#;
        let s: Settings = match serde_json::from_str(json) {
            Ok(settings) => settings,
            Err(err) => panic!("deserialize failed: {err}"),
        };
        assert_eq!(s.model.name, "x");
        // Missing sections fall back to defaults.
        assert_eq!(s.ui.theme, "dark");
        assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
    }

    #[test]
    fn settings_v0_10_permissions_default_mode_defaults_to_prompt() {
        let s = Settings::default();
        assert_eq!(s.permissions.default_mode, "prompt");
    }

    #[test]
    fn settings_v0_10_permissions_section_round_trips() {
        let mut s = Settings::default();
        s.permissions.default_mode = "bypass".into();
        let toml_str = toml::to_string(&s).expect("serialize");
        assert!(toml_str.contains("default_mode = \"bypass\""));
        let back: Settings = toml::from_str(&toml_str).expect("deserialize");
        assert_eq!(back.permissions.default_mode, "bypass");
    }

    #[test]
    fn settings_v0_10_missing_permissions_section_uses_default() {
        // Existing v0.9 settings.toml has no [permissions] — must still load.
        let toml_str = r#"
[model]
provider = "anthropic"
name = "claude-opus-4-7"
max_tokens = 8192
"#;
        let s: Settings = toml::from_str(toml_str).expect("load partial");
        assert_eq!(s.permissions.default_mode, "prompt");
    }

    #[test]
    fn ui_settings_v0_9_defaults() {
        let s = UiSettings::default();
        assert!(
            s.hide_thinking_blocks,
            "hide_thinking_blocks should default to true"
        );
        assert!(
            s.collapse_tool_output_by_default,
            "collapse_tool_output_by_default should default to true"
        );
        assert_eq!(s.collapse_tool_args_max_lines, 2);
        assert_eq!(s.collapse_history_after, 0);
    }

    #[test]
    fn ui_settings_v0_9_round_trips_through_toml() {
        let mut s = Settings::default();
        s.ui.hide_thinking_blocks = false;
        s.ui.collapse_tool_output_by_default = false;
        s.ui.collapse_tool_args_max_lines = 5;
        s.ui.collapse_history_after = 10;
        let toml_str = toml::to_string(&s).expect("serialize");
        // Smoke: payload contains the new fields.
        assert!(toml_str.contains("hide_thinking_blocks = false"));
        assert!(toml_str.contains("collapse_tool_output_by_default = false"));
        assert!(toml_str.contains("collapse_tool_args_max_lines = 5"));
        assert!(toml_str.contains("collapse_history_after = 10"));
        let back: Settings = toml::from_str(&toml_str).expect("deserialize");
        assert!(!back.ui.hide_thinking_blocks);
        assert!(!back.ui.collapse_tool_output_by_default);
        assert_eq!(back.ui.collapse_tool_args_max_lines, 5);
        assert_eq!(back.ui.collapse_history_after, 10);
    }

    fn parse_ok(input: &str) -> LlmProviderKind {
        match LlmProviderKind::parse(input) {
            Ok(kind) => kind,
            Err(err) => panic!("parse failed for {input}: {err}"),
        }
    }

    fn parse_err(input: &str) -> String {
        match LlmProviderKind::parse(input) {
            Ok(kind) => panic!("parse should have failed for {input}: {kind:?}"),
            Err(err) => err,
        }
    }

    #[test]
    fn llm_provider_kind_parses_all_three() {
        assert_eq!(parse_ok("anthropic"), LlmProviderKind::Anthropic);
        assert_eq!(parse_ok("claude-code"), LlmProviderKind::ClaudeCode);
        assert_eq!(parse_ok("claude_code"), LlmProviderKind::ClaudeCode);
        assert_eq!(parse_ok("codex-cli"), LlmProviderKind::CodexCli);
        assert_eq!(parse_ok("CODEX-CLI"), LlmProviderKind::CodexCli);
    }

    #[test]
    fn llm_provider_kind_rejects_unknown() {
        let err = parse_err("gpt-4");
        assert!(err.contains("unknown LLM provider"));
        assert!(err.contains("gpt-4"));
        assert!(err.contains("anthropic"));
    }

    #[test]
    fn parse_recognizes_v0_5_providers() {
        assert!(matches!(parse_ok("openai"), LlmProviderKind::Openai));
        assert!(matches!(parse_ok("gemini"), LlmProviderKind::Gemini));
        assert!(matches!(parse_ok("gemini-cli"), LlmProviderKind::GeminiCli));
        assert!(matches!(parse_ok("gemini_cli"), LlmProviderKind::GeminiCli));
    }

    #[test]
    fn parse_unknown_provider_lists_all_six_supported_names() {
        let err = parse_err("nope");
        for name in [
            "anthropic",
            "claude-code",
            "codex-cli",
            "openai",
            "gemini",
            "gemini-cli",
        ] {
            assert!(err.contains(name), "{name} missing from: {err}");
        }
    }
}