Skip to main content

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}