Skip to main content

nucel_agent_opencode/
lib.rs

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