Skip to main content

embacle/
copilot_headless_config.rs

1// ABOUTME: Configuration for the Copilot Headless (ACP) provider.
2// ABOUTME: Reads environment variables and provides defaults for the ACP client.
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7use std::env;
8use std::path::PathBuf;
9
10use tracing::warn;
11
12use crate::copilot_common::{resolve_github_token, PermissionPolicy, DEFAULT_MAX_HISTORY_TURNS};
13use crate::copilot_models::{preferred_default, ReasoningEffort};
14
15/// Configuration for the Copilot Headless (ACP) provider.
16#[derive(Debug, Clone)]
17pub struct CopilotHeadlessConfig {
18    /// Override path to the copilot CLI binary (default: auto-detect via PATH).
19    pub cli_path: Option<PathBuf>,
20    /// Directory every `copilot --acp` subprocess runs in and the `cwd` of every
21    /// session it serves. Copilot treats that directory as the project — it
22    /// loads `.mcp.json`, agent files and custom instructions from it — so a
23    /// host that sets this hands the model that project. A relative path is
24    /// anchored to the host's cwd. Unset, the runner uses a scratch directory
25    /// under the system temp dir, not the host process's working directory.
26    pub working_directory: Option<PathBuf>,
27    /// Default model to use for completions.
28    pub model: String,
29    /// GitHub token for authentication (optional, uses stored OAuth by default).
30    pub github_token: Option<String>,
31    /// Policy for handling permission requests from the copilot subprocess.
32    pub permission_policy: PermissionPolicy,
33    /// Maximum number of prior conversation messages (user + assistant) to include
34    /// in the ACP prompt for multi-turn context. Set to 0 to disable history injection.
35    pub max_history_turns: usize,
36    /// Advertise `SDK_TOOL_CALLING` so callers route tool turns through the
37    /// ACP `converse()` loop with per-request MCP servers (native tool
38    /// calling), instead of falling through to text-based `<tool_call>`
39    /// parsing. The caller must pass `mcp_servers` on each request for tools
40    /// to be reachable. Default: false.
41    pub mcp_tool_calling: bool,
42    /// Reasoning effort every `copilot --acp` child is launched with
43    /// (`--reasoning-effort`). `None` takes the model's family default
44    /// ([`crate::copilot_models::default_effort_for`]); a model with no known
45    /// default gets no flag, and then the CLI uses the `effortLevel` in the
46    /// host user's `~/.copilot/settings.json`, which can be `high`.
47    ///
48    /// Reasoning is billed as output and is most of a short turn's time: one
49    /// snippet on claude-haiku-5.5 (CLI 1.0.108) took 5.8 s and 1,093 output
50    /// tokens, 895 of them reasoning, at `high`, and 0.9 s, 65 tokens and no
51    /// reasoning at `low`.
52    pub reasoning_effort: Option<ReasoningEffort>,
53    /// Keep the Copilot CLI's built-in tools (`bash`, `view`, `edit`, `glob`,
54    /// `grep`, `web_fetch`, ...) available to the model. Default: false.
55    ///
56    /// Off, the child is launched with `--available-tools mcp:* task_complete`:
57    /// the MCP servers a request brings stay callable, Autopilot keeps the tool
58    /// it ends a run with, and nothing else is offered. The built-ins' schemas
59    /// are most of what Copilot adds to every prompt: a plain completion
60    /// measured 21,149 prompt tokens with them and 4,003 without (CLI 1.0.108).
61    /// Under [`PermissionPolicy::DenyAll`] the model could only reach the ones
62    /// that need no permission, and those read the session directory.
63    pub builtin_tools: bool,
64}
65
66impl CopilotHeadlessConfig {
67    /// Create configuration from environment variables.
68    ///
69    /// Environment variables:
70    /// - `COPILOT_CLI_PATH` — Override path to copilot binary
71    /// - `COPILOT_HEADLESS_WORKING_DIR` — Directory the subprocess and its sessions
72    ///   run in (default: a scratch directory under the system temp dir)
73    /// - `COPILOT_HEADLESS_MODEL` — Default model (defaults to the top-ranked
74    ///   candidate in [`crate::copilot_models::CATALOG`])
75    /// - `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN` — GitHub auth token
76    /// - `COPILOT_HEADLESS_MAX_HISTORY_TURNS` — Max conversation history turns (default: 20)
77    /// - `COPILOT_HEADLESS_MCP_TOOL_CALLING` — Advertise `SDK_TOOL_CALLING` for native ACP MCP tool calling (default: false)
78    /// - `COPILOT_HEADLESS_REASONING_EFFORT` — `low`, `medium`, `high` or `xhigh`
79    ///   (default: the model's family default; an unknown value is ignored with a warning)
80    /// - `COPILOT_HEADLESS_BUILTIN_TOOLS` — Keep the CLI's built-in tools (default:
81    ///   true when the permission policy approves tool calls, false otherwise)
82    #[must_use]
83    pub fn from_env() -> Self {
84        let cli_path = env::var("COPILOT_CLI_PATH").ok().map(PathBuf::from);
85
86        // An empty value reads as unset, so `export COPILOT_HEADLESS_WORKING_DIR=`
87        // in a profile does not pin the subprocess to a relative "".
88        let working_directory = env::var("COPILOT_HEADLESS_WORKING_DIR")
89            .ok()
90            .filter(|v| !v.is_empty())
91            .map(PathBuf::from);
92
93        let model =
94            env::var("COPILOT_HEADLESS_MODEL").unwrap_or_else(|_| preferred_default().to_owned());
95
96        let github_token = resolve_github_token();
97
98        let permission_policy = PermissionPolicy::parse(
99            &env::var("COPILOT_HEADLESS_PERMISSION_POLICY").unwrap_or_default(),
100        );
101
102        let max_history_turns = env::var("COPILOT_HEADLESS_MAX_HISTORY_TURNS")
103            .ok()
104            .and_then(|v| v.parse::<usize>().ok())
105            .unwrap_or(DEFAULT_MAX_HISTORY_TURNS);
106
107        let mcp_tool_calling = env::var("COPILOT_HEADLESS_MCP_TOOL_CALLING")
108            .is_ok_and(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"));
109
110        let reasoning_effort = env::var("COPILOT_HEADLESS_REASONING_EFFORT")
111            .ok()
112            .filter(|v| !v.trim().is_empty())
113            .and_then(|v| {
114                let parsed = ReasoningEffort::parse(&v);
115                if parsed.is_none() {
116                    warn!(
117                        value = %v,
118                        "COPILOT_HEADLESS_REASONING_EFFORT is not low, medium, high or xhigh; ignored"
119                    );
120                }
121                parsed
122            });
123
124        // A host that approves tool calls asked for a runtime that acts, so it
125        // keeps the built-ins unless it says otherwise.
126        let builtin_tools = env::var("COPILOT_HEADLESS_BUILTIN_TOOLS")
127            .map_or(permission_policy == PermissionPolicy::AutoApprove, |v| {
128                matches!(v.to_lowercase().as_str(), "1" | "true" | "yes")
129            });
130
131        Self {
132            cli_path,
133            working_directory,
134            model,
135            github_token,
136            permission_policy,
137            max_history_turns,
138            mcp_tool_calling,
139            reasoning_effort,
140            builtin_tools,
141        }
142    }
143}
144
145impl Default for CopilotHeadlessConfig {
146    fn default() -> Self {
147        Self {
148            cli_path: None,
149            working_directory: None,
150            model: preferred_default().to_owned(),
151            github_token: None,
152            permission_policy: PermissionPolicy::default(),
153            max_history_turns: DEFAULT_MAX_HISTORY_TURNS,
154            mcp_tool_calling: false,
155            reasoning_effort: None,
156            builtin_tools: false,
157        }
158    }
159}