Skip to main content

smol_workflow_engine/agent_providers/
opencode.rs

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}