mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 07:20:19 +00:00
* feat: Add PR review tools, job monitor, and channel injection for E2E sandbox workflows Adds JobEventsTool and JobPromptTool so the main agent can read container event logs and send follow-up prompts to running Claude Code sessions. A background JobMonitor forwards container assistant messages into the agent loop via a new inject channel on ChannelManager. CreateJobTool now accepts a project_dir parameter for mounting existing cloned repos into containers, and spawns the monitor automatically for async jobs. Also: Dockerfile bumped to Rust 1.88 (rig-core needs let chains), GITHUB_TOKEN forwarded into containers for gh CLI auth, and truncate() fixed for multi-byte char boundary panics. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address PR #57 review comments (IDOR, Dockerfile, truncate, logging) - Add ownership checks to JobEventsTool and JobPromptTool via ContextManager to prevent users from accessing other users' jobs (IDOR) - Combine Dockerfile gh CLI install into single apt-get layer - Handle truncate() edge case when max falls inside first multi-byte char - Log actual count of registered job management tools - Document fire-and-forget job monitor lifecycle - Add tests for ownership rejection and schema validation Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Replace hardcoded GITHUB_TOKEN with on-demand credential delivery Containers now fetch credentials via authenticated GET /worker/{id}/credentials endpoint instead of receiving them baked into env vars at creation time. Secrets are decrypted from SecretsStore on demand, scoped per-job via CredentialGrant, and revoked automatically when the job completes. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address sandbox audit findings (CONNECT tunnel, readonly_rootfs, type consolidation) - Implement real CONNECT tunnel with bidirectional TCP piping via hyper upgrade - Fix readonly_rootfs to apply for both ReadOnly and WorkspaceWrite policies - Consolidate duplicate CredentialMapping/CredentialLocation into secrets::types - Share reqwest::Client across proxy requests instead of per-request allocation - Store Docker connection and reuse across executions - Remove .unwrap() from proxy response builders with safe fallbacks - Add output truncation to direct (non-container) execution (64KB limit) - Delete dead src/tools/sandbox.rs (ToolSandbox never used) - Fix connect_docker error message to list all attempted socket paths - Update proxy credential injection to handle all CredentialLocation variants - Use glob-based host_patterns matching for credential lookup in proxy policy Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address PR #57 review comments (IDOR, Dockerfile, truncate, logging) - Dockerfile: install curl+ca-certificates before fetching GitHub CLI GPG key - JobEventsTool/JobPromptTool: reject missing context (prevents IDOR bypass) - parse_credentials: validate env var names against denylist and pattern - resolve_project_dir: require explicit paths to exist before validation - Credential serving: lower log level from info to debug Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address orchestrator audit findings (constant-time auth, error handling, tests) - auth: constant-time token comparison via subtle::ConstantTimeEq - auth: replace hand-rolled hex_encode with std::fmt::Write fold - api: report_status now updates ContainerHandle (was a no-op) - api: log complete_job errors instead of silently discarding - job_manager: log Docker cleanup errors in stop_job/complete_job - job_manager: extract validate_bind_mount_path with proper error on missing home_dir and mandatory base dir creation before canonicalize - job_manager: cache Docker connection across operations - error: remove dead OrchestratorError::AuthFailed and ContainerTimeout - Add 13 new tests (prompt queue, credentials, events, status, paths) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: use floor_char_boundary in sandbox manager truncate to prevent multi-byte panics String::truncate() panics when the index falls mid-way through a multi-byte UTF-8 character. Use the same floor_char_boundary utility already used in worker/runtime.rs and tools/builtin/shell.rs. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: default base_url to private.near.ai for Responses API mode Session tokens only authenticate against private.near.ai, not cloud-api.near.ai. The default base_url now matches the api_mode: - Responses (session token): https://private.near.ai - ChatCompletions (API key): https://cloud-api.near.ai This broke when the multi-provider merge introduced cloud-api.near.ai as the unconditional default. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: use private.near.ai as default base URL for all API modes private.near.ai now supports both Responses and ChatCompletions endpoints, so there is no reason to route through cloud-api.near.ai. This also fixes session token auth which only works against private.near.ai. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: harden libSQL concurrency, fix Claude Code Docker auth and permissions Three fixes for the sandbox/Claude Code pipeline: 1. SQLite "database is locked": set WAL journal mode in migrations and PRAGMA busy_timeout=5000 on every connection across LibSqlBackend, LibSqlSecretsStore, and LibSqlWasmToolStore (~83 async call sites). 2. Claude Code container auth: extract OAuth token from macOS Keychain (or Linux ~/.claude/.credentials.json) at startup and inject via CLAUDE_CODE_OAUTH_TOKEN env var. Removes the broken bind-mount approach that failed on uid mismatch. 3. Claude Code tool permissions: wire CLAUDE_CODE_ALLOWED_TOOLS env var through to the worker binary (was hardcoded to empty vec), and expand defaults to include all standard tools (Read, Write, Edit, Glob, Grep, NotebookEdit, Bash, Task, WebFetch, WebSearch). Also adds --verbose flag to claude CLI (required with stream-json + -p), failover provider model switching, and nearai models endpoint fix. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: stream event parsing, job ID prefix resolution, session renewal in list_models Three fixes for the Docker/gateway pipeline: 1. Claude Code stream event parsing (claude_bridge.rs): Rewrite ClaudeStreamEvent to match actual NDJSON format where content blocks are nested under message.content[], not at the top level. Add handler for "user" events (tool_result blocks) and emit result text as a "message" event so reviews appear in gateway activity view. 2. Job ID prefix resolution (job.rs): Add resolve_job_id() that accepts short hex prefixes (like git short SHAs) in addition to full UUIDs. The LLM sees truncated IDs in job monitor messages like "[Job f2854dd8]" and can now use them directly with job_status/cancel/events/prompt tools. 3. Session renewal in list_models (nearai.rs): list_models() now retries with OAuth renewal on 401, matching send_request()'s existing behavior. Previously it returned SessionExpired immediately, causing the setup wizard to fall back to defaults instead of prompting re-authentication. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: /model command now lists available models Previously /model with no args only showed the current model name. Now it fetches and displays all available models from the provider, marking the active one, so users can see what's available before switching with /model <name>. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR #57 review findings (set_var UB, tunnel timeout, restart creds) - Replace unsafe `std::env::set_var` in worker runtime and Claude bridge with `Command::envs()` injection via a new `extra_env` field on `JobContext`, avoiding undefined behavior in the multi-threaded tokio runtime. - Add 30-minute timeout to CONNECT tunnel `copy_bidirectional` in the sandbox proxy to prevent stuck connections from leaking spawned tasks. - Persist credential grants (as JSON in the description column) on `SandboxJobRecord` so `jobs_restart_handler` can restore them instead of passing `vec![]`, which caused restarted containers to lose access to their original secrets. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address second round of PR #57 review comments - Normalize host_patterns to lowercase in proxy policy matching - Push LIMIT into SQL for list_job_events (Database trait + both backends) - Remove unused was_explicit binding in job tool - Return 500 instead of 200 in make_response fallback path - Update copy_auth_from_mount docstring for env-var default - Use entry.file_type() instead of is_dir() to avoid following symlinks Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address third round of PR #57 review comments - Restore glob patterns in default_claude_code_allowed_tools (Bash -> Bash(*)) - Add tracing::warn for credential grant serialize/deserialize failures - Wrap extra_env in Arc<HashMap> to avoid deep cloning per tool call - Document unsupported credential locations (AuthorizationBasic, UrlPath) - Document TOCTOU window in validate_bind_mount_path - Expand doc comments on JobEventsTool and JobPromptTool Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address fourth round of PR #57 review comments - Document CONNECT tunnel task lifecycle (timeout is the cleanup mechanism) - Remove secret names from error-level credential logs to prevent leaking - Expand DANGEROUS_ENV_VARS denylist with language runtime hijack vectors Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address fifth round of PR #57 review comments - Promote job monitor startup log to info level for observability - Require minimum 4-char prefix in resolve_job_id to limit enumeration - Cap credential grants at 20 per job to bound column storage - Clamp job events limit to 1..1000 to prevent memory abuse Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add missing closing brace for SkillsConfig impl block The merge resolution dropped the closing `}` for `impl SkillsConfig`, causing a compilation error in CI. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
550 lines
19 KiB
Rust
550 lines
19 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.
|
|
|
|
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(), self.safety.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
|
|
)));
|
|
|
|
// 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();
|
|
|
|
// 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));
|
|
}
|
|
RespondResult::ToolCalls {
|
|
tool_calls,
|
|
content,
|
|
} => {
|
|
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 {
|
|
// 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, "...");
|
|
}
|
|
}
|