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)
10
11mod client;
12mod protocol;
13
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex};
16
17use async_trait::async_trait;
18use uuid::Uuid;
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
54impl Default for OpencodeExecutor {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60/// Internal session implementation for OpenCode.
61struct OpenCodeSessionImpl {
62    cost: Arc<Mutex<AgentCost>>,
63    budget: f64,
64    base_url: String,
65    api_key: Option<String>,
66    working_dir: PathBuf,
67    opencode_session_id: String,
68    config: SpawnConfig,
69}
70
71#[async_trait]
72impl SessionImpl for OpenCodeSessionImpl {
73    async fn query(&self, prompt: &str) -> Result<AgentResponse> {
74        {
75            let c = self.cost.lock().unwrap();
76            if c.total_usd >= self.budget {
77                return Err(AgentError::BudgetExceeded {
78                    limit: self.budget,
79                    spent: c.total_usd,
80                });
81            }
82        }
83
84        let client = OpencodeClient::new(
85            &self.base_url,
86            self.api_key.as_deref(),
87            self.working_dir.to_str(),
88        );
89
90        let resp = client
91            .prompt(&self.opencode_session_id, prompt, &self.config, self.budget)
92            .await?;
93
94        {
95            let mut c = self.cost.lock().unwrap();
96            c.input_tokens += resp.cost.input_tokens;
97            c.output_tokens += resp.cost.output_tokens;
98            c.total_usd += resp.cost.total_usd;
99        }
100
101        Ok(resp)
102    }
103
104    async fn total_cost(&self) -> Result<AgentCost> {
105        Ok(self.cost.lock().unwrap().clone())
106    }
107
108    async fn close(&self) -> Result<()> {
109        Ok(())
110    }
111}
112
113#[async_trait]
114impl AgentExecutor for OpencodeExecutor {
115    fn executor_type(&self) -> ExecutorType {
116        ExecutorType::OpenCode
117    }
118
119    async fn spawn(
120        &self,
121        working_dir: &Path,
122        prompt: &str,
123        config: &SpawnConfig,
124    ) -> Result<AgentSession> {
125        let session_id = Uuid::new_v4().to_string();
126        let cost = Arc::new(Mutex::new(AgentCost::default()));
127        let budget = config.budget_usd.unwrap_or(f64::MAX);
128
129        if budget <= 0.0 {
130            return Err(AgentError::BudgetExceeded {
131                limit: budget,
132                spent: 0.0,
133            });
134        }
135
136        let client = OpencodeClient::new(
137            &self.base_url,
138            self.api_key.as_deref(),
139            working_dir.to_str(),
140        );
141
142        // Create session on server.
143        let session_data = client.create_session().await?;
144        let opencode_session_id = session_data
145            .get("id")
146            .and_then(|v| v.as_str())
147            .ok_or_else(|| AgentError::Provider {
148                provider: "opencode".into(),
149                message: "session response missing id".into(),
150            })?
151            .to_string();
152
153        // Send first prompt.
154        let response = client
155            .prompt(&opencode_session_id, prompt, config, budget)
156            .await?;
157
158        {
159            let mut c = cost.lock().unwrap();
160            *c = response.cost.clone();
161        }
162
163        let inner = Arc::new(OpenCodeSessionImpl {
164            cost: cost.clone(),
165            budget,
166            base_url: self.base_url.clone(),
167            api_key: self.api_key.clone(),
168            working_dir: working_dir.to_path_buf(),
169            opencode_session_id,
170            config: config.clone(),
171        });
172
173        Ok(AgentSession::new(
174            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.
190        let cost = Arc::new(Mutex::new(AgentCost::default()));
191        let budget = config.budget_usd.unwrap_or(f64::MAX);
192
193        let client = OpencodeClient::new(
194            &self.base_url,
195            self.api_key.as_deref(),
196            working_dir.to_str(),
197        );
198
199        let response = client
200            .prompt(session_id, prompt, config, budget)
201            .await?;
202
203        {
204            let mut c = cost.lock().unwrap();
205            *c = response.cost.clone();
206        }
207
208        let new_session_id = Uuid::new_v4().to_string();
209
210        let inner = Arc::new(OpenCodeSessionImpl {
211            cost: cost.clone(),
212            budget,
213            base_url: self.base_url.clone(),
214            api_key: self.api_key.clone(),
215            working_dir: working_dir.to_path_buf(),
216            opencode_session_id: session_id.to_string(),
217            config: config.clone(),
218        });
219
220        Ok(AgentSession::new(
221            new_session_id,
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            token_usage: true,
233            mcp_support: true,
234            autonomous_mode: true,
235            structured_output: false,
236        }
237    }
238
239    fn availability(&self) -> AvailabilityStatus {
240        AvailabilityStatus {
241            available: true,
242            reason: Some(format!(
243                "Run `opencode serve` to start server at {}",
244                self.base_url
245            )),
246        }
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn executor_type_is_opencode() {
256        let exec = OpencodeExecutor::new();
257        assert_eq!(exec.executor_type(), ExecutorType::OpenCode);
258    }
259
260    #[test]
261    fn capabilities_declares_session_resume() {
262        let caps = OpencodeExecutor::new().capabilities();
263        assert!(caps.session_resume);
264        assert!(caps.autonomous_mode);
265        assert!(caps.mcp_support);
266        assert!(caps.token_usage);
267    }
268
269    #[test]
270    fn default_base_url_is_localhost() {
271        let exec = OpencodeExecutor::new();
272        assert_eq!(exec.base_url, "http://127.0.0.1:4096");
273    }
274
275    #[test]
276    fn custom_base_url_strips_trailing_slash() {
277        let exec = OpencodeExecutor::with_base_url("http://my-server:8080/");
278        assert_eq!(exec.base_url, "http://my-server:8080");
279    }
280}