1mod 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
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
54impl Default for OpencodeExecutor {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60struct 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 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 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 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}