mod error_classification;
#[cfg(test)]
mod tests;
use crate::agents::{AgentRole, JsonParserType};
use crate::common::domain_types::AgentName;
use crate::logger::Loggable;
use crate::pipeline::{run_with_prompt, PipelineRuntime, PromptCommand};
use crate::reducer::event::{AgentErrorKind, PipelineEvent, TimeoutOutputKind};
use crate::workspace::Workspace;
use anyhow::Result;
pub use error_classification::{
classify_agent_error, classify_io_error, is_auth_error, is_rate_limit_error,
is_retriable_agent_error, is_timeout_error,
};
const ERROR_PREVIEW_MAX_CHARS: usize = 100;
pub struct AgentExecutionResult {
pub event: PipelineEvent,
pub session_id: Option<String>,
}
#[derive(Clone, Copy)]
pub struct AgentExecutionConfig<'a> {
pub role: AgentRole,
pub agent_name: &'a str,
pub cmd_str: &'a str,
pub parser_type: JsonParserType,
pub env_vars: &'a std::collections::HashMap<String, String>,
pub prompt: &'a str,
pub display_name: &'a str,
pub log_prefix: &'a str,
pub model_index: usize,
pub attempt: u32,
pub logfile: &'a str,
pub completion_output_path: Option<&'a std::path::Path>,
}
pub fn execute_agent_fault_tolerantly(
config: AgentExecutionConfig<'_>,
runtime: &mut PipelineRuntime<'_>,
) -> Result<AgentExecutionResult> {
let role = config.role;
let agent_name = AgentName::from(config.agent_name.to_string());
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
try_agent_execution(config, runtime)
}));
Ok(result.unwrap_or_else(|_| {
let error_kind = AgentErrorKind::InternalError;
let retriable = is_retriable_agent_error(&error_kind);
AgentExecutionResult {
event: PipelineEvent::agent_invocation_failed(
role,
agent_name.clone(),
1,
error_kind,
retriable,
),
session_id: None,
}
}))
}
fn try_agent_execution(
config: AgentExecutionConfig<'_>,
runtime: &mut PipelineRuntime<'_>,
) -> AgentExecutionResult {
let agent_name = AgentName::from(config.agent_name.to_string());
let prompt_cmd = PromptCommand {
label: config.agent_name,
display_name: config.display_name,
cmd_str: config.cmd_str,
prompt: config.prompt,
log_prefix: config.log_prefix,
model_index: Some(config.model_index),
attempt: Some(config.attempt),
logfile: config.logfile,
parser_type: config.parser_type,
env_vars: config.env_vars,
completion_output_path: config.completion_output_path,
};
match run_with_prompt(&prompt_cmd, runtime) {
Ok(result) if result.exit_code == 0 => AgentExecutionResult {
event: PipelineEvent::agent_invocation_succeeded(config.role, agent_name.clone()),
session_id: result.session_id,
},
Ok(result) => classify_nonzero_command_result(result, &config, &agent_name, runtime),
Err(e) => {
let error_kind = classify_io_error(&e);
if let Some(path) = config.completion_output_path {
if crate::files::llm_output_extraction::has_valid_xml_output(
runtime.workspace,
path,
) {
return AgentExecutionResult {
event: PipelineEvent::agent_invocation_succeeded(
config.role,
agent_name.clone(),
),
session_id: None,
};
}
}
let retriable = is_retriable_agent_error(&error_kind);
AgentExecutionResult {
event: PipelineEvent::agent_invocation_failed(
config.role,
agent_name.clone(),
1,
error_kind,
retriable,
),
session_id: None,
}
}
}
}
pub(crate) fn classify_nonzero_command_result(
result: crate::pipeline::CommandResult,
config: &AgentExecutionConfig<'_>,
agent_name: &AgentName,
runtime: &PipelineRuntime<'_>,
) -> AgentExecutionResult {
let exit_code = result.exit_code;
let has_explicit_timeout = result.timeout_context.is_some();
let stdout_error =
crate::pipeline::extract_error_identifier_from_logfile(config.logfile, runtime.workspace);
if runtime.config.verbosity.is_debug() {
if let Some(ref err_msg) = stdout_error {
runtime.logger.log(&format!(
"[DEBUG] [OpenCode] Extracted error from logfile for agent '{}': {}",
config.agent_name, err_msg
));
}
}
let error_kind = classify_agent_error(exit_code, &result.stderr, stdout_error.as_deref());
if let Some(path) = config.completion_output_path {
if crate::files::llm_output_extraction::has_valid_xml_output(runtime.workspace, path) {
return AgentExecutionResult {
event: PipelineEvent::agent_invocation_succeeded(config.role, agent_name.clone()),
session_id: result.session_id,
};
}
}
if is_rate_limit_error(&error_kind) {
let error_source = if stdout_error.is_some() {
"stdout"
} else {
"stderr"
};
let error_preview = stdout_error
.as_deref()
.or(Some(result.stderr.as_str()))
.unwrap_or("");
let preview = build_error_preview(error_preview, ERROR_PREVIEW_MAX_CHARS);
runtime.logger.info(&format!(
"[OpenCode] Rate limit detected for agent '{}' (source: {}): {}",
config.agent_name, error_source, preview
));
return AgentExecutionResult {
event: PipelineEvent::agent_rate_limited(
config.role,
agent_name.clone(),
Some(config.prompt.to_string()),
),
session_id: None,
};
}
if is_auth_error(&error_kind) {
return AgentExecutionResult {
event: PipelineEvent::agent_auth_failed(config.role, agent_name.clone()),
session_id: None,
};
}
if has_explicit_timeout {
let output_kind = determine_timeout_output_kind(
config.logfile,
config.completion_output_path,
runtime.workspace,
);
return AgentExecutionResult {
event: PipelineEvent::agent_timed_out(
config.role,
agent_name.clone(),
output_kind,
Some(config.logfile.to_string()),
result.child_status_at_timeout,
),
session_id: None,
};
}
let retriable = is_retriable_agent_error(&error_kind);
AgentExecutionResult {
event: PipelineEvent::agent_invocation_failed(
config.role,
agent_name.clone(),
exit_code,
error_kind,
retriable,
),
session_id: None,
}
}
fn build_error_preview(message: &str, max_chars: usize) -> String {
message.chars().take(max_chars).collect()
}
const MEANINGFUL_OUTPUT_THRESHOLD: usize = 10;
fn determine_timeout_output_kind(
logfile_path: &str,
completion_output_path: Option<&std::path::Path>,
workspace: &dyn Workspace,
) -> TimeoutOutputKind {
if let Some(path) = completion_output_path {
return if workspace.exists(path) {
TimeoutOutputKind::PartialResult
} else {
TimeoutOutputKind::NoResult
};
}
let Some(content) = workspace.read(std::path::Path::new(logfile_path)).ok() else {
return TimeoutOutputKind::NoResult;
};
let non_whitespace_count = content.chars().filter(|c| !c.is_whitespace()).count();
if non_whitespace_count >= MEANINGFUL_OUTPUT_THRESHOLD {
TimeoutOutputKind::PartialResult
} else {
TimeoutOutputKind::NoResult
}
}