Extract agent, compact, embed, task, and modes modules from single service.rs files into focused sub-modules. Add orao module for O1-like reasoning loop. Move RigAgentService to rig_tool.rs.
203 lines
6.9 KiB
Rust
203 lines
6.9 KiB
Rust
//! Act phase: execute planned actions with safety checks.
|
|
//!
|
|
//! Actions are executed through a caller-provided executor callback, which
|
|
//! typically dispatches to the [`ToolRegistry`] or runs shell commands.
|
|
//! All file access must go through function calls (tools), never direct
|
|
//! filesystem operations.
|
|
//!
|
|
//! [`ToolRegistry`]: crate::tool::ToolRegistry
|
|
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
use std::process::Command;
|
|
use std::time::Duration;
|
|
|
|
use super::types::{
|
|
ActionType, ActionResult, ActionVerdict, OraoConfig, PlannedAction, SafetyLevel,
|
|
};
|
|
|
|
/// Callback for executing a planned action.
|
|
///
|
|
/// The caller (service layer) provides this to wire up tool execution.
|
|
/// Returns `ActionResult` on completion.
|
|
pub type ActionExecutor = Box<
|
|
dyn Fn(
|
|
PlannedAction,
|
|
) -> Pin<Box<dyn Future<Output = ActionResult> + Send>>
|
|
+ Send
|
|
+ Sync,
|
|
>;
|
|
|
|
/// Check whether an action is allowed under the given safety configuration.
|
|
///
|
|
/// Returns `None` if allowed, or `Some(reason)` if blocked.
|
|
pub fn check_safety(action: &PlannedAction, config: &OraoConfig) -> Option<String> {
|
|
let safety = SafetyLevel::classify_command(&action.command_or_content);
|
|
|
|
if safety > config.max_safety_level {
|
|
return Some(format!(
|
|
"Action denied: safety level {:?} exceeds max allowed {:?}",
|
|
safety, config.max_safety_level
|
|
));
|
|
}
|
|
|
|
// Check for dangerous command patterns
|
|
if let Some(reason) = check_dangerous_command(&action.command_or_content) {
|
|
return Some(reason);
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Execute a single planned action via the provided executor.
|
|
///
|
|
/// Applies safety checks and timeout, then delegates to the executor.
|
|
pub async fn execute_action(
|
|
action: PlannedAction,
|
|
config: &OraoConfig,
|
|
executor: &ActionExecutor,
|
|
) -> ActionResult {
|
|
// ── Safety gate ────────────────────────────────────────────────────
|
|
if let Some(reason) = check_safety(&action, config) {
|
|
return ActionResult {
|
|
action,
|
|
exit_code: Some(1),
|
|
stdout: String::new(),
|
|
stderr: reason,
|
|
file_changes: Vec::new(),
|
|
verdict: ActionVerdict::Failure,
|
|
};
|
|
}
|
|
|
|
// ── Execute with timeout ──────────────────────────────────────────
|
|
let action_clone = action.clone();
|
|
let exec_future = executor(action);
|
|
|
|
match tokio::time::timeout(
|
|
Duration::from_secs(config.action_timeout_secs),
|
|
exec_future,
|
|
)
|
|
.await
|
|
{
|
|
Ok(result) => result,
|
|
Err(_elapsed) => ActionResult {
|
|
action: action_clone,
|
|
exit_code: None,
|
|
stdout: String::new(),
|
|
stderr: format!(
|
|
"Action timed out after {} seconds",
|
|
config.action_timeout_secs
|
|
),
|
|
file_changes: Vec::new(),
|
|
verdict: ActionVerdict::Failure,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Build a default action executor that runs shell commands directly.
|
|
///
|
|
/// This is suitable for `shell_command` and `git_operation` action types.
|
|
/// For `tool_invoke`, the caller should provide a custom executor that
|
|
/// dispatches to the [`ToolRegistry`].
|
|
///
|
|
/// [`ToolRegistry`]: crate::tool::ToolRegistry
|
|
pub fn shell_executor(working_dir: String) -> ActionExecutor {
|
|
Box::new(move |action: PlannedAction| {
|
|
let dir = working_dir.clone();
|
|
Box::pin(async move {
|
|
match action.action_type {
|
|
ActionType::ShellCommand
|
|
| ActionType::GitOperation
|
|
| ActionType::ToolInvoke => run_shell_command(&action, &dir).await,
|
|
ActionType::FileWrite | ActionType::FileEdit => {
|
|
// File operations should use tool_invoke with a file-writing tool.
|
|
// Direct file access is discouraged; return an error directing to tools.
|
|
ActionResult {
|
|
exit_code: Some(1),
|
|
stdout: String::new(),
|
|
stderr: "File operations must use tool_invoke with registered file tools. Use shell_command with sed/echo for inline edits.".to_string(),
|
|
file_changes: Vec::new(),
|
|
verdict: ActionVerdict::Failure,
|
|
action,
|
|
}
|
|
}
|
|
ActionType::UserDialog => ActionResult {
|
|
exit_code: None,
|
|
stdout: "User dialog requested".to_string(),
|
|
stderr: String::new(),
|
|
file_changes: Vec::new(),
|
|
verdict: ActionVerdict::Success,
|
|
action,
|
|
},
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
async fn run_shell_command(action: &PlannedAction, working_dir: &str) -> ActionResult {
|
|
let cmd = &action.command_or_content;
|
|
|
|
let output = Command::new("sh")
|
|
.args(["-c", cmd])
|
|
.current_dir(working_dir)
|
|
.output();
|
|
|
|
match output {
|
|
Ok(out) => {
|
|
let exit_code = out.status.code();
|
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
|
|
|
let verdict = match exit_code {
|
|
Some(0) if !stderr_has_errors(&stderr) => ActionVerdict::Success,
|
|
Some(0) => ActionVerdict::SuccessWithWarnings,
|
|
_ => ActionVerdict::Failure,
|
|
};
|
|
|
|
ActionResult {
|
|
action: action.clone(),
|
|
exit_code,
|
|
stdout,
|
|
stderr,
|
|
file_changes: Vec::new(),
|
|
verdict,
|
|
}
|
|
}
|
|
Err(e) => ActionResult {
|
|
action: action.clone(),
|
|
exit_code: None,
|
|
stdout: String::new(),
|
|
stderr: format!("Failed to spawn command: {}", e),
|
|
file_changes: Vec::new(),
|
|
verdict: ActionVerdict::Failure,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn stderr_has_errors(stderr: &str) -> bool {
|
|
let lower = stderr.to_lowercase();
|
|
lower.contains("error") || lower.contains("fail") || lower.contains("panic")
|
|
}
|
|
|
|
/// Check whether a shell command contains dangerous patterns.
|
|
///
|
|
/// Returns `Some(reason)` if the command is blocked, `None` if it's safe.
|
|
pub fn check_dangerous_command(cmd: &str) -> Option<String> {
|
|
let dangerous = [
|
|
("rm -rf /", "Recursive root deletion"),
|
|
("rm -rf ~", "Recursive home deletion"),
|
|
(":(){ :|:& };:", "Fork bomb"),
|
|
("mkfs.", "Filesystem format"),
|
|
("dd if=", "Raw device write"),
|
|
("> /dev/sda", "Raw device write"),
|
|
("chmod 777 /", "World-writable root"),
|
|
];
|
|
|
|
for (pattern, reason) in &dangerous {
|
|
if cmd.contains(pattern) {
|
|
return Some(format!("Blocked: {} — {}", pattern, reason));
|
|
}
|
|
}
|
|
|
|
None
|
|
} |