embacle/copilot_common.rs
1// ABOUTME: Types and helpers shared by every Copilot provider: the permission policy, the turn API
2// ABOUTME: (tool observations, aggregated responses, the streaming event), history rendering, token lookup.
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! The turn API both Copilot providers implement.
8//!
9//! Two providers reach GitHub's Copilot runtime — one through the `copilot
10//! --acp` adapter, one through `github-copilot-sdk` — and the host speaks to
11//! both through the same turn API. Everything here is transport-neutral and
12//! compiles without either feature, so a consumer can name the trait and its
13//! types regardless of which provider it links.
14
15use std::env;
16use std::pin::Pin;
17
18use async_trait::async_trait;
19use serde_json::Value;
20use tokio_stream::Stream;
21
22use crate::types::{ChatMessage, ChatRequest, LlmProvider, MessageRole, RunnerError, TokenUsage};
23
24/// Policy for handling permission requests from the Copilot runtime.
25///
26/// Controls whether tool-execution permission prompts are auto-approved or denied.
27///
28/// **Denies by default.** The runtime's own tools — shell, git, file editing —
29/// run with the host's environment and credentials, in the session working
30/// directory (a scratch directory unless the host configures one), so a host
31/// that builds its prompt from untrusted input (an end user's chat text, say)
32/// turns an approval into arbitrary execution next to its secrets. That is not a
33/// hypothetical: a Dravr coaching turn was observed making five `shell`/`bash`/
34/// `Grep`/`Glob` calls during a user's request, because the default approved them.
35///
36/// A host that genuinely wants the runtime to run tools must now say so
37/// explicitly with [`PermissionPolicy::AutoApprove`]. Every current consumer
38/// wants denial, and the safe value should not depend on each one remembering to
39/// set an env var.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum PermissionPolicy {
42 /// Deny all permission requests by cancelling them.
43 #[default]
44 DenyAll,
45 /// Automatically approve permission requests by selecting the best allow option.
46 ///
47 /// Only safe when the prompt is fully trusted — never when it is assembled
48 /// from end-user input.
49 AutoApprove,
50}
51
52impl PermissionPolicy {
53 /// Parse the value of a `*_PERMISSION_POLICY` environment variable.
54 ///
55 /// Approval is opt-in and must be spelled out. An unset — or misspelled —
56 /// value denies, so a typo degrades to the safe side rather than silently
57 /// handing the runtime a shell.
58 #[must_use]
59 pub fn parse(raw: &str) -> Self {
60 match raw.to_lowercase().as_str() {
61 "auto_approve" | "autoapprove" | "approve" => Self::AutoApprove,
62 _ => Self::DenyAll,
63 }
64 }
65}
66
67/// Default number of conversation history turns rendered into a prompt.
68/// Each "turn" is one user or assistant message.
69pub const DEFAULT_MAX_HISTORY_TURNS: usize = 20;
70
71/// The GitHub token the Copilot runtime authenticates with, from the
72/// environment, in the runtime's own precedence order:
73/// `COPILOT_GITHUB_TOKEN`, then `GH_TOKEN`, then `GITHUB_TOKEN`.
74#[must_use]
75pub fn resolve_github_token() -> Option<String> {
76 env::var("COPILOT_GITHUB_TOKEN")
77 .or_else(|_| env::var("GH_TOKEN"))
78 .or_else(|_| env::var("GITHUB_TOKEN"))
79 .ok()
80}
81
82/// A tool call observed during a turn.
83///
84/// `id`, `title` and `status` are what every transport reports. `name`,
85/// `arguments` and `result` are what the SDK transport reports and the ACP
86/// adapter does not — it carries a display title and a status, nothing the
87/// host could persist as a tool round.
88#[derive(Debug, Clone, Default)]
89pub struct ObservedToolCall {
90 /// Tool call ID as the runtime assigned it.
91 pub id: String,
92 /// Human-readable title describing the tool action.
93 pub title: String,
94 /// Execution status (e.g., "Pending", "`InProgress`", "Completed", "Failed").
95 pub status: String,
96 /// The tool's registered name, when the transport carries it.
97 pub name: Option<String>,
98 /// The arguments the model passed, when the transport carries them.
99 pub arguments: Option<Value>,
100 /// The text result handed back to the model, when the transport carries it.
101 pub result: Option<String>,
102}
103
104/// Response from a conversation turn including tool execution metadata.
105#[derive(Debug, Clone)]
106pub struct HeadlessToolResponse {
107 /// Final assistant response content.
108 pub content: String,
109 /// Model that generated the response.
110 pub model: String,
111 /// Tool calls observed during the turn.
112 pub tool_calls: Vec<ObservedToolCall>,
113 /// Token usage for this turn.
114 pub usage: Option<TokenUsage>,
115 /// Finish reason.
116 pub finish_reason: Option<String>,
117}
118
119/// Event emitted by [`HeadlessTurnProvider::converse_stream`] as the turn
120/// progresses.
121///
122/// Unlike [`StreamChunk`](crate::types::StreamChunk) (which only carries text
123/// deltas), this enum surfaces tool-call observations alongside text —
124/// keeping the rich metadata that [`HeadlessTurnProvider::converse`] returns
125/// at the end of the turn while delivering it incrementally.
126#[derive(Debug, Clone)]
127pub enum HeadlessStreamEvent {
128 /// Partial assistant text — the next chunk to append to the
129 /// in-flight assistant message.
130 TextDelta(String),
131 /// A tool call was observed (start or status update). Each event
132 /// is a snapshot of the tool call's latest known state, so a
133 /// consumer can either accumulate updates or replace by id.
134 ToolCall(ObservedToolCall),
135 /// The turn has finished. Carries the aggregated
136 /// [`HeadlessToolResponse`] — same shape that
137 /// [`HeadlessTurnProvider::converse`] would have returned.
138 /// Always emitted as the last event before the stream closes
139 /// successfully.
140 Done(HeadlessToolResponse),
141}
142
143/// Stream of [`HeadlessStreamEvent`]s for a single converse turn.
144pub type HeadlessEventStream =
145 Pin<Box<dyn Stream<Item = Result<HeadlessStreamEvent, RunnerError>> + Send>>;
146
147/// A provider whose runtime executes tools itself during a turn.
148///
149/// [`LlmProvider::complete`] hides the tool activity inside a plain response;
150/// these two methods expose it, so a host that hands the runtime an MCP
151/// surface on each [`ChatRequest`] can observe what was called.
152#[async_trait]
153pub trait HeadlessTurnProvider: LlmProvider {
154 /// Run a conversation turn and return detailed results including tool call metadata.
155 async fn converse(&self, request: &ChatRequest) -> Result<HeadlessToolResponse, RunnerError>;
156
157 /// Run a conversation turn, streaming text deltas and tool-call
158 /// observations as they happen, ending with
159 /// [`HeadlessStreamEvent::Done`].
160 async fn converse_stream(
161 &self,
162 request: &ChatRequest,
163 ) -> Result<HeadlessEventStream, RunnerError>;
164}
165
166/// The system prompt of a request, if it carries one.
167///
168/// Only the first `System` message counts; the Copilot transports have one
169/// system slot each.
170#[must_use]
171pub fn system_prompt(request: &ChatRequest) -> Option<&str> {
172 request
173 .messages
174 .iter()
175 .find(|m| m.role == MessageRole::System)
176 .map(|m| m.content.as_str())
177}
178
179/// A turn rendered for a provider that opens a fresh session per call.
180#[derive(Debug)]
181pub struct RenderedTurn<'a> {
182 /// The prompt text: optional system text, the history block, the current
183 /// user message.
184 pub text: String,
185 /// The user message the turn answers, for attachments the text cannot
186 /// carry (images).
187 pub last_user: Option<&'a ChatMessage>,
188}
189
190/// Render a request into one prompt for a fresh session.
191///
192/// The host owns the conversation, so every request carries the whole
193/// history; a session that starts empty gets the prior turns serialized into
194/// a `<conversation-history>` block ahead of the current user message, capped
195/// to the most recent `max_history_turns` messages (`0` disables history).
196/// When `inline_system` is set the system prompt is written at the top of the
197/// text — for a transport with no system slot of its own; a transport that
198/// delivers the system prompt natively passes `false` so it is not sent twice.
199#[must_use]
200pub fn render_turn(
201 request: &ChatRequest,
202 max_history_turns: usize,
203 inline_system: bool,
204) -> RenderedTurn<'_> {
205 let non_system: Vec<&ChatMessage> = request
206 .messages
207 .iter()
208 .filter(|m| m.role != MessageRole::System)
209 .collect();
210
211 let (history, last_user) = if non_system.is_empty() {
212 (Vec::new(), None)
213 } else {
214 let last_idx = non_system.iter().rposition(|m| m.role == MessageRole::User);
215 match last_idx {
216 Some(idx) => {
217 let hist = non_system[..idx].to_vec();
218 (hist, Some(non_system[idx]))
219 }
220 None => (non_system, None),
221 }
222 };
223
224 let user_text = last_user.map(|m| m.content.as_str()).unwrap_or_default();
225
226 // Keep only the most recent turns.
227 let truncated_history = if max_history_turns == 0 || history.is_empty() {
228 &[][..]
229 } else if history.len() > max_history_turns {
230 &history[history.len() - max_history_turns..]
231 } else {
232 &history
233 };
234
235 let history_block = if truncated_history.is_empty() {
236 String::new()
237 } else {
238 let mut buf = String::from("<conversation-history>\n");
239 for msg in truncated_history {
240 let role_label = match msg.role {
241 MessageRole::User => "User",
242 MessageRole::Assistant => "Assistant",
243 MessageRole::Tool => "Tool",
244 MessageRole::System => continue,
245 };
246 buf.push_str(role_label);
247 buf.push_str(": ");
248 buf.push_str(&msg.content);
249 buf.push('\n');
250 }
251 buf.push_str("</conversation-history>\n\n");
252 buf
253 };
254
255 let mut text = String::new();
256 if inline_system {
257 if let Some(sys) = system_prompt(request) {
258 text.push_str(sys);
259 text.push_str("\n\n");
260 }
261 }
262 text.push_str(&history_block);
263 text.push_str(user_text);
264
265 RenderedTurn { text, last_user }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 fn request(messages: Vec<ChatMessage>) -> ChatRequest {
273 ChatRequest::new(messages)
274 }
275
276 #[test]
277 fn permission_policy_parse_denies_unless_spelled_out() {
278 assert_eq!(PermissionPolicy::parse(""), PermissionPolicy::DenyAll);
279 assert_eq!(
280 PermissionPolicy::parse("deny_all"),
281 PermissionPolicy::DenyAll
282 );
283 assert_eq!(
284 PermissionPolicy::parse("auto-approve"),
285 PermissionPolicy::DenyAll
286 );
287 assert_eq!(
288 PermissionPolicy::parse("auto_approve"),
289 PermissionPolicy::AutoApprove
290 );
291 assert_eq!(
292 PermissionPolicy::parse("APPROVE"),
293 PermissionPolicy::AutoApprove
294 );
295 }
296
297 #[test]
298 fn render_inlines_system_history_and_user_in_order() {
299 let req = request(vec![
300 ChatMessage::system("SYS"),
301 ChatMessage::user("first"),
302 ChatMessage::assistant("reply"),
303 ChatMessage::user("second"),
304 ]);
305 let turn = render_turn(&req, DEFAULT_MAX_HISTORY_TURNS, true);
306 assert_eq!(
307 turn.text,
308 "SYS\n\n<conversation-history>\nUser: first\nAssistant: reply\n</conversation-history>\n\nsecond"
309 );
310 assert_eq!(turn.last_user.map(|m| m.content.as_str()), Some("second"));
311 }
312
313 #[test]
314 fn render_without_inline_system_leaves_the_system_slot_to_the_transport() {
315 let req = request(vec![ChatMessage::system("SYS"), ChatMessage::user("hi")]);
316 let turn = render_turn(&req, DEFAULT_MAX_HISTORY_TURNS, false);
317 assert_eq!(turn.text, "hi");
318 assert_eq!(system_prompt(&req), Some("SYS"));
319 }
320
321 #[test]
322 fn render_caps_history_to_the_most_recent_turns() {
323 let req = request(vec![
324 ChatMessage::user("u1"),
325 ChatMessage::assistant("a1"),
326 ChatMessage::user("u2"),
327 ChatMessage::assistant("a2"),
328 ChatMessage::user("u3"),
329 ]);
330 let turn = render_turn(&req, 2, false);
331 assert_eq!(
332 turn.text,
333 "<conversation-history>\nUser: u2\nAssistant: a2\n</conversation-history>\n\nu3"
334 );
335 let none = render_turn(&req, 0, false);
336 assert_eq!(none.text, "u3");
337 }
338
339 #[test]
340 fn render_with_no_user_message_yields_history_only() {
341 let req = request(vec![ChatMessage::assistant("a1")]);
342 let turn = render_turn(&req, DEFAULT_MAX_HISTORY_TURNS, false);
343 assert_eq!(
344 turn.text,
345 "<conversation-history>\nAssistant: a1\n</conversation-history>\n\n"
346 );
347 assert!(turn.last_user.is_none());
348 }
349}