Files
optimclaw/src/worker/container.rs
T
6daa2f155f fix: ensure LLM calls always end with user message (closes #763) (#1259)
* fix: ensure LLM calls always end with user message (closes #763)

Claude 4.6 models (claude-sonnet-4-6, claude-opus-4-6) no longer support
assistant message prefill — any LLM call where the conversation ends on an
assistant message is rejected with HTTP 400 "This model does not support
assistant message prefill".

The same root cause also triggers NEAR AI's "No user query found in messages"
400 error for the routine engine path.

Two fixes:

1. src/worker/container.rs — before_llm_call()
   After poll_and_inject_prompt(), if no user follow-up arrived and
   handle_text_response() left an assistant message at the end of the
   conversation, inject a sentinel "Continue." user message before
   the next LLM call.

2. src/agent/routine_engine.rs — execute_lightweight_with_tools()
   Before the force_text final completion call, ensure messages end
   with a user-role message. Tool result messages (Role::Tool) satisfy
   Anthropic but not NEAR AI; assistant messages satisfy neither.

Also updates the worker system prompt to instruct the agent to include
the phrase "The job is complete" in its final message, so the agentic
loop can detect termination reliably.

Tested with claude-sonnet-4-6 and claude-opus-4-6.
Workaround: ANTHROPIC_MODEL=claude-sonnet-4-20250514 (still supports prefill).

* fix: broaden sentinel guard to any non-user message (per review)

Gemini suggested the Role::Assistant check in before_llm_call() is too
specific. Changed to !Role::User to match the routine_engine.rs fix and
cover tool results too.

* fix: address zmanian review — JobDelegate sentinel, shared helper, NearAI complete() flattening

- Extract ensure_ends_with_user_message() to src/util.rs with 4 unit tests
  (empty list, after assistant, after tool result, no-op when already user)
- Add sentinel guard to JobDelegate::before_llm_call() in src/worker/job.rs
  so scheduler jobs (CreateJob / /job path) no longer hit Claude 4.6 / NEAR AI 400s
- Replace inline guards in ContainerDelegate and routine_engine.rs with the
  shared helper — all 3 call sites now use one implementation
- Fix complete() in nearai_chat.rs to apply flatten_tool_messages when
  flatten_tool_messages=true — previously only complete_with_tools() flattened,
  so force_text paths could still send role:"tool" messages to NEAR AI
- Update stale comment in container.rs: "assistant message" → "non-user message"
- Add flatten tests in nearai_chat.rs covering the complete() path

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* ci: fix fmt and tar advisory

---------

Co-authored-by: Jacob Lasky <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
Co-authored-by: firat.sertgoz <[email protected]>
2026-03-25 10:31:44 +03:00

549 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. When finished, your final message MUST include the phrase "The job is complete" to signal termination."#,
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;
// Claude 4.6 rejects assistant prefill; NEAR AI rejects any non-user-ending
// conversation. Ensure the last message is user-role before calling the LLM.
crate::util::ensure_ends_with_user_message(&mut reason_ctx.messages);
// 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, "...");
}
}