1use std::ffi::OsString;
8use std::path::{Path, PathBuf};
9use std::process::Stdio;
10use std::time::{Duration, Instant};
11
12use serde_json::Value;
13use thiserror::Error;
14use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader};
15use tokio::process::Command;
16
17mod other;
18pub use other::{AgentCli, Provider, ProviderError};
19
20pub(crate) use other::validate_request;
21
22const STDERR_LIMIT: usize = 4096;
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
31#[non_exhaustive]
32pub enum ToolPolicy {
33 #[default]
35 Default,
36 McpOnly,
38 Web,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct McpServer {
45 pub name: String,
47 pub url: String,
49 pub bearer_token_env: Option<String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56#[non_exhaustive]
57pub enum AgentEvent {
58 TextDelta(String),
60 Text(String),
62 ToolUse {
64 name: String,
66 },
67}
68
69#[derive(Clone)]
71pub struct EventHandler(pub std::sync::Arc<dyn Fn(AgentEvent) + Send + Sync>);
72
73impl EventHandler {
74 pub fn new(f: impl Fn(AgentEvent) + Send + Sync + 'static) -> Self {
76 Self(std::sync::Arc::new(f))
77 }
78
79 pub(crate) fn emit(&self, event: AgentEvent) {
80 (self.0)(event);
81 }
82}
83
84impl std::fmt::Debug for EventHandler {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 f.write_str("EventHandler")
87 }
88}
89
90#[derive(Debug, Clone)]
95pub struct RunRequest {
96 pub prompt: String,
97 pub cwd: PathBuf,
98 pub model: Option<String>,
100 pub timeout: Duration,
101 pub env_remove: Vec<OsString>,
103 pub json_schema: Option<Value>,
106 pub tool_policy: ToolPolicy,
108 pub mcp_servers: Vec<McpServer>,
111 pub env: Vec<(OsString, OsString)>,
113 pub system_prompt: Option<String>,
116 pub persist_session: bool,
118 pub resume: Option<String>,
121 pub on_event: Option<EventHandler>,
124}
125
126impl Default for RunRequest {
127 fn default() -> Self {
129 Self {
130 prompt: String::new(),
131 cwd: PathBuf::from("."),
132 model: None,
133 timeout: Duration::from_secs(300),
134 env_remove: Vec::new(),
135 json_schema: None,
136 tool_policy: ToolPolicy::Default,
137 mcp_servers: Vec::new(),
138 env: Vec::new(),
139 system_prompt: None,
140 persist_session: false,
141 resume: None,
142 on_event: None,
143 }
144 }
145}
146
147impl RunRequest {
148 pub(crate) fn prompt_with_system(&self) -> String {
150 match self.system_prompt.as_deref().map(str::trim) {
151 Some(system) if !system.is_empty() => {
152 format!(
153 "<instructions>\n{system}\n</instructions>\n\n{}",
154 self.prompt
155 )
156 }
157 _ => self.prompt.clone(),
158 }
159 }
160
161 pub(crate) fn require_default_tools(&self, cli: &str) -> Result<(), Error> {
163 if self.tool_policy != ToolPolicy::Default {
164 return Err(Error::InvalidRequest(format!(
165 "{cli} cannot enforce tool policy {:?}",
166 self.tool_policy
167 )));
168 }
169 if !self.mcp_servers.is_empty() {
170 return Err(Error::InvalidRequest(format!(
171 "{cli} does not support per-run MCP servers"
172 )));
173 }
174 Ok(())
175 }
176}
177
178#[derive(Debug, Clone, Default, PartialEq, Eq)]
180pub struct TokenUsage {
181 pub input: u64,
182 pub output: u64,
183 pub reasoning: u64,
184 pub cache_read: u64,
185 pub cache_write: u64,
186}
187
188#[derive(Debug, Clone)]
190pub struct RunResult {
191 pub text: String,
192 pub session_id: Option<String>,
193 pub usage: Option<TokenUsage>,
195 pub duration: Duration,
196 pub stderr_tail: String,
197}
198
199#[derive(Debug, Error)]
200pub enum Error {
201 #[error("CLI executable not found: {0}")]
202 NotFound(PathBuf),
203 #[error("failed to probe CLI version: {0}")]
204 VersionProbe(String),
205 #[error("OpenCode v2 required, found: {0}")]
206 UnsupportedVersion(String),
207 #[error("invalid run request: {0}")]
208 InvalidRequest(String),
209 #[error("failed to run CLI: {0}")]
210 Io(#[from] std::io::Error),
211 #[error("CLI timed out after {0:?}")]
212 Timeout(Duration),
213 #[error("CLI exited with {status:?}: {stderr_tail}")]
214 Exit {
215 status: Option<i32>,
216 stderr_tail: String,
217 },
218 #[error("agent reported an error: {0}")]
219 Agent(String),
220 #[error("invalid CLI output: {0}")]
221 Protocol(String),
222 #[error("agent returned no assistant text")]
223 EmptyResponse,
224 #[error("session not found: {0}")]
226 SessionNotFound(String),
227}
228
229#[derive(Debug, Clone)]
231pub struct OpenCodeV2 {
232 binary: PathBuf,
233}
234
235impl OpenCodeV2 {
236 pub fn discover() -> Result<Self, Error> {
238 Self::with_binary(PathBuf::from("opencode"))
239 }
240
241 pub fn with_binary(binary: impl Into<PathBuf>) -> Result<Self, Error> {
243 let binary = binary.into();
244 let output = std::process::Command::new(&binary)
245 .arg("--version")
246 .output()
247 .map_err(|e| {
248 if e.kind() == std::io::ErrorKind::NotFound {
249 Error::NotFound(binary.clone())
250 } else {
251 Error::VersionProbe(e.to_string())
252 }
253 })?;
254 if !output.status.success() {
255 return Err(Error::VersionProbe(
256 String::from_utf8_lossy(&output.stderr).trim().to_string(),
257 ));
258 }
259 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
260 let first = version.trim_start_matches('v');
261 if !first.starts_with("2.") {
262 return Err(Error::UnsupportedVersion(version));
263 }
264 Ok(Self { binary })
265 }
266
267 pub fn binary(&self) -> &Path {
268 &self.binary
269 }
270
271 pub async fn run(&self, request: RunRequest) -> Result<RunResult, Error> {
273 validate_request(&request)?;
274 request.require_default_tools("OpenCode")?;
275 let started = Instant::now();
276 let mut command = Command::new(&self.binary);
277 command.args(["run", "--standalone", "--format", "json"]);
278 if let Some(session) = request.resume.as_deref() {
279 command.arg("--session").arg(session);
280 }
281 if let Some(model) = request.model.as_deref() {
282 if model.trim().is_empty() || !model.contains('/') {
283 return Err(Error::InvalidRequest(format!(
284 "model must be provider/model[#variant]: {model}"
285 )));
286 }
287 command.arg("--model").arg(model);
288 }
289 for name in &request.env_remove {
290 command.env_remove(name);
291 }
292 command.envs(request.env.iter().map(|(k, v)| (k, v)));
293 command
294 .current_dir(&request.cwd)
295 .stdin(Stdio::piped())
296 .stdout(Stdio::piped())
297 .stderr(Stdio::piped())
298 .kill_on_drop(true);
299
300 let mut child = command.spawn()?;
301 let mut stdin = child.stdin.take().expect("piped stdin");
302 let stdout = child.stdout.take().expect("piped stdout");
303 let stderr = child.stderr.take().expect("piped stderr");
304 let work = async {
305 let input = async {
306 stdin
307 .write_all(request.prompt_with_system().as_bytes())
308 .await?;
309 stdin.shutdown().await?;
310 drop(stdin);
311 Ok::<(), std::io::Error>(())
312 };
313 let (input, parsed, stderr_tail, status) = tokio::join!(
314 input,
315 parse_stdout(stdout, request.on_event.clone()),
316 read_stderr_tail(stderr),
317 child.wait()
318 );
319 input?;
320 let parsed = parsed?;
321 let stderr_tail = stderr_tail?;
322 let status = status?;
323 if !status.success() {
324 return Err(Error::Exit {
325 status: status.code(),
326 stderr_tail,
327 });
328 }
329 if let Some(error) = parsed.error {
330 return Err(Error::Agent(error));
331 }
332 let text = parsed.text_parts.join("\n").trim().to_string();
333 if text.is_empty() {
334 return Err(Error::EmptyResponse);
335 }
336 let usage = parsed.usage();
337 Ok(RunResult {
338 text,
339 session_id: parsed.session_id,
340 usage,
341 duration: started.elapsed(),
342 stderr_tail,
343 })
344 };
345 match tokio::time::timeout(request.timeout, work).await {
346 Ok(result) => result,
347 Err(_) => {
348 let _ = child.start_kill();
349 let _ = child.wait().await;
350 Err(Error::Timeout(request.timeout))
351 }
352 }
353 }
354}
355
356#[derive(Default)]
357struct Parsed {
358 text_parts: Vec<String>,
359 session_id: Option<String>,
360 usage_sum: TokenUsage,
361 starts: usize,
362 finishes: usize,
363 last_reason: Option<String>,
364 error: Option<String>,
365}
366
367impl Parsed {
368 fn usage(&self) -> Option<TokenUsage> {
369 (self.finishes > 0
370 && self.starts == self.finishes
371 && self.last_reason.as_deref() != Some("tool-calls"))
372 .then(|| self.usage_sum.clone())
373 }
374}
375
376async fn parse_stdout(
377 stdout: impl AsyncRead + Unpin,
378 on_event: Option<EventHandler>,
379) -> Result<Parsed, Error> {
380 let mut lines = BufReader::new(stdout).lines();
381 let mut parsed = Parsed::default();
382 while let Some(line) = lines.next_line().await? {
383 if line.trim().is_empty() {
384 continue;
385 }
386 let event: Value = serde_json::from_str(&line)
387 .map_err(|e| Error::Protocol(format!("{e}; line: {}", tail(&line, 200))))?;
388 if let Some(id) = event["sessionID"].as_str() {
389 parsed.session_id = Some(id.to_string());
390 }
391 let part = &event["part"];
392 match event["type"].as_str() {
393 Some("text") => {
394 if let Some(text) = part["text"].as_str()
395 && !text.trim().is_empty()
396 {
397 if let Some(h) = &on_event {
398 h.emit(AgentEvent::Text(text.to_string()));
399 }
400 parsed.text_parts.push(text.to_string());
401 }
402 }
403 Some("tool_use") => {
404 if let (Some(h), Some(name)) = (&on_event, part["tool"].as_str()) {
405 h.emit(AgentEvent::ToolUse {
406 name: name.to_string(),
407 });
408 }
409 }
410 Some("step_start") => parsed.starts += 1,
411 Some("step_finish") => {
412 parsed.finishes += 1;
413 parsed.last_reason = part["reason"].as_str().map(str::to_string);
414 let tokens = &part["tokens"];
415 parsed.usage_sum.input += tokens["input"].as_u64().unwrap_or(0);
416 parsed.usage_sum.output += tokens["output"].as_u64().unwrap_or(0);
417 parsed.usage_sum.reasoning += tokens["reasoning"].as_u64().unwrap_or(0);
418 parsed.usage_sum.cache_read += tokens["cache"]["read"].as_u64().unwrap_or(0);
419 parsed.usage_sum.cache_write += tokens["cache"]["write"].as_u64().unwrap_or(0);
420 }
421 Some("error") => {
422 let message = event["error"]["data"]["message"]
423 .as_str()
424 .or_else(|| event["error"]["message"].as_str())
425 .or_else(|| event["error"]["name"].as_str())
426 .or_else(|| event["error"].as_str())
427 .unwrap_or("unknown OpenCode error");
428 parsed.error = Some(message.to_string());
429 }
430 _ => {}
431 }
432 }
433 Ok(parsed)
434}
435
436async fn read_stderr_tail(mut stderr: impl AsyncRead + Unpin) -> Result<String, std::io::Error> {
437 let mut tail_bytes = Vec::new();
438 let mut chunk = [0_u8; 4096];
439 loop {
440 let n = stderr.read(&mut chunk).await?;
441 if n == 0 {
442 break;
443 }
444 tail_bytes.extend_from_slice(&chunk[..n]);
445 if tail_bytes.len() > STDERR_LIMIT {
446 tail_bytes.drain(..tail_bytes.len() - STDERR_LIMIT);
447 }
448 }
449 Ok(String::from_utf8_lossy(&tail_bytes).to_string())
450}
451
452fn tail(s: &str, count: usize) -> String {
453 let chars: Vec<char> = s.chars().collect();
454 chars[chars.len().saturating_sub(count)..].iter().collect()
455}