1#![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
61pub 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 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
106struct OpenCodeSessionImpl {
108 cost: Arc<Mutex<AgentCost>>,
109 budget: f64,
110 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 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 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 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 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 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#[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}