mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 16:19:21 +00:00
* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787) GitHub Actions step-level `if:` doesn't have access to `secrets` context. Replace `if: secrets.X != ''` with `continue-on-error: true` and let the Set token step handle the fallback. Co-authored-by: Claude Sonnet 4.6 <[email protected]> * fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794) - Remove continue-on-error from staging-ci.yml app token steps (secrets are configured) - Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml already runs tests before promoting, promotion PR gets full CI on main) - Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs Co-authored-by: Claude Opus 4.6 <[email protected]> * fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802) - Remove branches:[main] filter from code_style.yml so it runs on all PRs - Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs) - Update rollup job to allow skipped clippy-windows - Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs) Co-authored-by: Claude Opus 4.6 <[email protected]> * feat: persist user_id in save_job and expose job_id on routine runs (#709) * feat: persist worker events to DB and fix activity tab rendering In-process Worker (used by Scheduler::dispatch_job) now persists events via save_job_event at key execution points: plan creation, LLM responses, tool_use, tool_result, and job completion/failure/stuck. Event data shapes match the container worker format so the gateway activity tab renders them correctly. Frontend: tool_result errors now show a red X icon with danger styling instead of a silent empty output. The result event falls back to the error field when message is absent. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: wire RoutineEngine into gateway for direct manual trigger firing Replace the message-channel hack in routines_trigger_handler with a direct call to RoutineEngine::fire_manual(), ensuring FullJob routines dispatch correctly when triggered from the web UI. Inject the engine into GatewayState from Agent::run after construction. Also persists user_id in save_job for both PG and libSQL backends, removes the source='sandbox' filter so all jobs are visible, and exposes job_id on RoutineRunInfo for the frontend job link. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: remove stale gateway_state argument from Agent::new test call sites The gateway_state parameter was removed from Agent::new during rebase (replaced by post-construction set_routine_engine_slot), but three test call sites still passed the extra None argument. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review — restore sandbox source filter, remove blank lines - Revert removal of `source = 'sandbox'` filter in all SandboxStore queries (8 sites across PG and libSQL). Sandbox-specific APIs should stay scoped to sandbox jobs; unified job listing for the Jobs tab should use a separate query path. - Remove extra blank lines in agent_loop.rs and worker.rs that caused formatting CI failure. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review — regenerate Cargo.lock, add user_id regression test - Regenerate Cargo.lock from main's lockfile to eliminate dependency version downgrades (anyhow, syn, etc.) that were churn from rebase. - Add regression test verifying user_id round-trips through save_job and get_job in the libSQL backend. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: remove trailing blank line in libsql jobs.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * test: add Postgres-side regression test for user_id persistence in save_job Mirrors the existing libSQL test (test_save_job_persists_user_id) for the Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore] since it requires a running PostgreSQL instance (integration tier). Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]> * refactor: unify three agentic loops into single AgenticLoop engine (#654) Replace three independent copy-pasted agentic loops (dispatcher, worker, container runtime) with a single shared engine in `agentic_loop.rs` that all consumers customize via the `LoopDelegate` trait. Phase 1 — Shared engine (`src/agent/agentic_loop.rs`, 205 lines): - `run_agentic_loop()` owns the core LLM → tool exec → repeat cycle - `LoopDelegate` trait (Send + Sync, &dyn dispatch) with 6 hook points - Tool intent nudge logic consolidated (was duplicated in 3 files) - Iteration limit + force-text behavior preserved Phase 2 — Three delegate implementations: - `ChatDelegate` (dispatcher.rs): 3-phase approval flow, hooks, cost guard, context compaction, skill attenuation, interruption - `JobDelegate` (worker/job.rs): planning pre-loop phase, parallel JoinSet exec, mark_completed/stuck/failed, SSE streaming, self-repair - `ContainerDelegate` (worker/container.rs): sequential tool exec, HTTP-proxied LLM, container-safe tools, credential injection Phase 3 — File moves and cleanup: - Delete `src/agent/worker.rs` — job logic moved to `src/worker/job.rs` - Rename `src/worker/runtime.rs` → `src/worker/container.rs` - Re-export `Worker`/`WorkerDeps` from `crate::worker` in `agent/mod.rs` - Update `scheduler.rs` imports to new worker location Shared helpers (`src/tools/execute.rs`): - `execute_tool_with_safety()` replaces 4 copies of validate → timeout → execute → serialize - `process_tool_result()` replaces 3 copies of sanitize → wrap → ChatMessage (also used by thread_ops.rs approval resume paths) Net result: -2,408 lines, zero duplicated loop logic, single code path for tool intent nudge and completion detection. Closes #654 Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review feedback from Copilot 1. scheduler.rs: Replace `unwrap_or` fallback with proper error propagation when parsing tool output JSON — surfaces bugs instead of silently changing the output type. 2. worker/job.rs: Drop MutexGuard before the cancellation `.await` in `check_signals()` to avoid holding a lock across an async I/O call (prevents `await_holding_lock` lint). 3. worker/job.rs: Restore consecutive rate-limit counter (MAX_CONSECUTIVE_RATE_LIMITS = 10) so sustained rate limiting marks the job stuck with "Persistent rate limiting" instead of silently burning through max_iterations. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: incorporate staging changes — token budget tracking + mark_failed Merge staging's changes into the refactored JobDelegate: - Add token budget tracking in call_llm (update_context/add_tokens) - mark_stuck → mark_failed for iteration cap and rate-limit exhaustion (aligns with staging's #788 fix) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address zmanian's PR review — eliminate type erasure, clean up Address all 6 review points from zmanian on PR #800: 1. Replace LoopOutcome::Custom(Box<dyn Any>) with typed LoopOutcome::NeedApproval(Box<PendingApproval>) — eliminates type erasure and downcast, resolves clippy large_enum_variant. 2. Remove dead max_tool_iterations field from ChatDelegate struct. 3. Add on_tool_intent_nudge() hook to LoopDelegate trait with implementations in Job and Container delegates for observability. 4. Fix SSE events in job worker to emit raw sanitized content instead of XML-wrapped <tool_output> tags. 5. Remove 4 duplicate completion tests from job.rs that were already covered by the shared util module. 6. Avoid logging full tool results — use result_size_bytes in debug logs (execute.rs, job.rs). Also updates path references in CLAUDE.md, COVERAGE_PLAN.md, and add-sse-event.md command. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(doctor): expand diagnostics from 7 to 16 health checks * test: add unit tests for agentic_loop and execute shared modules Add 16 tests covering the two new critical shared modules: agentic_loop.rs (10 tests): - Text response exits loop immediately - Tool call → text response continuation - LoopSignal::Stop exits before LLM call - LoopSignal::InjectMessage adds user message to context - Max iterations terminates with LoopOutcome::MaxIterations - Tool intent nudge fires twice then caps - before_llm_call early exit bypasses LLM - truncate_for_preview: short string, long string, multibyte safety execute.rs (6 tests): - execute_tool_with_safety success path - Missing tool returns ToolError::NotFound - Tool execution failure propagates - Per-tool timeout enforcement (50ms) - process_tool_result XML wrapping on success - process_tool_result error formatting All 2,777 unit tests pass, 0 clippy warnings. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address code review — 9 issues across agentic loop, job worker, container CRITICAL fixes: - Rate-limit exhaustion now returns Err(LlmError::RateLimited) instead of Ok(Text("")), stopping the loop immediately with no ghost iteration. Below-threshold retries still use Text("") with an explicit empty-string guard in handle_text_response to skip injection. - check_signals drains the entire message channel before returning, prioritizing Stop over UserMessage. Previously returned early on first UserMessage, silently dropping any queued Stop or additional messages. - check_signals now detects all non-progressing job states (Cancelled, Failed, Stuck, Completed, Submitted, Accepted) instead of only Cancelled and Failed. HIGH fixes: - Error path in process_tool_result_job applies truncate_for_preview to bound error strings in SSE/DB events (was unbounded). - Document Send+Sync lifetime constraint on LoopDelegate trait. - Test mock before_llm_call refactored from double-lock to single lock acquisition, eliminating deadlock risk on refactor. MEDIUM fixes: - CompletionReport includes actual iteration count via shared Arc<Mutex<u32>> tracker (was hardcoded 0). - process_tool_result_job return type changed from Result<bool> to Result<()> — the bool was always false (dead API). - Deduplicate truncate in container.rs; now uses truncate_for_preview from agentic_loop. Verified: 0 clippy warnings, 2781 tests pass, cargo fmt clean. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Henry Park <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: Illia Polosukhin <[email protected]> Co-authored-by: Umesh Kumar Singh <[email protected]> Co-authored-by: reidliu41 <[email protected]>
This commit is contained in:
co-authored by
Henry Park
Claude Sonnet 4.6
Illia Polosukhin
Umesh Kumar Singh
reidliu41
parent
ebb22094a5
commit
88f4894a18
@@ -0,0 +1,539 @@
|
||||
//! 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, &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)),
|
||||
},
|
||||
"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, "...");
|
||||
}
|
||||
}
|
||||
+1841
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -26,13 +26,15 @@
|
||||
|
||||
pub mod api;
|
||||
pub mod claude_bridge;
|
||||
pub mod container;
|
||||
pub mod job;
|
||||
pub mod proxy_llm;
|
||||
pub mod runtime;
|
||||
|
||||
pub use api::WorkerHttpClient;
|
||||
pub use claude_bridge::ClaudeBridgeRuntime;
|
||||
pub use container::WorkerRuntime;
|
||||
pub use job::{Worker, WorkerDeps};
|
||||
pub use proxy_llm::ProxyLlmProvider;
|
||||
pub use runtime::WorkerRuntime;
|
||||
|
||||
/// Run the Worker subcommand (inside Docker containers).
|
||||
pub async fn run_worker(
|
||||
@@ -46,7 +48,7 @@ pub async fn run_worker(
|
||||
orchestrator_url
|
||||
);
|
||||
|
||||
let config = runtime::WorkerConfig {
|
||||
let config = container::WorkerConfig {
|
||||
job_id,
|
||||
orchestrator_url: orchestrator_url.to_string(),
|
||||
max_iterations,
|
||||
|
||||
@@ -1,570 +0,0 @@
|
||||
//! 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.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::SafetyConfig;
|
||||
use crate::context::JobContext;
|
||||
use crate::error::WorkerError;
|
||||
use crate::llm::{
|
||||
ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
|
||||
};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::ToolRegistry;
|
||||
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(&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())
|
||||
.with_model_name(self.llm.active_model_name());
|
||||
|
||||
// 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
|
||||
)));
|
||||
|
||||
// Run with timeout
|
||||
let result = tokio::time::timeout(self.config.timeout, async {
|
||||
self.execution_loop(&reasoning, &mut reason_ctx).await
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) => {
|
||||
tracing::info!("Worker completed job {} successfully", self.config.job_id);
|
||||
self.post_event(
|
||||
"result",
|
||||
serde_json::json!({
|
||||
"success": true,
|
||||
"message": truncate(&output, 2000),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
self.client
|
||||
.report_complete(&CompletionReport {
|
||||
success: true,
|
||||
message: Some(output),
|
||||
iterations: 0,
|
||||
})
|
||||
.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: 0,
|
||||
})
|
||||
.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: 0,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execution_loop(
|
||||
&self,
|
||||
reasoning: &Reasoning,
|
||||
reason_ctx: &mut ReasoningContext,
|
||||
) -> Result<String, WorkerError> {
|
||||
let max_iterations = self.config.max_iterations;
|
||||
let mut last_output = String::new();
|
||||
const MAX_TOOL_INTENT_NUDGES: u32 = 2;
|
||||
let mut consecutive_tool_intent_nudges: u32 = 0;
|
||||
|
||||
// Load tool definitions
|
||||
reason_ctx.available_tools = self.tools.tool_definitions().await;
|
||||
|
||||
for iteration in 1..=max_iterations {
|
||||
// Report progress
|
||||
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;
|
||||
|
||||
// Ask the LLM what to do next
|
||||
let selections = reasoning.select_tools(reason_ctx).await.map_err(|e| {
|
||||
WorkerError::ExecutionFailed {
|
||||
reason: format!("tool selection failed: {}", e),
|
||||
}
|
||||
})?;
|
||||
|
||||
if selections.is_empty() {
|
||||
// No tools selected, try direct response
|
||||
let respond_result =
|
||||
reasoning
|
||||
.respond_with_tools(reason_ctx)
|
||||
.await
|
||||
.map_err(|e| WorkerError::ExecutionFailed {
|
||||
reason: format!("respond_with_tools failed: {}", e),
|
||||
})?;
|
||||
|
||||
match respond_result.result {
|
||||
RespondResult::Text(response) => {
|
||||
self.post_event(
|
||||
"message",
|
||||
serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": truncate(&response, 2000),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
if crate::util::llm_signals_completion(&response) {
|
||||
if last_output.is_empty() {
|
||||
last_output = response.clone();
|
||||
}
|
||||
return Ok(last_output);
|
||||
}
|
||||
reason_ctx.messages.push(ChatMessage::assistant(&response));
|
||||
|
||||
// Nudge the LLM if it expressed tool intent without calling tools
|
||||
let signals_intent = !reason_ctx.available_tools.is_empty()
|
||||
&& crate::llm::llm_signals_tool_intent(&response);
|
||||
if signals_intent && consecutive_tool_intent_nudges < MAX_TOOL_INTENT_NUDGES
|
||||
{
|
||||
consecutive_tool_intent_nudges += 1;
|
||||
tracing::info!(
|
||||
"LLM expressed tool intent without calling a tool, nudging"
|
||||
);
|
||||
reason_ctx
|
||||
.messages
|
||||
.push(ChatMessage::user(crate::llm::TOOL_INTENT_NUDGE));
|
||||
} else if !signals_intent {
|
||||
consecutive_tool_intent_nudges = 0;
|
||||
}
|
||||
}
|
||||
RespondResult::ToolCalls {
|
||||
tool_calls,
|
||||
content,
|
||||
} => {
|
||||
consecutive_tool_intent_nudges = 0;
|
||||
if let Some(ref text) = content {
|
||||
self.post_event(
|
||||
"message",
|
||||
serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": truncate(text, 2000),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Add assistant message with tool_calls (OpenAI protocol)
|
||||
reason_ctx
|
||||
.messages
|
||||
.push(ChatMessage::assistant_with_tool_calls(
|
||||
content,
|
||||
tool_calls.clone(),
|
||||
));
|
||||
|
||||
for tc in tool_calls {
|
||||
self.post_event(
|
||||
"tool_use",
|
||||
serde_json::json!({
|
||||
"tool_name": tc.name,
|
||||
"input": truncate(&tc.arguments.to_string(), 500),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let result = self.execute_tool(&tc.name, &tc.arguments).await;
|
||||
|
||||
self.post_event(
|
||||
"tool_result",
|
||||
serde_json::json!({
|
||||
"tool_name": tc.name,
|
||||
"output": match &result {
|
||||
Ok(output) => truncate(output, 2000),
|
||||
Err(e) => format!("Error: {}", truncate(e, 500)),
|
||||
},
|
||||
"success": result.is_ok(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Ok(ref output) = result {
|
||||
last_output = output.clone();
|
||||
}
|
||||
let selection = ToolSelection {
|
||||
tool_name: tc.name.clone(),
|
||||
parameters: tc.arguments.clone(),
|
||||
reasoning: String::new(),
|
||||
alternatives: vec![],
|
||||
tool_call_id: tc.id.clone(),
|
||||
};
|
||||
self.process_result(reason_ctx, &selection, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
consecutive_tool_intent_nudges = 0;
|
||||
// Execute selected tools
|
||||
for selection in &selections {
|
||||
self.post_event(
|
||||
"tool_use",
|
||||
serde_json::json!({
|
||||
"tool_name": selection.tool_name,
|
||||
"input": truncate(&selection.parameters.to_string(), 500),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let result = self
|
||||
.execute_tool(&selection.tool_name, &selection.parameters)
|
||||
.await;
|
||||
|
||||
self.post_event(
|
||||
"tool_result",
|
||||
serde_json::json!({
|
||||
"tool_name": selection.tool_name,
|
||||
"output": match &result {
|
||||
Ok(output) => truncate(output, 2000),
|
||||
Err(e) => format!("Error: {}", truncate(e, 500)),
|
||||
},
|
||||
"success": result.is_ok(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Ok(ref output) = result {
|
||||
last_output = output.clone();
|
||||
}
|
||||
|
||||
let completed = self.process_result(reason_ctx, selection, result);
|
||||
if completed {
|
||||
return Ok(last_output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Brief pause between iterations
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
Err(WorkerError::ExecutionFailed {
|
||||
reason: format!("max iterations ({}) exceeded", max_iterations),
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_tool(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
let tool = match self.tools.get(tool_name).await {
|
||||
Some(t) => t,
|
||||
None => return Err(format!("tool '{}' not found", tool_name)),
|
||||
};
|
||||
|
||||
let ctx = JobContext {
|
||||
extra_env: self.extra_env.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Validate params
|
||||
let validation = self.safety.validator().validate_tool_params(params);
|
||||
if !validation.is_valid {
|
||||
let details = validation
|
||||
.errors
|
||||
.iter()
|
||||
.map(|e| format!("{}: {}", e.field, e.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
return Err(format!("invalid parameters: {}", details));
|
||||
}
|
||||
|
||||
// Execute with per-tool timeout
|
||||
let tool_timeout = tool.execution_timeout();
|
||||
let result = tokio::time::timeout(tool_timeout, tool.execute(params.clone(), &ctx)).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) => serde_json::to_string_pretty(&output.result)
|
||||
.map_err(|e| format!("serialization error: {}", e)),
|
||||
Ok(Err(e)) => Err(e.to_string()),
|
||||
Err(_) => Err("tool execution timed out".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a tool result into the reasoning context. Returns true if the job is complete.
|
||||
fn process_result(
|
||||
&self,
|
||||
reason_ctx: &mut ReasoningContext,
|
||||
selection: &ToolSelection,
|
||||
result: Result<String, String>,
|
||||
) -> bool {
|
||||
match result {
|
||||
Ok(output) => {
|
||||
let sanitized = self
|
||||
.safety
|
||||
.sanitize_tool_output(&selection.tool_name, &output);
|
||||
let wrapped = self.safety.wrap_for_llm(
|
||||
&selection.tool_name,
|
||||
&sanitized.content,
|
||||
sanitized.was_modified,
|
||||
);
|
||||
|
||||
reason_ctx.messages.push(ChatMessage::tool_result(
|
||||
&selection.tool_call_id,
|
||||
&selection.tool_name,
|
||||
wrapped,
|
||||
));
|
||||
|
||||
// Tool output should never signal job completion. Only the LLM's
|
||||
// natural language response should decide when a job is done. A
|
||||
// tool could return text containing "TASK_COMPLETE" in its output
|
||||
// (e.g. from file contents) and trigger a false positive.
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Tool {} failed: {}", selection.tool_name, e);
|
||||
reason_ctx.messages.push(ChatMessage::tool_result(
|
||||
&selection.tool_call_id,
|
||||
&selection.tool_name,
|
||||
format!("Error: {}", e),
|
||||
));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// 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(&prompt.content, 100)
|
||||
);
|
||||
self.post_event(
|
||||
"message",
|
||||
serde_json::json!({
|
||||
"role": "user",
|
||||
"content": truncate(&prompt.content, 2000),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
reason_ctx.messages.push(ChatMessage::user(&prompt.content));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to poll for prompt: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
let end = crate::util::floor_char_boundary(s, max);
|
||||
format!("{}...", &s[..end])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::worker::runtime::truncate;
|
||||
|
||||
#[test]
|
||||
fn test_truncate_within_limit() {
|
||||
assert_eq!(truncate("hello", 10), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_at_limit() {
|
||||
assert_eq!(truncate("hello", 5), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_beyond_limit() {
|
||||
let result = truncate("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("é is fancy", 1);
|
||||
// Should truncate to 0 chars (can't fit "é" in 1 byte)
|
||||
assert_eq!(result, "...");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user