mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +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]>
540 lines
18 KiB
Rust
540 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, &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, "...");
|
|
}
|
|
}
|