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}