Skip to main content

opi_coding_agent/
runner.rs

1//! Non-interactive runner (S10).
2//!
3//! Takes a single prompt, runs it through the agent, captures assistant text
4//! for stdout, diagnostics for stderr, and returns an exit code.
5
6use std::path::PathBuf;
7use std::sync::{Arc, Mutex};
8
9use opi_agent::event::AgentEvent;
10use opi_agent::hooks::{
11    AfterToolCallContext, AfterToolCallResult, AgentHooks, BeforeToolCallContext,
12    BeforeToolCallResult, PrepareNextTurnContext, ShouldStopAfterTurnContext,
13};
14use opi_agent::loop_types::AgentError;
15use opi_agent::message::AgentMessage;
16use opi_ai::message::Message;
17use opi_ai::provider::Provider;
18use opi_ai::stream::AssistantStreamEvent;
19
20use crate::config::OpiConfig;
21use crate::harness::CodingHarness;
22use crate::policy::is_mutating_tool;
23
24// ---------------------------------------------------------------------------
25// Exit codes (S10)
26// ---------------------------------------------------------------------------
27
28/// Exit codes for the non-interactive runner.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[repr(i32)]
31pub enum ExitCode {
32    Success = 0,
33    RuntimeFailure = 1,
34    ConfigError = 2,
35    AuthFailure = 3,
36    ProviderFailure = 4,
37    ToolFailure = 5,
38    Interrupted = 130,
39}
40
41// ---------------------------------------------------------------------------
42// Result
43// ---------------------------------------------------------------------------
44
45/// Captured output from a non-interactive run.
46#[derive(Debug, Clone)]
47pub struct NonInteractiveResult {
48    pub stdout: String,
49    pub stderr: String,
50    pub exit_code: i32,
51}
52
53// ---------------------------------------------------------------------------
54// Runner
55// ---------------------------------------------------------------------------
56
57/// Non-interactive runner that executes a single prompt and captures output.
58pub struct NonInteractiveRunner {
59    harness: CodingHarness,
60}
61
62impl NonInteractiveRunner {
63    /// Create a new non-interactive runner.
64    pub fn new(
65        provider: Box<dyn Provider>,
66        model: String,
67        config: OpiConfig,
68        workspace_root: PathBuf,
69        allow_mutating: bool,
70        user_system_prompt: Option<String>,
71    ) -> Self {
72        let hooks = Box::new(NonInteractiveHooks { allow_mutating });
73        let harness = CodingHarness::new_with_hooks(
74            provider,
75            model,
76            config,
77            workspace_root,
78            hooks,
79            user_system_prompt,
80        );
81        Self { harness }
82    }
83
84    /// Cancel the running operation.
85    pub fn cancel(&self) {
86        self.harness.cancel();
87    }
88
89    /// Run a single prompt and return captured output.
90    pub async fn run(&mut self, prompt: &str) -> NonInteractiveResult {
91        // Subscribe to capture text from TextDelta events
92        let text_parts: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
93        let tp = text_parts.clone();
94        self.harness.subscribe(Box::new(move |event| {
95            if let AgentEvent::MessageUpdate {
96                assistant_event, ..
97            } = event
98                && let AssistantStreamEvent::TextDelta { delta, .. } = assistant_event.as_ref()
99                && let Ok(mut guard) = tp.lock()
100            {
101                guard.push(delta.clone());
102            }
103        }));
104
105        match self.harness.prompt(prompt).await {
106            Ok(messages) => {
107                // Check for provider errors in assistant messages
108                if let Some(error) = find_error_message(&messages) {
109                    return NonInteractiveResult {
110                        stdout: String::new(),
111                        stderr: error,
112                        exit_code: ExitCode::ProviderFailure as i32,
113                    };
114                }
115
116                let stdout = text_parts.lock().map(|g| g.join("")).unwrap_or_default();
117                NonInteractiveResult {
118                    stdout,
119                    stderr: String::new(),
120                    exit_code: ExitCode::Success as i32,
121                }
122            }
123            Err(AgentError::Cancelled) => NonInteractiveResult {
124                stdout: String::new(),
125                stderr: "cancelled".into(),
126                exit_code: ExitCode::Interrupted as i32,
127            },
128            Err(AgentError::AuthFailed(e)) => NonInteractiveResult {
129                stdout: String::new(),
130                stderr: format!("authentication error: {e}"),
131                exit_code: ExitCode::AuthFailure as i32,
132            },
133            Err(AgentError::Provider(e)) => NonInteractiveResult {
134                stdout: String::new(),
135                stderr: format!("provider error: {e}"),
136                exit_code: ExitCode::ProviderFailure as i32,
137            },
138            Err(AgentError::Tool(e)) => NonInteractiveResult {
139                stdout: String::new(),
140                stderr: format!("tool error: {e}"),
141                exit_code: ExitCode::ToolFailure as i32,
142            },
143            Err(AgentError::Hook(e)) => NonInteractiveResult {
144                stdout: String::new(),
145                stderr: format!("hook error: {e}"),
146                exit_code: ExitCode::RuntimeFailure as i32,
147            },
148            Err(AgentError::MaxTurnsExceeded(n)) => NonInteractiveResult {
149                stdout: String::new(),
150                stderr: format!("max turns exceeded ({n})"),
151                exit_code: ExitCode::RuntimeFailure as i32,
152            },
153        }
154    }
155}
156
157// ---------------------------------------------------------------------------
158// Helpers
159// ---------------------------------------------------------------------------
160
161/// Find the first error_message in assistant messages.
162fn find_error_message(messages: &[AgentMessage]) -> Option<String> {
163    for msg in messages {
164        if let AgentMessage::Llm(Message::Assistant(asst)) = msg
165            && let Some(err) = &asst.error_message
166        {
167            return Some(err.clone());
168        }
169    }
170    None
171}
172
173// ---------------------------------------------------------------------------
174// Hooks
175// ---------------------------------------------------------------------------
176
177/// Hooks for non-interactive mode with tool safety policy.
178struct NonInteractiveHooks {
179    allow_mutating: bool,
180}
181
182impl AgentHooks for NonInteractiveHooks {
183    fn convert_to_llm(&self, messages: &[AgentMessage]) -> Result<Vec<Message>, AgentError> {
184        let mut result = Vec::new();
185        for msg in messages {
186            if let AgentMessage::Llm(m) = msg {
187                result.push(m.clone());
188            }
189        }
190        Ok(result)
191    }
192
193    fn before_tool_call(
194        &self,
195        ctx: BeforeToolCallContext,
196    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = BeforeToolCallResult> + Send>> {
197        let allowed = self.allow_mutating;
198        let tool_name = ctx.tool_name.clone();
199        Box::pin(async move {
200            if !allowed && is_mutating_tool(&tool_name) {
201                return BeforeToolCallResult::Deny {
202                    reason: format!(
203                        "tool '{}' is not allowed in non-interactive mode without --allow-mutating",
204                        tool_name
205                    ),
206                };
207            }
208            BeforeToolCallResult::Allow
209        })
210    }
211
212    fn after_tool_call(
213        &self,
214        _ctx: AfterToolCallContext,
215    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = AfterToolCallResult> + Send>> {
216        Box::pin(async { AfterToolCallResult::Keep })
217    }
218
219    fn should_stop_after_turn(
220        &self,
221        _ctx: ShouldStopAfterTurnContext,
222    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>> {
223        Box::pin(async { false })
224    }
225
226    fn prepare_next_turn(
227        &self,
228        _ctx: PrepareNextTurnContext,
229    ) -> std::pin::Pin<
230        Box<
231            dyn std::future::Future<Output = Option<opi_agent::loop_types::AgentLoopTurnUpdate>>
232                + Send,
233        >,
234    > {
235        Box::pin(async { None })
236    }
237}