embacle 0.38.0

LLM runner library — 12 AI CLI tools, ACP, and HTTP APIs (Anthropic, Gemini, Cohere, Groq, OpenRouter, OpenAI-compatible) as pluggable LLM providers with fallback chains, agent loop, guardrails, and cost tracking
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
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
// ABOUTME: Types and helpers shared by every Copilot provider: the permission policy, the turn API
// ABOUTME: (tool observations, aggregated responses, the streaming event), history rendering, token lookup.
//
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 dravr.ai

//! The turn API both Copilot providers implement.
//!
//! Two providers reach GitHub's Copilot runtime — one through the `copilot
//! --acp` adapter, one through `github-copilot-sdk` — and the host speaks to
//! both through the same turn API. Everything here is transport-neutral and
//! compiles without either feature, so a consumer can name the trait and its
//! types regardless of which provider it links.

use std::env;
use std::pin::Pin;

use async_trait::async_trait;
use serde_json::Value;
use tokio_stream::Stream;

use crate::config::default_allowed_env_keys;
use crate::copilot::CREDENTIAL_ENV_KEYS;
use crate::types::{ChatMessage, ChatRequest, LlmProvider, MessageRole, RunnerError, TokenUsage};

/// Policy for handling permission requests from the Copilot runtime.
///
/// Controls whether tool-execution permission prompts are auto-approved or denied.
///
/// **Denies by default.** The runtime's own tools — shell, git, file editing —
/// run as the host's user, with the runtime's GitHub credential and the part
/// of the host's environment [`runtime_env_keys`] lets through, in the session
/// working directory (a scratch directory unless the host configures one), so a host
/// that builds its prompt from untrusted input (an end user's chat text, say)
/// turns an approval into arbitrary execution next to its secrets. That is not a
/// hypothetical: a Dravr coaching turn was observed making five `shell`/`bash`/
/// `Grep`/`Glob` calls during a user's request, because the default approved them.
///
/// A host that genuinely wants the runtime to run tools must now say so
/// explicitly with [`PermissionPolicy::AutoApprove`]. Every current consumer
/// wants denial, and the safe value should not depend on each one remembering to
/// set an env var.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PermissionPolicy {
    /// Deny all permission requests by cancelling them.
    #[default]
    DenyAll,
    /// Automatically approve permission requests by selecting the best allow option.
    ///
    /// Only safe when the prompt is fully trusted — never when it is assembled
    /// from end-user input.
    AutoApprove,
}

impl PermissionPolicy {
    /// Parse the value of a `*_PERMISSION_POLICY` environment variable.
    ///
    /// Approval is opt-in and must be spelled out. An unset — or misspelled —
    /// value denies, so a typo degrades to the safe side rather than silently
    /// handing the runtime a shell.
    #[must_use]
    pub fn parse(raw: &str) -> Self {
        match raw.to_lowercase().as_str() {
            "auto_approve" | "autoapprove" | "approve" => Self::AutoApprove,
            _ => Self::DenyAll,
        }
    }
}

/// The Copilot CLI flag that folds every MCP server's `initialize`
/// instructions into the model's SYSTEM prompt.
///
/// Without it the CLI folds only the instructions of servers on its own
/// allowlist, and a caller's server — handed over per call, never allowlisted —
/// is not one of them. The CLI has no `--system-prompt` flag, so this is the one
/// channel through which a caller's server instructions (an identity anchor,
/// say) reach the system layer rather than the prompt body, where the model
/// reads them as the user talking. Both CLI-spawning providers pass it: the
/// `-p` runner in `copilot.rs` and the `--acp` runner in `copilot_headless.rs`.
pub(crate) const ALLOW_ALL_MCP_SERVER_INSTRUCTIONS_FLAG: &str =
    "--allow-all-mcp-server-instructions";

/// Default number of conversation history turns rendered into a prompt.
/// Each "turn" is one user or assistant message.
pub const DEFAULT_MAX_HISTORY_TURNS: usize = 20;

/// The Copilot CLI's own variables that route a session to another provider.
///
/// They send a session to a model provider the host chooses instead of
/// GitHub, and keep it off GitHub altogether (`COPILOT_OFFLINE`). A host that
/// sets them means them for its Copilot runtime, so they reach it through the
/// [`runtime_env_keys`] allowlist.
pub const PROVIDER_ENV_KEYS: &[&str] = &[
    "COPILOT_PROVIDER_BASE_URL",
    "COPILOT_PROVIDER_TYPE",
    "COPILOT_PROVIDER_API_KEY",
    "COPILOT_PROVIDER_MAX_PROMPT_TOKENS",
    "COPILOT_OFFLINE",
];

