mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* perf(tools): remove unconditional params clone in shared execution * Update src/tools/execute.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * chore(fmt): apply rustfmt in worker container tool execution * fix(tools): restore owned param call sites * fix(tools): pass normalized_params to tool.execute() instead of raw params The ownership refactor accidentally passed the un-coerced `params` to `tool.execute()` while validation ran against the coerced `normalized_params`. This meant tools received un-normalized input (e.g. stringified JSON arrays instead of actual arrays). Since `normalized_params` is owned and unused after the execute call, passing it directly achieves the original zero-clone goal without breaking parameter coercion. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(tools): update empty-tool-name test for owned params signature Adapts the test_execute_empty_tool_name_returns_not_found test (added on staging) to pass owned Value instead of &Value, matching the new execute_tool_with_safety signature. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
545 lines
18 KiB
Rust
545 lines
18 KiB
Rust
//! 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<WorkerHttpClient>,
|
|
llm: Arc<dyn LlmProvider>,
|
|
safety: Arc<SafetyLayer>,
|
|
tools: Arc<ToolRegistry>,
|
|
/// 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<HashMap<String, String>>,
|
|
}
|
|
|
|
impl WorkerRuntime {
|
|
/// Create a new worker runtime.
|
|
///
|
|
/// Reads `IRONCLAW_WORKER_TOKEN` from the environment for auth.
|
|
pub fn new(config: WorkerConfig) -> Result<Self, WorkerError> {
|
|
let client = Arc::new(WorkerHttpClient::from_env(
|
|
config.orchestrator_url.clone(),
|
|
config.job_id,
|
|
)?);
|
|
|
|
let llm: Arc<dyn LlmProvider> = 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<WorkerHttpClient>,
|
|
safety: Arc<SafetyLayer>,
|
|
tools: Arc<ToolRegistry>,
|
|
extra_env: Arc<HashMap<String, String>>,
|
|
/// Tracks the last successful tool output for the final response.
|
|
last_output: Mutex<String>,
|
|
/// Tracks the current iteration — shared with the outer `run` method so
|
|
/// `CompletionReport` can include accurate iteration counts.
|
|
iteration_tracker: Arc<Mutex<u32>>,
|
|
}
|
|
|
|
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<LoopOutcome> {
|
|
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<crate::llm::RespondOutput, crate::error::Error> {
|
|
// 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<crate::llm::ToolCall>,
|
|
content: Option<String>,
|
|
reason_ctx: &mut ReasoningContext,
|
|
) -> Result<Option<LoopOutcome>, 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, "...");
|
|
}
|
|
}
|