opi_coding_agent/
runner.rs1use 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#[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#[derive(Debug, Clone)]
47pub struct NonInteractiveResult {
48 pub stdout: String,
49 pub stderr: String,
50 pub exit_code: i32,
51}
52
53pub struct NonInteractiveRunner {
59 harness: CodingHarness,
60}
61
62impl NonInteractiveRunner {
63 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 pub fn cancel(&self) {
86 self.harness.cancel();
87 }
88
89 pub async fn run(&mut self, prompt: &str) -> NonInteractiveResult {
91 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 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
157fn 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
173struct 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}