1mod 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
27pub 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 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
72struct OpenCodeSessionImpl {
74 cost: Arc<Mutex<AgentCost>>,
75 budget: f64,
76 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 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 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 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 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 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#[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}