defect_agent/session/context.rs
1//! Environment context: factual information injected into the `# Environment` section of
2//! the system prompt.
3//!
4//! The [`RunningContext`] aggregate holds session-varying parts (access method, cwd),
5//! while platform/version/shell detection results that are invariant for the entire
6//! process are cached with [`OnceLock`] — `os_info::get()` reads files and runs probes,
7//! so recomputing it every turn is not worthwhile.
8
9use std::path::Path;
10use std::sync::OnceLock;
11
12/// How the agent is connected — determines its understanding of the file and command
13/// execution environment.
14///
15/// Note: The `defect` CLI binary itself runs as an ACP server over stdio; all real paths
16/// currently go through [`Frontend::Acp`]. [`Frontend::Cli`] and [`Frontend::Headless`]
17/// are variants reserved for future forms (bare CLI user, server backend service).
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Frontend {
20 /// Direct CLI interaction (reserved for a future local CLI user mode).
21 Cli,
22 /// Accessed via the ACP protocol (editor / IDE client).
23 ///
24 /// `fs_delegated` / `shell_delegated` come from the ACP `initialize` handshake
25 /// negotiation:
26 /// `true` means file I/O / command execution is delegated to the client proxy,
27 /// `false` means
28 /// executed locally. The agent uses this to know whether it is facing a local
29 /// environment or a remote proxy.
30 Acp {
31 fs_delegated: bool,
32 shell_delegated: bool,
33 },
34 /// Headless (reserved for a background service on the server).
35 Headless,
36}
37
38impl Frontend {
39 /// Whether the filesystem is delegated to the client proxy. Only true for
40 /// [`Frontend::Acp`] when `fs_delegated = true` is negotiated; all other variants
41 /// read and write directly on the local side.
42 fn fs_delegated(self) -> bool {
43 matches!(
44 self,
45 Self::Acp {
46 fs_delegated: true,
47 ..
48 }
49 )
50 }
51
52 /// A single-line description rendered into the `# Environment` section.
53 fn describe(self) -> String {
54 match self {
55 Self::Cli => "CLI".to_owned(),
56 Self::Acp {
57 fs_delegated,
58 shell_delegated,
59 } => format!(
60 "ACP (fs: {}, shell: {})",
61 delegation(fs_delegated),
62 delegation(shell_delegated),
63 ),
64 Self::Headless => "headless".to_owned(),
65 }
66 }
67}
68
69fn delegation(delegated: bool) -> &'static str {
70 if delegated { "delegated" } else { "local" }
71}
72
73/// Injects runtime environment context into the system prompt.
74///
75/// Static parts (platform, version, shell) are detected and cached internally by this
76/// type; callers only need to provide the session‑varying [`Frontend`] and `cwd`.
77pub struct RunningContext<'a> {
78 pub frontend: Frontend,
79 pub cwd: &'a Path,
80}
81
82impl<'a> RunningContext<'a> {
83 pub fn new(frontend: Frontend, cwd: &'a Path) -> Self {
84 Self { frontend, cwd }
85 }
86
87 /// Renders the body of the `# Environment` section (the title and separator are
88 /// handled by
89 /// [`crate::session::resolve_system_prompt`]).
90 pub fn render(&self) -> String {
91 let mut lines = Vec::with_capacity(6);
92 lines.push(format!("- platform: {}", platform_line()));
93 lines.push(format!("- defect version: {}", env!("CARGO_PKG_VERSION")));
94 lines.push(format!("- frontend: {}", self.frontend.describe()));
95 lines.push(format!("- cwd: {}", self.cwd.display()));
96 lines.push(format!("- shell: {}", shell_line()));
97 // The delegated filesystem (ACP) backchannel only supports text; `read_file` will
98 // fail on images.
99 // Explicitly instruct the model not to use `read_file` on images to avoid
100 // unnecessary error round-trips.
101 if self.frontend.fs_delegated() {
102 lines.push(
103 "- note: the filesystem is delegated and only supports text reads; \
104 do not use read_file on image or other binary files (it will fail)"
105 .to_owned(),
106 );
107 }
108 lines.join("\n")
109 }
110}
111
112/// Format: `linux / x86_64 (Ubuntu 22.04)`. OS/arch from compile-time `std` constants,
113/// distro and version from runtime `os_info` detection. Cached for the entire process.
114fn platform_line() -> &'static str {
115 static PLATFORM: OnceLock<String> = OnceLock::new();
116 PLATFORM.get_or_init(|| {
117 let info = os_info::get();
118 format!(
119 "{} / {} ({} {})",
120 std::env::consts::OS,
121 std::env::consts::ARCH,
122 info.os_type(),
123 info.version(),
124 )
125 })
126}
127
128/// Default shell: reads `$SHELL`, falls back to `unknown`. Cached for the process
129/// lifetime.
130fn shell_line() -> &'static str {
131 static SHELL: OnceLock<String> = OnceLock::new();
132 SHELL.get_or_init(|| std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_owned()))
133}
134
135#[cfg(test)]
136mod tests;