/// The variables a Copilot runtime needs to reach the network as its host does.
///
/// The proxy, and the certificate authorities it trusts (the runtime is a Node
/// program, which reads `NODE_EXTRA_CA_CERTS`).
pub const NETWORK_ENV_KEYS: &[&str] = &[
    "HTTPS_PROXY",
    "https_proxy",
    "HTTP_PROXY",
    "http_proxy",
    "NO_PROXY",
    "no_proxy",
    "SSL_CERT_FILE",
    "SSL_CERT_DIR",
    "NODE_EXTRA_CA_CERTS",
];

/// The Copilot CLI's own variables that say where its package lives and how it
/// loads, and where it keeps its state.
///
/// A host image extracts the CLI's package once into a shared directory
/// (`COPILOT_PKG_CACHE_HOME`) and pins the version (`COPILOT_AUTO_UPDATE`);
/// without them every cold `copilot --acp` spawn re-extracts ~150 MB under
/// `HOME` and the loader may select another version. `COPILOT_HOME`, the XDG
/// base directories and `TMPDIR` place the runtime's config, state and scratch
/// files where the host put them. None of them carries a secret.
pub const CLI_RUNTIME_ENV_KEYS: &[&str] = &[
    "COPILOT_PKG_CACHE_HOME",
    "COPILOT_AUTO_UPDATE",
    "COPILOT_HOME",
    "XDG_CONFIG_HOME",
    "XDG_STATE_HOME",
    "XDG_CACHE_HOME",
    "XDG_DATA_HOME",
    "TMPDIR",
];

/// Every host variable a Copilot runtime child may inherit, and no other.
///
/// The child runs a model that can be steered by its prompt, and its tools
/// can read their own environment, so it gets what it needs to run and
/// nothing else of the host's: the [`default_allowed_env_keys`] every CLI
/// runner keeps, the GitHub credential variables it reads
/// ([`CREDENTIAL_ENV_KEYS`]), [`PROVIDER_ENV_KEYS`], [`NETWORK_ENV_KEYS`] and
/// [`CLI_RUNTIME_ENV_KEYS`]. A database URL or an encryption key in the host's environment never
/// reaches it. Both Copilot providers start their child from this one list.
#[must_use]
pub fn runtime_env_keys() -> Vec<String> {
    let mut keys = default_allowed_env_keys();
    for key in CREDENTIAL_ENV_KEYS
        .iter()
        .chain(PROVIDER_ENV_KEYS)
        .chain(NETWORK_ENV_KEYS)
        .chain(CLI_RUNTIME_ENV_KEYS)
    {
        if !keys.iter().any(|kept| kept == key) {
            keys.push((*key).to_owned());
        }
    }
    keys
}

/// The GitHub token the Copilot runtime authenticates with, from the
/// environment, in the runtime's own precedence order:
/// `COPILOT_GITHUB_TOKEN`, then `GH_TOKEN`, then `GITHUB_TOKEN`.
#[must_use]
pub fn resolve_github_token() -> Option<String> {
    env::var("COPILOT_GITHUB_TOKEN")
        .or_else(|_| env::var("GH_TOKEN"))
        .or_else(|_| env::var("GITHUB_TOKEN"))
        .ok()
}

/// A tool call observed during a turn.
///
/// `id`, `title` and `status` are what every transport reports. `name`,
/// `arguments` and `result` are what the SDK transport reports and the ACP
/// adapter does not — it carries a display title and a status, nothing the
/// host could persist as a tool round.
#[derive(Debug, Clone, Default)]
pub struct ObservedToolCall {
    /// Tool call ID as the runtime assigned it.
    pub id: String,
    /// Human-readable title describing the tool action.
    pub title: String,
    /// Execution status (e.g., "Pending", "`InProgress`", "Completed", "Failed").
    pub status: String,
    /// The tool's registered name, when the transport carries it.
    pub name: Option<String>,
    /// The arguments the model passed, when the transport carries them.
    pub arguments: Option<Value>,
    /// The text result handed back to the model, when the transport carries it.
    pub result: Option<String>,
}

/// Response from a conversation turn including tool execution metadata.
#[derive(Debug, Clone)]
pub struct HeadlessToolResponse {
    /// Final assistant response content.
    pub content: String,
    /// Model that generated the response.
    pub model: String,
    /// Tool calls observed during the turn.
    pub tool_calls: Vec<ObservedToolCall>,
    /// Token usage for this turn.
    pub usage: Option<TokenUsage>,
    /// Finish reason.
    pub finish_reason: Option<String>,
}

