//! Worker runtime: the main execution loop inside a container. //! //! Reuses the existing `Reasoning` and `SafetyLayer` infrastructure but //! connects to the orchestrator for LLM calls instead of calling APIs directly. //! Streams real-time events (message, tool_use, tool_result, result) through //! the orchestrator's job event pipeline for UI visibility. //! //! Uses the shared `AgenticLoop` engine via `ContainerDelegate`. use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; use tokio::sync::Mutex; use uuid::Uuid; use crate::agent::agentic_loop::{ AgenticLoopConfig, LoopDelegate, LoopOutcome, LoopSignal, TextAction, truncate_for_preview, }; use crate::config::SafetyConfig; use crate::context::JobContext; use crate::error::WorkerError; use crate::llm::{ChatMessage, LlmProvider, Reasoning, ReasoningContext}; use crate::safety::SafetyLayer; use crate::tools::ToolRegistry; use crate::tools::execute::{execute_tool_simple, process_tool_result}; use crate::worker::api::{CompletionReport, JobEventPayload, StatusUpdate, WorkerHttpClient}; use crate::worker::proxy_llm::ProxyLlmProvider; /// Configuration for the worker runtime. pub struct WorkerConfig { pub job_id: Uuid, pub orchestrator_url: String, pub max_iterations: u32, pub timeout: Duration, } impl Default for WorkerConfig { fn default() -> Self { Self { job_id: Uuid::nil(), orchestrator_url: String::new(), max_iterations: 50, timeout: Duration::from_secs(600), } } } /// The worker runtime runs inside a Docker container. /// /// It connects to the orchestrator over HTTP, fetches its job description, /// then runs a tool execution loop until the job is complete. Events are /// streamed to the orchestrator so the UI can show real-time progress. pub struct WorkerRuntime { config: WorkerConfig, client: Arc, llm: Arc, safety: Arc, tools: Arc, /// Credentials fetched from the orchestrator, injected into child processes /// via `Command::envs()` rather than mutating the global process environment. /// /// Wrapped in `Arc` to avoid deep-cloning the map on every tool invocation. extra_env: Arc>, } impl WorkerRuntime { /// Create a new worker runtime. /// /// Reads `IRONCLAW_WORKER_TOKEN` from the environment for auth. pub fn new(config: WorkerConfig) -> Result { let client = Arc::new(WorkerHttpClient::from_env( config.orchestrator_url.clone(), config.job_id, )?); let llm: Arc = Arc::new(ProxyLlmProvider::new( Arc::clone(&client), "proxied".to_string(), )); let safety = Arc::new(SafetyLayer::new(&SafetyConfig { max_output_length: 100_000, injection_check_enabled: true, })); let tools = Arc::new(ToolRegistry::new()); // Register only container-safe tools tools.register_container_tools(); Ok(Self { config, client, llm, safety, tools, extra_env: Arc::new(HashMap::new()), }) } /// Run the worker until the job is complete or an error occurs. pub async fn run(mut self) -> Result<(), WorkerError> { tracing::info!("Worker starting for job {}", self.config.job_id); // Fetch job description from orchestrator let job = self.client.get_job().await?; tracing::info!( "Received job: {} - {}", job.title, truncate_for_preview(&job.description, 100) ); // Fetch credentials and store them for injection into child processes // via Command::envs() (avoids unsafe std::env::set_var in multi-threaded runtime). let credentials = self.client.fetch_credentials().await?; { let mut env_map = HashMap::new(); for cred in &credentials { env_map.insert(cred.env_var.clone(), cred.value.clone()); } self.extra_env = Arc::new(env_map); } if !credentials.is_empty() { tracing::info!( "Fetched {} credential(s) for child process injection", credentials.len() ); } // Report that we're starting self.client .report_status(&StatusUpdate { state: "in_progress".to_string(), message: Some("Worker started, beginning execution".to_string()), iteration: 0, }) .await?; // Create reasoning engine let reasoning = Reasoning::new(self.llm.clone()); // Build initial context let mut reason_ctx = ReasoningContext::new().with_job(&job.description); reason_ctx.messages.push(ChatMessage::system(format!( r#"You are an autonomous agent running inside a Docker container. Job: {} Description: {} You have tools for shell commands, file operations, and code editing. Work independently to complete this job. Report when done."#, job.title, job.description ))); // Load tool definitions reason_ctx.available_tools = self.tools.tool_definitions().await; // Shared iteration tracker — read after the loop to report accurate counts. let iteration_tracker = Arc::new(Mutex::new(0u32)); // Run with timeout using the shared agentic loop let result = tokio::time::timeout(self.config.timeout, async { let delegate = ContainerDelegate { client: self.client.clone(), safety: self.safety.clone(), tools: self.tools.clone(), extra_env: self.extra_env.clone(), last_output: Mutex::new(String::new()), iteration_tracker: iteration_tracker.clone(), }; let config = AgenticLoopConfig { max_iterations: self.config.max_iterations as usize, enable_tool_intent_nudge: true, max_tool_intent_nudges: 2, }; crate::agent::agentic_loop::run_agentic_loop( &delegate, &reasoning, &mut reason_ctx, &config, ) .await }) .await; let iterations = *iteration_tracker.lock().await; match result { Ok(Ok(LoopOutcome::Response(output))) => { tracing::info!("Worker completed job {} successfully", self.config.job_id); self.post_event( "result", serde_json::json!({ "success": true, "message": truncate_for_preview(&output, 2000), }), ) .await; self.client .report_complete(&CompletionReport { success: true, message: Some(output), iterations, }) .await?; } Ok(Ok(LoopOutcome::MaxIterations)) => { let msg = format!("max iterations ({}) exceeded", self.config.max_iterations); tracing::warn!("Worker failed for job {}: {}", self.config.job_id, msg); self.post_event( "result", serde_json::json!({ "success": false, "message": format!("Execution failed: {}", msg), }), ) .await; self.client .report_complete(&CompletionReport { success: false, message: Some(format!("Execution failed: {}", msg)), iterations, }) .await?; } Ok(Ok(LoopOutcome::Stopped | LoopOutcome::NeedApproval(_))) => { tracing::info!("Worker for job {} stopped", self.config.job_id); self.client .report_complete(&CompletionReport { success: false, message: Some("Execution stopped".to_string()), iterations, }) .await?; } Ok(Err(e)) => { tracing::error!("Worker failed for job {}: {}", self.config.job_id, e); self.post_event( "result", serde_json::json!({ "success": false, "message": format!("Execution failed: {}", e), }), ) .await; self.client .report_complete(&CompletionReport { success: false, message: Some(format!("Execution failed: {}", e)), iterations, }) .await?; } Err(_) => { tracing::warn!("Worker timed out for job {}", self.config.job_id); self.post_event( "result", serde_json::json!({ "success": false, "message": "Execution timed out", }), ) .await; self.client .report_complete(&CompletionReport { success: false, message: Some("Execution timed out".to_string()), iterations, }) .await?; } } Ok(()) } /// Post a job event to the orchestrator (fire-and-forget). async fn post_event(&self, event_type: &str, data: serde_json::Value) { self.client .post_event(&JobEventPayload { event_type: event_type.to_string(), data, }) .await; } } /// Container delegate: implements `LoopDelegate` for the Docker container context. /// /// Tools execute sequentially. Events are posted to the orchestrator via HTTP. /// Completion is detected via `llm_signals_completion()`. struct ContainerDelegate { client: Arc, safety: Arc, tools: Arc, extra_env: Arc>, /// Tracks the last successful tool output for the final response. last_output: Mutex, /// Tracks the current iteration — shared with the outer `run` method so /// `CompletionReport` can include accurate iteration counts. iteration_tracker: Arc>, } impl ContainerDelegate { async fn post_event(&self, event_type: &str, data: serde_json::Value) { self.client .post_event(&JobEventPayload { event_type: event_type.to_string(), data, }) .await; } /// Poll the orchestrator for a follow-up prompt. If one is available, /// inject it as a user message into the reasoning context. async fn poll_and_inject_prompt(&self, reason_ctx: &mut ReasoningContext) { match self.client.poll_prompt().await { Ok(Some(prompt)) => { tracing::info!( "Received follow-up prompt: {}", truncate_for_preview(&prompt.content, 100) ); self.post_event( "message", serde_json::json!({ "role": "user", "content": truncate_for_preview(&prompt.content, 2000), }), ) .await; reason_ctx.messages.push(ChatMessage::user(&prompt.content)); } Ok(None) => {} Err(e) => { tracing::debug!("Failed to poll for prompt: {}", e); } } } } #[async_trait] impl LoopDelegate for ContainerDelegate { async fn check_signals(&self) -> LoopSignal { // Container runtime has no stop signals — the orchestrator manages lifecycle. LoopSignal::Continue } async fn before_llm_call( &self, reason_ctx: &mut ReasoningContext, iteration: usize, ) -> Option { let iteration = iteration as u32; *self.iteration_tracker.lock().await = iteration; // Report progress every 5 iterations if iteration % 5 == 1 { let _ = self .client .report_status(&StatusUpdate { state: "in_progress".to_string(), message: Some(format!("Iteration {}", iteration)), iteration, }) .await; } // Poll for follow-up prompts from the user self.poll_and_inject_prompt(reason_ctx).await; // Refresh tools (in case WASM tools were built) reason_ctx.available_tools = self.tools.tool_definitions().await; None } async fn call_llm( &self, reasoning: &Reasoning, reason_ctx: &mut ReasoningContext, _iteration: usize, ) -> Result { // Container uses respond_with_tools (which may return either text or tool calls) reasoning .respond_with_tools(reason_ctx) .await .map_err(Into::into) } async fn handle_text_response( &self, text: &str, reason_ctx: &mut ReasoningContext, ) -> TextAction { self.post_event( "message", serde_json::json!({ "role": "assistant", "content": truncate_for_preview(text, 2000), }), ) .await; // Check for completion if crate::util::llm_signals_completion(text) { let last = self.last_output.lock().await; let output = if last.is_empty() { text.to_string() } else { last.clone() }; return TextAction::Return(LoopOutcome::Response(output)); } reason_ctx.messages.push(ChatMessage::assistant(text)); TextAction::Continue } async fn execute_tool_calls( &self, tool_calls: Vec, content: Option, reason_ctx: &mut ReasoningContext, ) -> Result, crate::error::Error> { if let Some(ref text) = content { self.post_event( "message", serde_json::json!({ "role": "assistant", "content": truncate_for_preview(text, 2000), }), ) .await; } // Add assistant message with tool_calls (OpenAI protocol) reason_ctx .messages .push(ChatMessage::assistant_with_tool_calls( content, tool_calls.clone(), )); // Execute tools sequentially (container context — no parallel execution) for tc in tool_calls { self.post_event( "tool_use", serde_json::json!({ "tool_name": tc.name, "input": truncate_for_preview(&tc.arguments.to_string(), 500), }), ) .await; let job_ctx = JobContext { extra_env: self.extra_env.clone(), ..Default::default() }; let result = execute_tool_simple( &self.tools, &self.safety, &tc.name, tc.arguments.clone(), &job_ctx, ) .await; self.post_event( "tool_result", serde_json::json!({ "tool_name": tc.name, "output": match &result { Ok(output) => truncate_for_preview(output, 2000), Err(e) => format!("Error: {}", truncate_for_preview(e, 500)).into(), }, "success": result.is_ok(), }), ) .await; if let Ok(ref output) = result { *self.last_output.lock().await = output.clone(); } // Use shared result processing let (_, message) = process_tool_result(&self.safety, &tc.name, &tc.id, &result); reason_ctx.messages.push(message); } Ok(None) } async fn on_tool_intent_nudge(&self, text: &str, _reason_ctx: &mut ReasoningContext) { self.post_event( "message", serde_json::json!({ "role": "assistant", "content": truncate_for_preview(text, 2000), "nudge": true, }), ) .await; } async fn after_iteration(&self, _iteration: usize) { // Brief pause between iterations tokio::time::sleep(Duration::from_millis(100)).await; } } #[cfg(test)] mod tests { use crate::agent::agentic_loop::truncate_for_preview; #[test] fn test_truncate_within_limit() { assert_eq!(truncate_for_preview("hello", 10), "hello"); } #[test] fn test_truncate_at_limit() { assert_eq!(truncate_for_preview("hello", 5), "hello"); } #[test] fn test_truncate_beyond_limit() { let result = truncate_for_preview("hello world", 5); assert_eq!(result, "hello..."); } #[test] fn test_truncate_multibyte_safe() { // "é" is 2 bytes in UTF-8; slicing at byte 1 would panic without safety let result = truncate_for_preview("é is fancy", 1); // Should truncate to 0 chars (can't fit "é" in 1 byte) assert_eq!(result, "..."); } }