Skip to main content

nucel_agent_opencode/
lib.rs

1//! OpenCode provider — HTTP client to OpenCode server.
2//!
3//! OpenCode runs as a server (`opencode serve` on `:4096`). This provider
4//! connects to it via HTTP REST API.
5//!
6//! Supports:
7//! - Session creation and prompting
8//! - Multi-turn conversations
9//! - Session resume (native — returns the same OpenCode session id)
10//! - Basic-auth credentials (api_key → HTTP basic password)
11
12mod client;
13mod protocol;
14
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex};
17
18use async_trait::async_trait;
19
20use nucel_agent_core::{
21    AgentCapabilities, AgentCost, AgentError, AgentExecutor, AgentResponse, AgentSession,
22    AvailabilityStatus, ExecutorType, Result, SessionImpl, SpawnConfig,
23};
24
25use client::OpencodeClient;
26
27/// OpenCode executor — connects to OpenCode HTTP server.
28pub struct OpencodeExecutor {
29    base_url: String,
30    api_key: Option<String>,
31}
32
33impl OpencodeExecutor {
34    pub fn new() -> Self {
35        Self {
36            base_url: "http://127.0.0.1:4096".to_string(),
37            api_key: None,
38        }
39    }
40
41    pub fn with_base_url(base_url: impl Into<String>) -> Self {
42        Self {
43            base_url: base_url.into().trim_end_matches('/').to_string(),
44            api_key: None,
45        }
46    }
47
48    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
49        self.api_key = Some(api_key.into());
50        self
51    }
52
53    /// Build a client scoped to a given working dir. The underlying
54    /// `reqwest::Client` will pool HTTP keep-alive connections per executor
55    /// invocation (`spawn`, `resume`, and within a session's `query` loop —
56    /// see `OpenCodeSessionImpl::client`).
57    fn make_client(&self, working_dir: &Path) -> OpencodeClient {
58        OpencodeClient::new(
59            &self.base_url,
60            self.api_key.as_deref(),
61            working_dir.to_str(),
62        )
63    }
64}
65
66impl Default for OpencodeExecutor {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72/// Internal session implementation for OpenCode.
73struct OpenCodeSessionImpl {
74    cost: Arc<Mutex<AgentCost>>,
75    budget: f64,
76    /// One client per session — preserves HTTP keep-alive across queries.
77    client: OpencodeClient,
78    opencode_session_id: String,
79    config: SpawnConfig,
80}
81
82#[async_trait]
83impl SessionImpl for OpenCodeSessionImpl {
84    async fn query(&self, prompt: &str) -> Result<AgentResponse> {
85        {
86            let c = self.cost.lock().unwrap();
87            if c.total_usd >= self.budget {
88                return Err(AgentError::BudgetExceeded {
89                    limit: self.budget,
90                    spent: c.total_usd,
91                });
92            }
93        }
94
95        let resp = self
96            .client
97            .prompt(&self.opencode_session_id, prompt, &self.config, self.budget)
98            .await?;
99
100        {
101            let mut c = self.cost.lock().unwrap();
102            c.input_tokens += resp.cost.input_tokens;
103            c.output_tokens += resp.cost.output_tokens;
104            c.total_usd += resp.cost.total_usd;
105        }
106
107        Ok(resp)
108    }
109
110    async fn total_cost(&self) -> Result<AgentCost> {
111        Ok(self.cost.lock().unwrap().clone())
112    }
113
114    async fn close(&self) -> Result<()> {
115        // Best-effort abort of any in-flight server-side work.
116        self.client.abort(&self.opencode_session_id).await
117    }
118}
119
120#[async_trait]
121impl AgentExecutor for OpencodeExecutor {
122    fn executor_type(&self) -> ExecutorType {
123        ExecutorType::OpenCode
124    }
125
126    async fn spawn(
127        &self,
128        working_dir: &Path,
129        prompt: &str,
130        config: &SpawnConfig,
131    ) -> Result<AgentSession> {
132        let cost = Arc::new(Mutex::new(AgentCost::default()));
133        let budget = config.budget_usd.unwrap_or(f64::MAX);
134
135        if budget <= 0.0 {
136            return Err(AgentError::BudgetExceeded {
137                limit: budget,
138                spent: 0.0,
139            });
140        }
141
142        let client = self.make_client(working_dir);
143
144        // Create session on server.
145        let session_data = client.create_session().await?;
146        let opencode_session_id = session_data
147            .get("id")
148            .and_then(|v| v.as_str())
149            .ok_or_else(|| AgentError::Provider {
150                provider: "opencode".into(),
151                message: "session response missing id".into(),
152            })?
153            .to_string();
154
155        // Send first prompt — reusing the same client (HTTP keep-alive).
156        let response = client
157            .prompt(&opencode_session_id, prompt, config, budget)
158            .await?;
159
160        {
161            let mut c = cost.lock().unwrap();
162            *c = response.cost.clone();
163        }
164
165        let inner = Arc::new(OpenCodeSessionImpl {
166            cost: cost.clone(),
167            budget,
168            client,
169            opencode_session_id: opencode_session_id.clone(),
170            config: config.clone(),
171        });
172
173        Ok(AgentSession::new(
174            opencode_session_id,
175            ExecutorType::OpenCode,
176            working_dir.to_path_buf(),
177            config.model.clone(),
178            inner,
179        ))
180    }
181
182    async fn resume(
183        &self,
184        working_dir: &Path,
185        session_id: &str,
186        prompt: &str,
187        config: &SpawnConfig,
188    ) -> Result<AgentSession> {
189        // OpenCode supports native session resume — we just keep prompting the
190        // existing server session id.
191        let cost = Arc::new(Mutex::new(AgentCost::default()));
192        let budget = config.budget_usd.unwrap_or(f64::MAX);
193
194        if budget <= 0.0 {
195            return Err(AgentError::BudgetExceeded {
196                limit: budget,
197                spent: 0.0,
198            });
199        }
200
201        let client = self.make_client(working_dir);
202
203        let response = client
204            .prompt(session_id, prompt, config, budget)
205            .await?;
206
207        {
208            let mut c = cost.lock().unwrap();
209            *c = response.cost.clone();
210        }
211
212        let inner = Arc::new(OpenCodeSessionImpl {
213            cost: cost.clone(),
214            budget,
215            client,
216            opencode_session_id: session_id.to_string(),
217            config: config.clone(),
218        });
219
220        Ok(AgentSession::new(
221            session_id.to_string(),
222            ExecutorType::OpenCode,
223            working_dir.to_path_buf(),
224            config.model.clone(),
225            inner,
226        ))
227    }
228
229    fn capabilities(&self) -> AgentCapabilities {
230        AgentCapabilities {
231            session_resume: true,
232            // True now that we actually parse info.tokens / tokens.
233            token_usage: true,
234            mcp_support: true,
235            autonomous_mode: true,
236            structured_output: false,
237        }
238    }
239
240    fn availability(&self) -> AvailabilityStatus {
241        AvailabilityStatus {
242            available: true,
243            reason: Some(format!(
244                "Run `opencode serve` to start server at {}",
245                self.base_url
246            )),
247        }
248    }
249}
250
251// Keep PathBuf used (formerly used directly; now via working_dir.to_path_buf()).
252#[allow(dead_code)]
253fn _pathbuf_used() -> PathBuf {
254    PathBuf::new()
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn executor_type_is_opencode() {
263        let exec = OpencodeExecutor::new();
264        assert_eq!(exec.executor_type(), ExecutorType::OpenCode);
265    }
266
267    #[test]
268    fn capabilities_declares_session_resume() {
269        let caps = OpencodeExecutor::new().capabilities();
270        assert!(caps.session_resume);
271        assert!(caps.autonomous_mode);
272        assert!(caps.mcp_support);
273        assert!(caps.token_usage);
274    }
275
276    #[test]
277    fn default_base_url_is_localhost() {
278        let exec = OpencodeExecutor::new();
279        assert_eq!(exec.base_url, "http://127.0.0.1:4096");
280    }
281
282    #[test]
283    fn custom_base_url_strips_trailing_slash() {
284        let exec = OpencodeExecutor::with_base_url("http://my-server:8080/");
285        assert_eq!(exec.base_url, "http://my-server:8080");
286    }
287}