1use super::common::*;
2use super::types::*;
3use crate::environment::{EnvironmentPath, ExecRequest, NullExecEventSink};
4use anyhow::{anyhow, bail, Context};
5use serde_json::{json, Value};
6use std::collections::{BTreeMap, HashMap};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
10pub struct OpenCodeAgentProviderOptions {
11 pub command: Option<String>,
12 pub subcommand: Vec<String>,
13 pub args: Vec<String>,
14 pub server_subcommand: Vec<String>,
15 pub server_args: Vec<String>,
16 pub structured_output_retry_count: u64,
17 pub server_startup_timeout_ms: u64,
18 pub cwd: Option<PathBuf>,
19 pub env: HashMap<String, String>,
20 pub timeout_ms: Option<u64>,
21}
22
23impl Default for OpenCodeAgentProviderOptions {
24 fn default() -> Self {
25 Self {
26 command: None,
27 subcommand: vec!["run".into()],
28 args: Vec::new(),
29 server_subcommand: vec!["serve".into()],
30 server_args: Vec::new(),
31 structured_output_retry_count: 2,
32 server_startup_timeout_ms: 15_000,
33 cwd: None,
34 env: HashMap::new(),
35 timeout_ms: None,
36 }
37 }
38}
39
40#[derive(Debug, Clone, Default)]
41pub struct OpenCodeAgentProvider {
42 options: OpenCodeAgentProviderOptions,
43}
44
45impl OpenCodeAgentProvider {
46 pub fn new(options: OpenCodeAgentProviderOptions) -> Self {
47 Self { options }
48 }
49}
50
51#[async_trait::async_trait]
52impl AgentProvider for OpenCodeAgentProvider {
53 fn name(&self) -> &str {
54 "opencode"
55 }
56 fn schema_mode(&self) -> AgentProviderSchemaMode {
57 AgentProviderSchemaMode::Builtin
58 }
59 fn usage_mode(&self) -> AgentProviderUsageMode {
60 AgentProviderUsageMode::Builtin
61 }
62 async fn run(&self, input: AgentProviderRunInput) -> anyhow::Result<AgentProviderResult> {
63 if option_schema(&input.options).is_some() {
64 run_opencode_structured(input, &self.options).await
65 } else {
66 run_opencode(input, &self.options).await
67 }
68 }
69}
70
71const MAX_PROMPT_ARG_LENGTH: usize = 32_000;
72
73async fn run_opencode(
74 input: AgentProviderRunInput,
75 options: &OpenCodeAgentProviderOptions,
76) -> anyhow::Result<AgentProviderResult> {
77 if input.prompt.len() > MAX_PROMPT_ARG_LENGTH {
78 return run_opencode_via_server(input, options).await;
79 }
80
81 let command = options.command.as_deref().unwrap_or("opencode");
82 let mut args = Vec::new();
83 args.extend(options.subcommand.clone());
84 args.extend(options.args.clone());
85 args.extend(["--format".into(), "json".into()]);
86 if let Some(model) = option_str(&input.options, "model") {
87 args.extend(["--model".into(), model]);
88 }
89 if let Some(thinking) = option_str(&input.options, "thinking") {
90 args.extend(["--variant".into(), thinking]);
91 }
92 if let Some(agent_type) = option_str(&input.options, "agentType") {
93 args.extend(["--agent".into(), agent_type]);
94 }
95 args.push(input.prompt.clone());
96 let cwd = input.context.cwd.as_deref().or(options.cwd.as_deref());
97 let (stdout, stderr) = run_command(RunCommandRequest {
98 provider: "OpenCode",
99 command,
100 args: &args,
101 stdin: None,
102 cwd,
103 env: &options.env,
104 timeout_ms: options.timeout_ms,
105 environment: input.environment.as_ref(),
106 })
107 .await?;
108 let parsed = parse_output(&stdout);
109 let events = match &parsed {
110 Value::Array(items) => items.clone(),
111 value => vec![value.clone()],
112 };
113 let candidate = extract_output(&parsed).unwrap_or(stdout);
114 let session_id = extract_session_id(&parsed)
115 .context("OpenCode provider response did not include a session id")?;
116 Ok(AgentProviderResult {
117 output: Value::String(candidate.trim_end().to_string()),
118 session_id: Some(session_id),
119 model: extract_model(&parsed).or_else(|| option_model(&input.options)),
120 usage: extract_usage(&parsed, true),
121 isolation: None,
122 raw: Some(to_json_value(
123 json!({ "events": events, "response": parsed, "stderr": stderr }),
124 )),
125 })
126}
127
128async fn run_opencode_via_server(
129 input: AgentProviderRunInput,
130 options: &OpenCodeAgentProviderOptions,
131) -> anyhow::Result<AgentProviderResult> {
132 let mut session_body = json!({
133 "title": "smol-workflows agent call",
134 });
135 if let Some(agent_type) = option_str(&input.options, "agentType") {
136 session_body["agent"] = Value::String(agent_type);
137 }
138
139 let model = option_str(&input.options, "model")
140 .map(|model| split_model(&model))
141 .transpose()?;
142 let mut body = json!({
143 "parts": [{ "type": "text", "text": input.prompt }],
144 });
145 if let Some(model) = model {
146 body["model"] = model;
147 }
148 if let Some(thinking) = option_str(&input.options, "thinking") {
149 body["variant"] = Value::String(thinking);
150 }
151 if let Some(agent_type) = option_str(&input.options, "agentType") {
152 body["agent"] = Value::String(agent_type);
153 }
154
155 let server_result = run_opencode_server_helper(&input, options, session_body, body).await?;
156 let session = server_result.session;
157 let session_id = extract_server_session_id(&session)?;
158 let response = server_result.response;
159 let output = extract_output(&response).ok_or_else(|| {
160 anyhow::anyhow!("OpenCode response did not include a final assistant message")
161 })?;
162 let logs = server_result.logs;
163 Ok(AgentProviderResult {
164 output: Value::String(output.trim_end().to_string()),
165 session_id: Some(session_id),
166 model: extract_model(&response)
167 .or_else(|| extract_model(&session))
168 .or_else(|| option_model(&input.options)),
169 usage: extract_usage(&response, true),
170 isolation: None,
171 raw: Some(to_json_value(
172 json!({ "events": [session, response], "session": session, "response": response, "serverLogs": logs }),
173 )),
174 })
175}
176
177async fn run_opencode_structured(
178 input: AgentProviderRunInput,
179 options: &OpenCodeAgentProviderOptions,
180) -> anyhow::Result<AgentProviderResult> {
181 let mut session_body = json!({
182 "title": "smol-workflows structured output",
183 });
184 if let Some(agent_type) = option_str(&input.options, "agentType") {
185 session_body["agent"] = Value::String(agent_type);
186 }
187
188 let model = option_str(&input.options, "model")
189 .map(|model| split_model(&model))
190 .transpose()?;
191 let mut body = json!({
192 "parts": [{ "type": "text", "text": input.prompt }],
193 "format": {
194 "type": "json_schema",
195 "schema": option_schema(&input.options).cloned(),
196 "retryCount": options.structured_output_retry_count,
197 }
198 });
199 if let Some(model) = model {
200 body["model"] = model;
201 }
202 if let Some(thinking) = option_str(&input.options, "thinking") {
203 body["variant"] = Value::String(thinking);
204 }
205 if let Some(agent_type) = option_str(&input.options, "agentType") {
206 body["agent"] = Value::String(agent_type);
207 }
208
209 let server_result = run_opencode_server_helper(&input, options, session_body, body).await?;
210 let session = server_result.session;
211 let session_id = extract_server_session_id(&session)?;
212 let response = server_result.response;
213 let output = extract_structured_output(&response).ok_or_else(|| {
214 anyhow::anyhow!("OpenCode structured-output response did not include a structured value")
215 })?;
216 let logs = server_result.logs;
217 Ok(AgentProviderResult {
218 output,
219 session_id: Some(session_id),
220 model: extract_model(&response)
221 .or_else(|| extract_model(&session))
222 .or_else(|| option_model(&input.options)),
223 usage: extract_usage(&response, true),
224 isolation: None,
225 raw: Some(to_json_value(
226 json!({ "events": [session, response], "session": session, "response": response, "serverLogs": logs }),
227 )),
228 })
229}
230
231fn extract_server_session_id(session: &Value) -> anyhow::Result<String> {
232 extract_session_id(session)
233 .or_else(|| {
234 session
235 .get("id")
236 .and_then(Value::as_str)
237 .map(ToString::to_string)
238 })
239 .ok_or_else(|| {
240 anyhow::anyhow!(
241 "OpenCode create-session response did not include a session id: {session}"
242 )
243 })
244}
245
246struct OpenCodeServerHelperResult {
247 session: Value,
248 response: Value,
249 logs: String,
250}
251
252async fn run_opencode_server_helper(
253 input: &AgentProviderRunInput,
254 options: &OpenCodeAgentProviderOptions,
255 session_body: Value,
256 message_body: Value,
257) -> anyhow::Result<OpenCodeServerHelperResult> {
258 let temp = input
259 .environment
260 .create_temp_dir("smol-wf-opencode-")
261 .await?;
262 let helper_path = join_environment_path(&temp, "opencode-server-helper.sh");
263 let session_body_path = join_environment_path(&temp, "session-body.json");
264 let message_body_path = join_environment_path(&temp, "message-body.json");
265 let session_output_path = join_environment_path(&temp, "session-output.json");
266 let response_output_path = join_environment_path(&temp, "response-output.json");
267 let logs_output_path = join_environment_path(&temp, "server.log");
268 input
269 .environment
270 .write_file(&helper_path, OPENCODE_SERVER_HELPER.as_bytes())
271 .await?;
272 input
273 .environment
274 .write_file(&session_body_path, &serde_json::to_vec(&session_body)?)
275 .await?;
276 input
277 .environment
278 .write_file(&message_body_path, &serde_json::to_vec(&message_body)?)
279 .await?;
280
281 let command = options.command.as_deref().unwrap_or("opencode");
282 let mut server_args = Vec::new();
283 server_args.extend(options.server_subcommand.clone());
284 server_args.extend(options.server_args.clone());
285 server_args.extend([
286 "--hostname".into(),
287 "127.0.0.1".into(),
288 "--port".into(),
289 "0".into(),
290 ]);
291 let directory = match input.context.cwd.as_ref().or(options.cwd.as_ref()) {
292 Some(path) => path_to_environment_path(path)?,
293 None => input.environment.cwd().cloned().unwrap_or(EnvironmentPath(
294 std::env::current_dir()?.to_string_lossy().into_owned(),
295 )),
296 };
297
298 let env = options
299 .env
300 .iter()
301 .map(|(key, value)| (key.clone(), value.clone()))
302 .collect::<BTreeMap<_, _>>();
303 let mut argv = vec![
304 "bash".to_string(),
305 helper_path.0.clone(),
306 "--command".to_string(),
307 command.to_string(),
308 "--directory".to_string(),
309 directory.0.clone(),
310 "--timeout-ms".to_string(),
311 options.server_startup_timeout_ms.to_string(),
312 "--session-body".to_string(),
313 session_body_path.0.clone(),
314 "--message-body".to_string(),
315 message_body_path.0.clone(),
316 "--session-output".to_string(),
317 session_output_path.0.clone(),
318 "--response-output".to_string(),
319 response_output_path.0.clone(),
320 "--logs-output".to_string(),
321 logs_output_path.0.clone(),
322 "--".to_string(),
323 ];
324 argv.extend(server_args);
325 let mut sink = NullExecEventSink;
326 let output = input
327 .environment
328 .exec(
329 ExecRequest {
330 argv,
331 cwd: Some(directory),
332 env,
333 stdin: None,
334 },
335 &mut sink,
336 )
337 .await
338 .context("failed to run OpenCode server helper")?;
339 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
340 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
341 if output.exit_code != 0 {
342 bail!(
343 "OpenCode server helper exited with code {}{}",
344 output.exit_code,
345 format_command_failure(&stdout, &stderr)
346 );
347 }
348 let session_bytes = input
349 .environment
350 .read_file(&session_output_path)
351 .await
352 .context("failed to read OpenCode session helper output")?;
353 let response_bytes = input
354 .environment
355 .read_file(&response_output_path)
356 .await
357 .context("failed to read OpenCode response helper output")?;
358 let logs = input
359 .environment
360 .read_file(&logs_output_path)
361 .await
362 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
363 .unwrap_or_default();
364 Ok(OpenCodeServerHelperResult {
365 session: serde_json::from_slice(&session_bytes)
366 .context("OpenCode server helper session output was not valid JSON")?,
367 response: serde_json::from_slice(&response_bytes)
368 .context("OpenCode server helper response output was not valid JSON")?,
369 logs,
370 })
371}
372
373fn join_environment_path(base: &EnvironmentPath, child: &str) -> EnvironmentPath {
374 EnvironmentPath(format!("{}/{}", base.as_str().trim_end_matches('/'), child))
375}
376
377fn path_to_environment_path(path: &Path) -> anyhow::Result<EnvironmentPath> {
378 let value = path
379 .to_str()
380 .ok_or_else(|| anyhow!("OpenCode server cwd must be valid UTF-8: {path:?}"))?;
381 Ok(EnvironmentPath(value.to_string()))
382}
383
384const OPENCODE_SERVER_HELPER: &str = include_str!("assets/opencode-server-helper.sh");
385
386fn split_model(model: &str) -> anyhow::Result<Value> {
387 let Some((provider, model_id)) = model.split_once('/') else {
388 bail!("OpenCode model must use provider/model form for structured output, got: {model}")
389 };
390 if provider.is_empty() || model_id.is_empty() {
391 bail!("OpenCode model must use provider/model form for structured output, got: {model}");
392 }
393 Ok(json!({ "providerID": provider, "modelID": model_id }))
394}
395
396fn parse_output(stdout: &str) -> Value {
397 let trimmed = stdout.trim();
398 if trimmed.is_empty() {
399 return Value::String(String::new());
400 }
401 serde_json::from_str(trimmed).unwrap_or_else(|_| {
402 let events = parse_json_lines(stdout);
403 if events.is_empty() {
404 Value::String(stdout.to_string())
405 } else {
406 Value::Array(events)
407 }
408 })
409}
410
411fn extract_structured_output(value: &Value) -> Option<Value> {
412 match value {
413 Value::Array(items) => items.iter().find_map(extract_structured_output),
414 Value::Object(record) => {
415 for key in ["structured", "structured_output", "structuredOutput"] {
416 if record.contains_key(key) {
417 return record.get(key).cloned();
418 }
419 }
420 if record.get("type").and_then(Value::as_str) == Some("tool")
421 && record.get("tool").and_then(Value::as_str) == Some("StructuredOutput")
422 {
423 if let Some(input) = get_path(value, &["state", "input"]) {
424 return Some(input.clone());
425 }
426 }
427 record.values().find_map(extract_structured_output)
428 }
429 _ => None,
430 }
431}
432
433fn extract_output(raw: &Value) -> Option<String> {
434 match raw {
435 Value::String(text) => Some(text.clone()),
436 Value::Array(items) => items.iter().rev().find_map(extract_output),
437 Value::Object(record) => {
438 if record.get("type").and_then(Value::as_str) == Some("text") {
439 if let Some(text) = record.get("part").and_then(extract_text) {
440 return Some(text);
441 }
442 }
443 for key in ["result", "output", "text", "message", "content", "parts"] {
444 if let Some(text) = record.get(key).and_then(extract_text) {
445 if !text.is_empty() {
446 return Some(text);
447 }
448 }
449 }
450 for key in ["data", "item", "event", "properties"] {
451 if let Some(value) = record.get(key).and_then(extract_output) {
452 if !value.is_empty() {
453 return Some(value);
454 }
455 }
456 }
457 None
458 }
459 _ => None,
460 }
461}
462
463fn extract_text(value: &Value) -> Option<String> {
464 match value {
465 Value::String(text) => Some(text.clone()),
466 Value::Array(items) => {
467 let text = items
468 .iter()
469 .map(|item| extract_text(item).unwrap_or_default())
470 .collect::<Vec<_>>()
471 .join("");
472 (!text.is_empty()).then_some(text)
473 }
474 Value::Object(record) => record
475 .get("text")
476 .or_else(|| record.get("content"))
477 .or_else(|| record.get("message"))
478 .or_else(|| record.get("parts"))
479 .and_then(extract_text),
480 _ => None,
481 }
482}
483
484fn extract_session_id(raw: &Value) -> Option<String> {
485 match raw {
486 Value::Array(items) => items.iter().find_map(extract_session_id),
487 Value::Object(record) => {
488 for key in ["sessionID", "sessionId", "session_id"] {
489 if let Some(value) = record.get(key).and_then(Value::as_str) {
490 return Some(value.to_string());
491 }
492 }
493 record.values().find_map(extract_session_id)
494 }
495 _ => None,
496 }
497}
498
499fn extract_usage(raw: &Value, sum: bool) -> Option<AgentUsage> {
500 let mut candidates = Vec::new();
501 find_usage_objects(raw, &mut candidates);
502 let mut usage = None;
503 for candidate in candidates {
504 usage = Some(if sum {
505 merge_usage_sum(usage, normalize_usage(&candidate))
506 } else {
507 merge_usage_right(usage, normalize_usage(&candidate))
508 });
509 }
510 usage
511}