/// Event emitted by [`HeadlessTurnProvider::converse_stream`] as the turn
/// progresses.
///
/// Unlike [`StreamChunk`](crate::types::StreamChunk) (which only carries text
/// deltas), this enum surfaces tool-call observations alongside text —
/// keeping the rich metadata that [`HeadlessTurnProvider::converse`] returns
/// at the end of the turn while delivering it incrementally.
#[derive(Debug, Clone)]
pub enum HeadlessStreamEvent {
    /// Partial assistant text — the next chunk to append to the
    /// in-flight assistant message.
    TextDelta(String),
    /// A tool call was observed (start or status update). Each event
    /// is a snapshot of the tool call's latest known state, so a
    /// consumer can either accumulate updates or replace by id.
    ToolCall(ObservedToolCall),
    /// The turn has finished. Carries the aggregated
    /// [`HeadlessToolResponse`] — same shape that
    /// [`HeadlessTurnProvider::converse`] would have returned.
    /// Always emitted as the last event before the stream closes
    /// successfully.
    Done(HeadlessToolResponse),
}

/// Stream of [`HeadlessStreamEvent`]s for a single converse turn.
pub type HeadlessEventStream =
    Pin<Box<dyn Stream<Item = Result<HeadlessStreamEvent, RunnerError>> + Send>>;

/// A provider whose runtime executes tools itself during a turn.
///
/// [`LlmProvider::complete`] hides the tool activity inside a plain response;
/// these two methods expose it, so a host that hands the runtime an MCP
/// surface on each [`ChatRequest`] can observe what was called.
#[async_trait]
pub trait HeadlessTurnProvider: LlmProvider {
    /// Run a conversation turn and return detailed results including tool call metadata.
    async fn converse(&self, request: &ChatRequest) -> Result<HeadlessToolResponse, RunnerError>;

    /// Run a conversation turn, streaming text deltas and tool-call
    /// observations as they happen, ending with
    /// [`HeadlessStreamEvent::Done`].
    async fn converse_stream(
        &self,
        request: &ChatRequest,
    ) -> Result<HeadlessEventStream, RunnerError>;
}

/// The system prompt of a request, if it carries one.
///
/// Only the first `System` message counts; the Copilot transports have one
/// system slot each.
#[must_use]
pub fn system_prompt(request: &ChatRequest) -> Option<&str> {
    request
        .messages
        .iter()
        .find(|m| m.role == MessageRole::System)
        .map(|m| m.content.as_str())
}

/// A turn rendered for a provider that opens a fresh session per call.
#[derive(Debug)]
pub struct RenderedTurn<'a> {
    /// The prompt text: optional system text, the history block, the current
    /// user message.
    pub text: String,
    /// The user message the turn answers, for attachments the text cannot
    /// carry (images).
    pub last_user: Option<&'a ChatMessage>,
}

