Merge remote-tracking branch 'origin/main'

This commit is contained in:
Illia Polosukhin
2026-02-04 22:11:37 -08:00
24 changed files with 4015 additions and 171 deletions
+417
View File
@@ -0,0 +1,417 @@
//! Job management tools.
//!
//! These tools allow the LLM to manage jobs:
//! - Create new jobs/tasks
//! - List existing jobs
//! - Check job status
//! - Cancel running jobs
use std::sync::Arc;
use async_trait::async_trait;
use uuid::Uuid;
use crate::context::{ContextManager, JobContext, JobState};
use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for creating a new job.
pub struct CreateJobTool {
context_manager: Arc<ContextManager>,
}
impl CreateJobTool {
pub fn new(context_manager: Arc<ContextManager>) -> Self {
Self { context_manager }
}
}
#[async_trait]
impl Tool for CreateJobTool {
fn name(&self) -> &str {
"create_job"
}
fn description(&self) -> &str {
"Create a new job or task for the agent to work on. Use this when the user wants \
you to do something substantial that should be tracked as a separate job."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "A short title for the job (max 100 chars)"
},
"description": {
"type": "string",
"description": "Full description of what needs to be done"
}
},
"required": ["title", "description"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let title = params
.get("title")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'title' parameter".into()))?;
let description = params
.get("description")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'description' parameter".into())
})?;
match self.context_manager.create_job(title, description).await {
Ok(job_id) => {
let result = serde_json::json!({
"job_id": job_id.to_string(),
"title": title,
"status": "pending",
"message": format!("Created job '{}'", title)
});
Ok(ToolOutput::success(result, start.elapsed()))
}
Err(e) => {
let result = serde_json::json!({
"error": e.to_string()
});
Ok(ToolOutput::success(result, start.elapsed()))
}
}
}
fn requires_sanitization(&self) -> bool {
false
}
}
/// Tool for listing jobs.
pub struct ListJobsTool {
context_manager: Arc<ContextManager>,
}
impl ListJobsTool {
pub fn new(context_manager: Arc<ContextManager>) -> Self {
Self { context_manager }
}
}
#[async_trait]
impl Tool for ListJobsTool {
fn name(&self) -> &str {
"list_jobs"
}
fn description(&self) -> &str {
"List all jobs or filter by status. Shows job IDs, titles, and current status."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"filter": {
"type": "string",
"description": "Filter by status: 'active', 'completed', 'failed', 'all' (default: 'all')",
"enum": ["active", "completed", "failed", "all"]
}
}
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let filter = params
.get("filter")
.and_then(|v| v.as_str())
.unwrap_or("all");
let job_ids = match filter {
"active" => self.context_manager.active_jobs().await,
_ => self.context_manager.all_jobs().await,
};
let mut jobs = Vec::new();
for job_id in job_ids {
if let Ok(ctx) = self.context_manager.get_context(job_id).await {
let include = match filter {
"completed" => ctx.state == JobState::Completed,
"failed" => ctx.state == JobState::Failed,
"active" => ctx.state.is_active(),
_ => true,
};
if include {
jobs.push(serde_json::json!({
"job_id": job_id.to_string(),
"title": ctx.title,
"status": format!("{:?}", ctx.state),
"created_at": ctx.created_at.to_rfc3339()
}));
}
}
}
let summary = self.context_manager.summary().await;
let result = serde_json::json!({
"jobs": jobs,
"summary": {
"total": summary.total,
"pending": summary.pending,
"in_progress": summary.in_progress,
"completed": summary.completed,
"failed": summary.failed
}
});
Ok(ToolOutput::success(result, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false
}
}
/// Tool for checking job status.
pub struct JobStatusTool {
context_manager: Arc<ContextManager>,
}
impl JobStatusTool {
pub fn new(context_manager: Arc<ContextManager>) -> Self {
Self { context_manager }
}
}
#[async_trait]
impl Tool for JobStatusTool {
fn name(&self) -> &str {
"job_status"
}
fn description(&self) -> &str {
"Check the status and details of a specific job by its ID."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "The UUID of the job to check"
}
},
"required": ["job_id"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let job_id_str = params
.get("job_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'job_id' parameter".into()))?;
let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
})?;
match self.context_manager.get_context(job_id).await {
Ok(ctx) => {
let result = serde_json::json!({
"job_id": job_id.to_string(),
"title": ctx.title,
"description": ctx.description,
"status": format!("{:?}", ctx.state),
"created_at": ctx.created_at.to_rfc3339(),
"started_at": ctx.started_at.map(|t| t.to_rfc3339()),
"completed_at": ctx.completed_at.map(|t| t.to_rfc3339()),
"actual_cost": ctx.actual_cost.to_string()
});
Ok(ToolOutput::success(result, start.elapsed()))
}
Err(e) => {
let result = serde_json::json!({
"error": format!("Job not found: {}", e)
});
Ok(ToolOutput::success(result, start.elapsed()))
}
}
}
fn requires_sanitization(&self) -> bool {
false
}
}
/// Tool for canceling a job.
pub struct CancelJobTool {
context_manager: Arc<ContextManager>,
}
impl CancelJobTool {
pub fn new(context_manager: Arc<ContextManager>) -> Self {
Self { context_manager }
}
}
#[async_trait]
impl Tool for CancelJobTool {
fn name(&self) -> &str {
"cancel_job"
}
fn description(&self) -> &str {
"Cancel a running or pending job. The job will be marked as cancelled and stopped."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "The UUID of the job to cancel"
}
},
"required": ["job_id"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let job_id_str = params
.get("job_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'job_id' parameter".into()))?;
let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
})?;
// Transition to cancelled state
match self
.context_manager
.update_context(job_id, |ctx| {
ctx.transition_to(JobState::Cancelled, Some("Cancelled by user".to_string()))
})
.await
{
Ok(Ok(())) => {
let result = serde_json::json!({
"job_id": job_id.to_string(),
"status": "cancelled",
"message": "Job cancelled successfully"
});
Ok(ToolOutput::success(result, start.elapsed()))
}
Ok(Err(reason)) => {
let result = serde_json::json!({
"error": format!("Cannot cancel job: {}", reason)
});
Ok(ToolOutput::success(result, start.elapsed()))
}
Err(e) => {
let result = serde_json::json!({
"error": format!("Job not found: {}", e)
});
Ok(ToolOutput::success(result, start.elapsed()))
}
}
}
fn requires_approval(&self) -> bool {
true // Canceling a job should require approval
}
fn requires_sanitization(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_create_job_tool() {
let manager = Arc::new(ContextManager::new(5));
let tool = CreateJobTool::new(manager.clone());
let params = serde_json::json!({
"title": "Test Job",
"description": "A test job description"
});
let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap();
let job_id = result.result.get("job_id").unwrap().as_str().unwrap();
assert!(!job_id.is_empty());
}
#[tokio::test]
async fn test_list_jobs_tool() {
let manager = Arc::new(ContextManager::new(5));
// Create some jobs
manager.create_job("Job 1", "Desc 1").await.unwrap();
manager.create_job("Job 2", "Desc 2").await.unwrap();
let tool = ListJobsTool::new(manager);
let params = serde_json::json!({});
let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap();
let jobs = result.result.get("jobs").unwrap().as_array().unwrap();
assert_eq!(jobs.len(), 2);
}
#[tokio::test]
async fn test_job_status_tool() {
let manager = Arc::new(ContextManager::new(5));
let job_id = manager.create_job("Test Job", "Description").await.unwrap();
let tool = JobStatusTool::new(manager);
let params = serde_json::json!({
"job_id": job_id.to_string()
});
let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap();
assert_eq!(
result.result.get("title").unwrap().as_str().unwrap(),
"Test Job"
);
}
}
+2
View File
@@ -4,6 +4,7 @@ mod echo;
mod ecommerce;
mod file;
mod http;
mod job;
mod json;
mod marketplace;
mod memory;
@@ -16,6 +17,7 @@ pub use echo::EchoTool;
pub use ecommerce::EcommerceTool;
pub use file::{ApplyPatchTool, ListDirTool, ReadFileTool, WriteFileTool};
pub use http::HttpTool;
pub use job::{CancelJobTool, CreateJobTool, JobStatusTool, ListJobsTool};
pub use json::JsonTool;
pub use marketplace::MarketplaceTool;
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool};
+145 -30
View File
@@ -1,15 +1,27 @@
//! Shell execution tool for running commands in a sandboxed environment.
//!
//! Provides controlled command execution with:
//! - Docker sandbox isolation (when enabled)
//! - Working directory isolation
//! - Timeout enforcement
//! - Output capture and truncation
//! - Blocked command patterns for safety
//!
//! # Execution Modes
//!
//! When sandbox is available and enabled:
//! - Commands run inside ephemeral Docker containers
//! - Network traffic goes through a validating proxy
//! - Credentials are injected by the proxy, never exposed to commands
//!
//! When sandbox is unavailable:
//! - Commands run directly on host with basic protections
//! - Blocked command patterns are still enforced
use std::collections::HashSet;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::LazyLock;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use async_trait::async_trait;
@@ -17,6 +29,7 @@ use tokio::io::AsyncReadExt;
use tokio::process::Command;
use crate::context::JobContext;
use crate::sandbox::{SandboxManager, SandboxPolicy};
use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Maximum output size before truncation (64KB).
@@ -62,7 +75,6 @@ static DANGEROUS_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
});
/// Shell command execution tool.
#[derive(Debug)]
pub struct ShellTool {
/// Working directory for commands (if None, uses job's working dir or cwd).
working_dir: Option<PathBuf>,
@@ -70,6 +82,22 @@ pub struct ShellTool {
timeout: Duration,
/// Whether to allow potentially dangerous commands (requires explicit approval).
allow_dangerous: bool,
/// Optional sandbox manager for Docker execution.
sandbox: Option<Arc<SandboxManager>>,
/// Sandbox policy to use when sandbox is available.
sandbox_policy: SandboxPolicy,
}
impl std::fmt::Debug for ShellTool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ShellTool")
.field("working_dir", &self.working_dir)
.field("timeout", &self.timeout)
.field("allow_dangerous", &self.allow_dangerous)
.field("sandbox", &self.sandbox.is_some())
.field("sandbox_policy", &self.sandbox_policy)
.finish()
}
}
impl ShellTool {
@@ -79,6 +107,8 @@ impl ShellTool {
working_dir: None,
timeout: DEFAULT_TIMEOUT,
allow_dangerous: false,
sandbox: None,
sandbox_policy: SandboxPolicy::ReadOnly,
}
}
@@ -94,6 +124,18 @@ impl ShellTool {
self
}
/// Enable sandbox execution with the given manager.
pub fn with_sandbox(mut self, sandbox: Arc<SandboxManager>) -> Self {
self.sandbox = Some(sandbox);
self
}
/// Set the sandbox policy.
pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self {
self.sandbox_policy = policy;
self
}
/// Check if a command is blocked.
fn is_blocked(&self, cmd: &str) -> Option<&'static str> {
let normalized = cmd.to_lowercase();
@@ -115,28 +157,44 @@ impl ShellTool {
None
}
/// Execute a command and capture output.
async fn execute_command(
/// Execute a command through the sandbox.
async fn execute_sandboxed(
&self,
sandbox: &SandboxManager,
cmd: &str,
workdir: &Path,
timeout: Duration,
) -> Result<(String, i64), ToolError> {
// Override sandbox config timeout if needed
let result = tokio::time::timeout(timeout, async {
sandbox
.execute_with_policy(
cmd,
workdir,
self.sandbox_policy,
std::collections::HashMap::new(),
)
.await
})
.await;
match result {
Ok(Ok(output)) => {
let combined = truncate_output(&output.output);
Ok((combined, output.exit_code))
}
Ok(Err(e)) => Err(ToolError::ExecutionFailed(format!("Sandbox error: {}", e))),
Err(_) => Err(ToolError::Timeout(timeout)),
}
}
/// Execute a command directly (fallback when sandbox unavailable).
async fn execute_direct(
&self,
cmd: &str,
workdir: Option<&str>,
timeout: Option<u64>,
workdir: &PathBuf,
timeout: Duration,
) -> Result<(String, i32), ToolError> {
// Check for blocked commands
if let Some(reason) = self.is_blocked(cmd) {
return Err(ToolError::NotAuthorized(format!(
"{}: {}",
reason,
truncate_for_error(cmd)
)));
}
// Determine working directory
let cwd = workdir
.map(PathBuf::from)
.or_else(|| self.working_dir.clone())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
// Build command
let mut command = if cfg!(target_os = "windows") {
let mut c = Command::new("cmd");
@@ -149,7 +207,7 @@ impl ShellTool {
};
command
.current_dir(&cwd)
.current_dir(workdir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
@@ -159,11 +217,8 @@ impl ShellTool {
.spawn()
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to spawn command: {}", e)))?;
// Determine timeout
let timeout_duration = timeout.map(Duration::from_secs).unwrap_or(self.timeout);
// Wait with timeout
let result = tokio::time::timeout(timeout_duration, async {
let result = tokio::time::timeout(timeout, async {
let status = child.wait().await?;
// Read stdout
@@ -204,10 +259,56 @@ impl ShellTool {
Err(_) => {
// Timeout - try to kill the process
let _ = child.kill().await;
Err(ToolError::Timeout(timeout_duration))
Err(ToolError::Timeout(timeout))
}
}
}
/// Execute a command, using sandbox if available.
async fn execute_command(
&self,
cmd: &str,
workdir: Option<&str>,
timeout: Option<u64>,
) -> Result<(String, i64), ToolError> {
// Check for blocked commands
if let Some(reason) = self.is_blocked(cmd) {
return Err(ToolError::NotAuthorized(format!(
"{}: {}",
reason,
truncate_for_error(cmd)
)));
}
// Determine working directory
let cwd = workdir
.map(PathBuf::from)
.or_else(|| self.working_dir.clone())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
// Determine timeout
let timeout_duration = timeout.map(Duration::from_secs).unwrap_or(self.timeout);
// Try sandbox execution if available
if let Some(ref sandbox) = self.sandbox {
if sandbox.is_initialized() || sandbox.config().enabled {
match self
.execute_sandboxed(sandbox, cmd, &cwd, timeout_duration)
.await
{
Ok((output, code)) => return Ok((output, code)),
Err(e) => {
// Log sandbox failure and fall through to direct execution
tracing::warn!("Sandbox execution failed, falling back to direct: {}", e);
}
}
}
}
// Fallback to direct execution
let (output, code) = self.execute_direct(cmd, &cwd, timeout_duration).await?;
Ok((output, code as i64))
}
}
impl Default for ShellTool {
@@ -224,7 +325,8 @@ impl Tool for ShellTool {
fn description(&self) -> &str {
"Execute shell commands. Use for running builds, tests, git operations, and other CLI tasks. \
Commands run in a subprocess with captured output. Long-running commands have a timeout."
Commands run in a subprocess with captured output. Long-running commands have a timeout. \
When Docker sandbox is enabled, commands run in isolated containers for security."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -265,10 +367,13 @@ impl Tool for ShellTool {
let (output, exit_code) = self.execute_command(command, workdir, timeout).await?;
let duration = start.elapsed();
let sandboxed = self.sandbox.is_some();
let result = serde_json::json!({
"output": output,
"exit_code": exit_code,
"success": exit_code == 0
"success": exit_code == 0,
"sandboxed": sandboxed
});
Ok(ToolOutput::success(result, duration))
@@ -348,4 +453,14 @@ mod tests {
assert!(matches!(result, Err(ToolError::Timeout(_))));
}
#[test]
fn test_sandbox_policy_builder() {
let tool = ShellTool::new()
.with_sandbox_policy(SandboxPolicy::WorkspaceWrite)
.with_timeout(Duration::from_secs(60));
assert_eq!(tool.sandbox_policy, SandboxPolicy::WorkspaceWrite);
assert_eq!(tool.timeout, Duration::from_secs(60));
}
}
+17 -2
View File
@@ -5,12 +5,14 @@ use std::sync::Arc;
use tokio::sync::RwLock;
use crate::context::ContextManager;
use crate::llm::{LlmProvider, ToolDefinition};
use crate::safety::SafetyLayer;
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
use crate::tools::builtin::{
ApplyPatchTool, EchoTool, HttpTool, JsonTool, ListDirTool, MemoryReadTool, MemorySearchTool,
MemoryTreeTool, MemoryWriteTool, ReadFileTool, ShellTool, TimeTool, WriteFileTool,
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, HttpTool, JobStatusTool, JsonTool,
ListDirTool, ListJobsTool, MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool,
ReadFileTool, ShellTool, TimeTool, WriteFileTool,
};
use crate::tools::tool::Tool;
use crate::tools::wasm::{
@@ -144,6 +146,19 @@ impl ToolRegistry {
tracing::info!("Registered 4 memory tools");
}
/// Register job management tools.
///
/// Job tools allow the LLM to create, list, check status, and cancel jobs.
/// These enable natural language job management without hardcoded intent parsing.
pub fn register_job_tools(&self, context_manager: Arc<ContextManager>) {
self.register_sync(Arc::new(CreateJobTool::new(Arc::clone(&context_manager))));
self.register_sync(Arc::new(ListJobsTool::new(Arc::clone(&context_manager))));
self.register_sync(Arc::new(JobStatusTool::new(Arc::clone(&context_manager))));
self.register_sync(Arc::new(CancelJobTool::new(context_manager)));
tracing::info!("Registered 4 job management tools");
}
/// Register the software builder tool.
///
/// The builder tool allows the agent to create new software including WASM tools,