/// Render a request into one prompt for a fresh session.
///
/// The host owns the conversation, so every request carries the whole
/// history; a session that starts empty gets the prior turns serialized into
/// a `<conversation-history>` block ahead of the current user message, capped
/// to the most recent `max_history_turns` messages (`0` disables history).
/// When `inline_system` is set the system prompt is written at the top of the
/// text — for a transport with no system slot of its own; a transport that
/// delivers the system prompt natively passes `false` so it is not sent twice.
#[must_use]
pub fn render_turn(
    request: &ChatRequest,
    max_history_turns: usize,
    inline_system: bool,
) -> RenderedTurn<'_> {
    let non_system: Vec<&ChatMessage> = request
        .messages
        .iter()
        .filter(|m| m.role != MessageRole::System)
        .collect();

    let (history, last_user) = if non_system.is_empty() {
        (Vec::new(), None)
    } else {
        let last_idx = non_system.iter().rposition(|m| m.role == MessageRole::User);
        match last_idx {
            Some(idx) => {
                let hist = non_system[..idx].to_vec();
                (hist, Some(non_system[idx]))
            }
            None => (non_system, None),
        }
    };

    let user_text = last_user.map(|m| m.content.as_str()).unwrap_or_default();

    // Keep only the most recent turns.
    let truncated_history = if max_history_turns == 0 || history.is_empty() {
        &[][..]
    } else if history.len() > max_history_turns {
        &history[history.len() - max_history_turns..]
    } else {
        &history
    };

    let history_block = if truncated_history.is_empty() {
        String::new()
    } else {
        let mut buf = String::from("<conversation-history>\n");
        for msg in truncated_history {
            let role_label = match msg.role {
                MessageRole::User => "User",
                MessageRole::Assistant => "Assistant",
                MessageRole::Tool => "Tool",
                MessageRole::System => continue,
            };
            buf.push_str(role_label);
            buf.push_str(": ");
            buf.push_str(&msg.content);
            buf.push('\n');
        }
        buf.push_str("</conversation-history>\n\n");
        buf
    };

    let mut text = String::new();
    if inline_system {
        if let Some(sys) = system_prompt(request) {
            text.push_str(sys);
            text.push_str("\n\n");
        }
    }
    text.push_str(&history_block);
    text.push_str(user_text);

    RenderedTurn { text, last_user }
}

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

    fn request(messages: Vec<ChatMessage>) -> ChatRequest {
        ChatRequest::new(messages)
    }

    #[test]
    fn permission_policy_parse_denies_unless_spelled_out() {
        assert_eq!(PermissionPolicy::parse(""), PermissionPolicy::DenyAll);
        assert_eq!(
            PermissionPolicy::parse("deny_all"),
            PermissionPolicy::DenyAll
        );
        assert_eq!(
            PermissionPolicy::parse("auto-approve"),
            PermissionPolicy::DenyAll
        );
        assert_eq!(
            PermissionPolicy::parse("auto_approve"),
            PermissionPolicy::AutoApprove
        );
        assert_eq!(
            PermissionPolicy::parse("APPROVE"),
            PermissionPolicy::AutoApprove
        );
    }

    #[test]
    fn runtime_env_keys_keep_the_cli_package_cache_and_drop_host_secrets() {
        let keys = runtime_env_keys();
        for key in [
            "HOME",
            "PATH",
            "COPILOT_GITHUB_TOKEN",
            "COPILOT_PKG_CACHE_HOME",
            "COPILOT_AUTO_UPDATE",
            "COPILOT_HOME",
            "TMPDIR",
        ] {
            assert!(
                keys.iter().any(|k| k == key),
                "{key} must reach the runtime"
            );
        }
        for key in ["DATABASE_URL", "PIERRE_MASTER_ENCRYPTION_KEY"] {
            assert!(
                !keys.iter().any(|k| k == key),
                "{key} must not reach the runtime"
            );
        }
        let mut deduped = keys.clone();
        deduped.sort();
        deduped.dedup();
        assert_eq!(deduped.len(), keys.len(), "no key is listed twice");
    }

    #[test]
    fn render_inlines_system_history_and_user_in_order() {
        let req = request(vec![
            ChatMessage::system("SYS"),
            ChatMessage::user("first"),
            ChatMessage::assistant("reply"),
            ChatMessage::user("second"),
        ]);
        let turn = render_turn(&req, DEFAULT_MAX_HISTORY_TURNS, true);
        assert_eq!(
            turn.text,
            "SYS\n\n<conversation-history>\nUser: first\nAssistant: reply\n</conversation-history>\n\nsecond"
        );
        assert_eq!(turn.last_user.map(|m| m.content.as_str()), Some("second"));
    }

    #[test]
    fn render_without_inline_system_leaves_the_system_slot_to_the_transport() {
        let req = request(vec![ChatMessage::system("SYS"), ChatMessage::user("hi")]);
        let turn = render_turn(&req, DEFAULT_MAX_HISTORY_TURNS, false);
        assert_eq!(turn.text, "hi");
        assert_eq!(system_prompt(&req), Some("SYS"));
    }

    #[test]
    fn render_caps_history_to_the_most_recent_turns() {
        let req = request(vec![
            ChatMessage::user("u1"),
            ChatMessage::assistant("a1"),
            ChatMessage::user("u2"),
            ChatMessage::assistant("a2"),
            ChatMessage::user("u3"),
        ]);
        let turn = render_turn(&req, 2, false);
        assert_eq!(
            turn.text,
            "<conversation-history>\nUser: u2\nAssistant: a2\n</conversation-history>\n\nu3"
        );
        let none = render_turn(&req, 0, false);
        assert_eq!(none.text, "u3");
    }

    #[test]
    fn render_with_no_user_message_yields_history_only() {
        let req = request(vec![ChatMessage::assistant("a1")]);
        let turn = render_turn(&req, DEFAULT_MAX_HISTORY_TURNS, false);
        assert_eq!(
            turn.text,
            "<conversation-history>\nAssistant: a1\n</conversation-history>\n\n"
        );
        assert!(turn.last_user.is_none());
    }
}