mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* fix: comprehensive security hardening across all layers Critical: - Replace --dangerously-skip-permissions with explicit tool allowlist via settings.json (Claude Code bridge) - Constant-time token comparison (subtle crate) in web auth and orchestrator auth to prevent timing attacks High: - Revoke tokens and clean up handles on container creation failure - Drop SETUID/SETGID capabilities from containers (keep only CHOWN) - Disable redirect following in HTTP tool and WASM wrapper (SSRF) - Reject URL userinfo (@) in WASM allowlist parser (host confusion) - Fix binary body bypassing leak detection (from_utf8 -> from_utf8_lossy) - Protect identity files from LLM overwrites (prompt injection defense) - Prevent tool shadowing: built-in tools cannot be replaced dynamically - User-scoped job APIs: list/detail/cancel/restart/prompt/events/files - CORS restricted to localhost origins, WebSocket origin validation - Sandbox shell fail-closed: no silent fallback to unsandboxed execution - Scrub secrets from log broadcaster before SSE broadcast - XSS sanitization on rendered markdown in web UI - WASM epoch ticker thread so timeout deadlines actually fire Medium: - Cap state transition history at 200 entries - SSE/WebSocket connection limit (100 max) - Request body size limit (1MB) - Response body size limit enforcement in WASM HTTP - UTF-8 safe string truncation (routine engine, shell tool) - Fix PolicyAction::Sanitize to actually run the sanitizer - TOCTOU fix in scheduler and context manager (hold write lock) - Project file serving moved behind auth - Path traversal guard on project_id - Session file permissions set to 0600 on unix - AtomicUsize for routine running_count (panic-safe) - Completion detection hardened against false positives and tool injection - Tool output no longer drives job completion (only LLM response) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address security review findings across all layers - Fix path traversal sandbox bypass via lexical normalization (file.rs) - Fix SSRF via DNS rebinding with pre-request hostname resolution (http.rs) - Add token budget enforcement on LLM calls (reasoning.rs, state.rs) - Fix cross-user chat history leak with ownership verification (store.rs, server.rs) - Add sliding-window rate limiter on gateway chat endpoint (server.rs) - Harden extension install: HTTPS-only, 50MB cap, WASM magic validation (manager.rs) - Add destructive command blocklist that overrides shell auto-approval (shell.rs) - Add 5MB response body size cap to HTTP tool (http.rs) Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: deduplicate shared helpers and remove dead code Extract floor_char_boundary and llm_signals_completion into src/util.rs, unifying diverging phrase lists from agent/worker.rs and worker/runtime.rs. Remove dead RespondResult::usage(), duplicate PROTECTED_IDENTITY_FILES constant, double LeakDetector scanning in WebLogLayer, and invalid 0.0.0.0 origin from WebSocket allow list. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review findings and CI test failures - Fix record_failed_approve: .truncate(true) wiped the attempts file before reading, so failed pairing attempts never accumulated and rate limiting never triggered. - Guard wizard WASM test: skip gracefully when channel build artifacts are absent (CI doesn't compile wasm32-wasip2 targets). - Fix DNS rebinding check: use port 0 instead of hardcoded 443, since the port is irrelevant for hostname resolution. - Remove hardcoded CORS port 3001: the dynamic addr.port() entries already cover the actual server port. - Require WebSocket Origin header: reject connections that omit it entirely, since browsers always send Origin for WS upgrades and a missing header indicates a non-browser client bypassing the check. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address second round of PR review findings - store.rs: reintroduce file locking around read-modify-write in record_failed_approve (concurrent callers could clobber each other). - sse.rs: replace load+check+fetch_add with atomic fetch_update in both subscribe_raw() and subscribe() to prevent overshooting max_connections. - ws.rs: decrement WS tracker before early return when subscribe_raw() returns None (connection limit reached), fixing a counter leak. - server.rs: parse WS Origin host exactly instead of prefix matching, preventing bypass via crafted origins like http://localhost.evil.com. - workspace_integration.rs: skip tests gracefully when Postgres is unreachable instead of panicking (fixes 10 CI failures). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add Origin header to WS integration tests The Origin header requirement added in a3b0190 broke the WS gateway integration tests. Test clients now send Origin: http://127.0.0.1:{port} to match the server's localhost validation. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
1050 lines
36 KiB
Rust
1050 lines
36 KiB
Rust
//! Software builder for creating programs and tools using LLM-driven code generation.
|
|
//!
|
|
//! This module provides a general-purpose software building capability that:
|
|
//! - Uses an agent loop similar to Codex for iterative development
|
|
//! - Can build any software (binaries, libraries, scripts)
|
|
//! - Has special context injection when building WASM tools
|
|
//! - Integrates with existing tool loading infrastructure
|
|
//!
|
|
//! # Architecture
|
|
//!
|
|
//! ```text
|
|
//! ┌─────────────────────────────────────────────────────────────────────────────┐
|
|
//! │ Software Build Loop │
|
|
//! │ │
|
|
//! │ 1. Analyze requirement ─▶ Determine project type, language, structure │
|
|
//! │ 2. Generate scaffold ─▶ Create initial project files │
|
|
//! │ 3. Implement code ─▶ Write the actual implementation │
|
|
//! │ 4. Build/compile ─▶ Run build commands (cargo, npm, etc.) │
|
|
//! │ 5. Fix errors ─▶ Parse errors, modify code, retry │
|
|
//! │ 6. Test ─▶ Run tests, fix failures │
|
|
//! │ 7. Package ─▶ Produce final artifact │
|
|
//! └─────────────────────────────────────────────────────────────────────────────┘
|
|
//! ```
|
|
//!
|
|
//! For WASM tools specifically:
|
|
//! - Injects Tool trait interface documentation
|
|
//! - Injects WASM host function documentation
|
|
//! - Compiles to wasm32-wasip2 target
|
|
//! - Validates against tool interface
|
|
//! - Registers with ToolRegistry
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::context::JobContext;
|
|
use crate::error::ToolError as AgentToolError;
|
|
use crate::llm::{
|
|
ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolDefinition,
|
|
};
|
|
use crate::safety::SafetyLayer;
|
|
use crate::tools::ToolRegistry;
|
|
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
|
|
|
/// Requirement specification for building software.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BuildRequirement {
|
|
/// Name for the software.
|
|
pub name: String,
|
|
/// Description of what it should do.
|
|
pub description: String,
|
|
/// Type of software to build.
|
|
pub software_type: SoftwareType,
|
|
/// Target language/runtime.
|
|
pub language: Language,
|
|
/// Expected input format (for tools/CLIs).
|
|
pub input_spec: Option<String>,
|
|
/// Expected output format.
|
|
pub output_spec: Option<String>,
|
|
/// External dependencies needed.
|
|
pub dependencies: Vec<String>,
|
|
/// Security/capability requirements (for WASM tools).
|
|
pub capabilities: Vec<String>,
|
|
}
|
|
|
|
/// Type of software being built.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SoftwareType {
|
|
/// A WASM tool for the agent.
|
|
WasmTool,
|
|
/// A standalone CLI application.
|
|
CliBinary,
|
|
/// A library/crate.
|
|
Library,
|
|
/// A script (Python, Bash, etc.).
|
|
Script,
|
|
/// A web service/API.
|
|
WebService,
|
|
}
|
|
|
|
/// Programming language for the build.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Language {
|
|
Rust,
|
|
Python,
|
|
TypeScript,
|
|
JavaScript,
|
|
Go,
|
|
Bash,
|
|
}
|
|
|
|
impl Language {
|
|
/// Get the file extension for this language.
|
|
pub fn extension(&self) -> &'static str {
|
|
match self {
|
|
Language::Rust => "rs",
|
|
Language::Python => "py",
|
|
Language::TypeScript => "ts",
|
|
Language::JavaScript => "js",
|
|
Language::Go => "go",
|
|
Language::Bash => "sh",
|
|
}
|
|
}
|
|
|
|
/// Get the build command for this language.
|
|
pub fn build_command(&self, project_dir: &str) -> Option<String> {
|
|
match self {
|
|
Language::Rust => Some(format!("cd {} && cargo build --release", project_dir)),
|
|
Language::TypeScript => Some(format!("cd {} && npm run build", project_dir)),
|
|
Language::Go => Some(format!("cd {} && go build ./...", project_dir)),
|
|
Language::Python | Language::JavaScript | Language::Bash => None, // Interpreted
|
|
}
|
|
}
|
|
|
|
/// Get the test command for this language.
|
|
pub fn test_command(&self, project_dir: &str) -> String {
|
|
match self {
|
|
Language::Rust => format!("cd {} && cargo test", project_dir),
|
|
Language::Python => format!("cd {} && python -m pytest", project_dir),
|
|
Language::TypeScript | Language::JavaScript => {
|
|
format!("cd {} && npm test", project_dir)
|
|
}
|
|
Language::Go => format!("cd {} && go test ./...", project_dir),
|
|
Language::Bash => format!("cd {} && shellcheck *.sh", project_dir),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Result of a build operation.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BuildResult {
|
|
/// Unique ID for this build.
|
|
pub build_id: Uuid,
|
|
/// The requirement that was built.
|
|
pub requirement: BuildRequirement,
|
|
/// Path to the output artifact.
|
|
pub artifact_path: PathBuf,
|
|
/// Build logs.
|
|
pub logs: Vec<BuildLog>,
|
|
/// Whether the build succeeded.
|
|
pub success: bool,
|
|
/// Error message if failed.
|
|
pub error: Option<String>,
|
|
/// When the build started.
|
|
pub started_at: DateTime<Utc>,
|
|
/// When the build completed.
|
|
pub completed_at: DateTime<Utc>,
|
|
/// Number of iterations to complete.
|
|
pub iterations: u32,
|
|
/// Validation warnings (for WASM tools).
|
|
#[serde(default)]
|
|
pub validation_warnings: Vec<String>,
|
|
/// Test results summary.
|
|
#[serde(default)]
|
|
pub tests_passed: u32,
|
|
/// Number of tests that failed.
|
|
#[serde(default)]
|
|
pub tests_failed: u32,
|
|
/// Whether the tool was auto-registered (for WASM tools).
|
|
#[serde(default)]
|
|
pub registered: bool,
|
|
}
|
|
|
|
/// A log entry from the build process.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BuildLog {
|
|
pub timestamp: DateTime<Utc>,
|
|
pub phase: BuildPhase,
|
|
pub message: String,
|
|
pub details: Option<String>,
|
|
}
|
|
|
|
/// Phases of the build process.
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum BuildPhase {
|
|
Analyzing,
|
|
Scaffolding,
|
|
Implementing,
|
|
Building,
|
|
Testing,
|
|
Fixing,
|
|
Validating,
|
|
Registering,
|
|
Packaging,
|
|
Complete,
|
|
Failed,
|
|
}
|
|
|
|
/// Configuration for the software builder.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BuilderConfig {
|
|
/// Directory where builds happen.
|
|
pub build_dir: PathBuf,
|
|
/// Maximum iterations before giving up.
|
|
pub max_iterations: u32,
|
|
/// Timeout for the entire build.
|
|
pub timeout: Duration,
|
|
/// Whether to clean up failed builds.
|
|
pub cleanup_on_failure: bool,
|
|
/// Whether to validate WASM tools after building.
|
|
pub validate_wasm: bool,
|
|
/// Whether to run tests after building.
|
|
pub run_tests: bool,
|
|
/// Whether to auto-register successful WASM tool builds.
|
|
pub auto_register: bool,
|
|
/// Directory to copy successful WASM tools for persistence.
|
|
pub wasm_output_dir: Option<PathBuf>,
|
|
}
|
|
|
|
impl Default for BuilderConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
build_dir: std::env::temp_dir().join("ironclaw-builds"),
|
|
max_iterations: 10,
|
|
timeout: Duration::from_secs(600), // 10 minutes
|
|
cleanup_on_failure: false, // Keep for debugging
|
|
validate_wasm: true,
|
|
run_tests: true,
|
|
auto_register: true,
|
|
wasm_output_dir: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Trait for building software.
|
|
#[async_trait]
|
|
pub trait SoftwareBuilder: Send + Sync {
|
|
/// Analyze a natural language description and extract a structured requirement.
|
|
async fn analyze(&self, description: &str) -> Result<BuildRequirement, AgentToolError>;
|
|
|
|
/// Build software from a requirement.
|
|
async fn build(&self, requirement: &BuildRequirement) -> Result<BuildResult, AgentToolError>;
|
|
|
|
/// Attempt to repair a failed build.
|
|
async fn repair(
|
|
&self,
|
|
result: &BuildResult,
|
|
error: &str,
|
|
) -> Result<BuildResult, AgentToolError>;
|
|
}
|
|
|
|
/// LLM-powered software builder.
|
|
pub struct LlmSoftwareBuilder {
|
|
config: BuilderConfig,
|
|
llm: Arc<dyn LlmProvider>,
|
|
safety: Arc<SafetyLayer>,
|
|
tools: Arc<ToolRegistry>,
|
|
}
|
|
|
|
impl LlmSoftwareBuilder {
|
|
/// Create a new LLM-based software builder.
|
|
pub fn new(
|
|
config: BuilderConfig,
|
|
llm: Arc<dyn LlmProvider>,
|
|
safety: Arc<SafetyLayer>,
|
|
tools: Arc<ToolRegistry>,
|
|
) -> Self {
|
|
// Ensure build directory exists
|
|
if let Err(e) = std::fs::create_dir_all(&config.build_dir) {
|
|
tracing::warn!("Failed to create build directory: {}", e);
|
|
}
|
|
|
|
Self {
|
|
config,
|
|
llm,
|
|
safety,
|
|
tools,
|
|
}
|
|
}
|
|
|
|
/// Get the build tools available for the build loop.
|
|
async fn get_build_tools(&self) -> Vec<ToolDefinition> {
|
|
// Only include tools useful for building software
|
|
self.tools
|
|
.tool_definitions_for(&[
|
|
"shell",
|
|
"read_file",
|
|
"write_file",
|
|
"list_dir",
|
|
"apply_patch",
|
|
"http", // For fetching docs/deps
|
|
])
|
|
.await
|
|
}
|
|
|
|
/// Create the system prompt for the build agent.
|
|
fn build_system_prompt(&self, requirement: &BuildRequirement) -> String {
|
|
let mut prompt = format!(
|
|
r#"You are a software developer building a program.
|
|
|
|
## Task
|
|
Build: {name}
|
|
Description: {description}
|
|
Type: {software_type:?}
|
|
Language: {language:?}
|
|
|
|
## Process
|
|
1. Create the project structure with necessary files
|
|
2. Implement the code based on the requirements
|
|
3. Build/compile if needed
|
|
4. Run tests to verify correctness
|
|
5. Fix any errors and iterate
|
|
|
|
## Guidelines
|
|
- Write clean, well-structured code
|
|
- Handle errors appropriately
|
|
- Add minimal but useful comments
|
|
- Follow idiomatic patterns for the language
|
|
- Test edge cases
|
|
|
|
## Tools Available
|
|
- shell: Run build commands, tests, install dependencies
|
|
- read_file: Read existing files
|
|
- write_file: Create new files
|
|
- apply_patch: Edit existing files surgically
|
|
- list_dir: Explore project structure
|
|
"#,
|
|
name = requirement.name,
|
|
description = requirement.description,
|
|
software_type = requirement.software_type,
|
|
language = requirement.language,
|
|
);
|
|
|
|
// Add tool-specific context when building WASM tools
|
|
if requirement.software_type == SoftwareType::WasmTool {
|
|
prompt.push_str(&self.wasm_tool_context());
|
|
}
|
|
|
|
prompt
|
|
}
|
|
|
|
/// Get additional context for building WASM tools.
|
|
fn wasm_tool_context(&self) -> String {
|
|
r#"
|
|
|
|
## WASM Tool Requirements
|
|
|
|
You are building a WASM Component tool for an autonomous agent using the WASM Component Model.
|
|
The tool MUST use `wit_bindgen` and `cargo-component` to build.
|
|
|
|
## Available Host Functions (from WIT interface)
|
|
|
|
The host provides these functions via `near::agent::host`:
|
|
|
|
```rust
|
|
// Logging (always available)
|
|
host::log(level: LogLevel, message: &str); // LogLevel: Trace, Debug, Info, Warn, Error
|
|
|
|
// Time (always available)
|
|
host::now_millis() -> u64; // Unix timestamp in milliseconds
|
|
|
|
// Workspace (if capability granted)
|
|
host::workspace_read(path: &str) -> Option<String>;
|
|
|
|
// HTTP (if capability granted)
|
|
host::http_request(method: &str, url: &str, headers_json: &str, body: Option<Vec<u8>>)
|
|
-> Result<HttpResponse, String>;
|
|
// HttpResponse has: status: u16, headers_json: String, body: Vec<u8>
|
|
|
|
// Tool invocation (if capability granted)
|
|
host::tool_invoke(alias: &str, params_json: &str) -> Result<String, String>;
|
|
|
|
// Secrets (if capability granted) - can only CHECK existence, not read values
|
|
host::secret_exists(name: &str) -> bool;
|
|
```
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
my_tool/
|
|
├── Cargo.toml
|
|
├── wit/
|
|
│ └── tool.wit # Copy from agent's wit/tool.wit
|
|
└── src/
|
|
└── lib.rs
|
|
```
|
|
|
|
## Cargo.toml Template
|
|
|
|
```toml
|
|
[package]
|
|
name = "my_tool"
|
|
version = "0.1.0"
|
|
edition = "2021"
|
|
|
|
[lib]
|
|
crate-type = ["cdylib"]
|
|
|
|
[dependencies]
|
|
wit-bindgen = "0.41"
|
|
serde = { version = "1", features = ["derive"] }
|
|
serde_json = "1"
|
|
```
|
|
|
|
## src/lib.rs Template
|
|
|
|
```rust
|
|
// Generate bindings from the WIT interface
|
|
wit_bindgen::generate!({
|
|
world: "sandboxed-tool",
|
|
path: "wit/tool.wit",
|
|
});
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use exports::near::agent::tool::{Guest, Request, Response};
|
|
use near::agent::host::{self, LogLevel};
|
|
|
|
// Your input/output types
|
|
#[derive(Deserialize)]
|
|
struct MyInput {
|
|
// Define parameters here
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct MyOutput {
|
|
// Define output here
|
|
}
|
|
|
|
struct MyTool;
|
|
|
|
impl Guest for MyTool {
|
|
fn execute(req: Request) -> Response {
|
|
// Parse input
|
|
let input: MyInput = match serde_json::from_str(&req.params) {
|
|
Ok(i) => i,
|
|
Err(e) => return Response {
|
|
output: None,
|
|
error: Some(format!("Invalid input: {}", e)),
|
|
},
|
|
};
|
|
|
|
host::log(LogLevel::Info, &format!("Processing request..."));
|
|
|
|
// Your implementation here
|
|
let output = MyOutput { /* ... */ };
|
|
|
|
// Return success
|
|
Response {
|
|
output: Some(serde_json::to_string(&output).unwrap()),
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
fn schema() -> String {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
// Define your JSON Schema here
|
|
},
|
|
"required": []
|
|
}).to_string()
|
|
}
|
|
|
|
fn description() -> String {
|
|
"Description of what this tool does".to_string()
|
|
}
|
|
}
|
|
|
|
export!(MyTool);
|
|
```
|
|
|
|
## Build Commands
|
|
|
|
```bash
|
|
# Install cargo-component (one time)
|
|
cargo install cargo-component
|
|
|
|
# Build the WASM component
|
|
cargo component build --release
|
|
|
|
# Output: target/wasm32-wasip2/release/my_tool.wasm
|
|
```
|
|
|
|
## Capabilities File (my_tool.capabilities.json)
|
|
|
|
Create alongside the .wasm file to grant capabilities:
|
|
|
|
```json
|
|
{
|
|
"http": {
|
|
"allowed_endpoints": [
|
|
{"host": "api.example.com", "path_prefix": "/v1/"}
|
|
]
|
|
},
|
|
"workspace": true,
|
|
"secrets": {
|
|
"allowed": ["API_KEY"]
|
|
}
|
|
}
|
|
```
|
|
|
|
## Important Notes
|
|
|
|
1. NEVER panic - always return Response with error field set
|
|
2. Secrets are NEVER exposed to WASM - use placeholders like `{API_KEY}` in URLs
|
|
and the host will inject the real value
|
|
3. HTTP requests are rate-limited and only allowed to endpoints in capabilities
|
|
4. Keep the tool focused on one thing - small, composable tools are better
|
|
|
|
"#
|
|
.to_string()
|
|
}
|
|
|
|
/// Execute the build loop.
|
|
async fn execute_build_loop(
|
|
&self,
|
|
requirement: &BuildRequirement,
|
|
project_dir: &Path,
|
|
) -> Result<BuildResult, AgentToolError> {
|
|
let build_id = Uuid::new_v4();
|
|
let started_at = Utc::now();
|
|
let mut logs = Vec::new();
|
|
let mut iteration = 0;
|
|
|
|
// Create reasoning engine
|
|
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
|
|
|
|
// Build initial context
|
|
let tool_defs = self.get_build_tools().await;
|
|
let mut reason_ctx = ReasoningContext::new().with_tools(tool_defs);
|
|
|
|
// Add system prompt
|
|
reason_ctx
|
|
.messages
|
|
.push(ChatMessage::system(self.build_system_prompt(requirement)));
|
|
|
|
// Add initial user message - directive to force immediate tool use
|
|
reason_ctx.messages.push(ChatMessage::user(format!(
|
|
"Build the {} in directory: {}\n\n\
|
|
Requirements:\n- {}\n\n\
|
|
IMPORTANT: Use the write_file tool NOW to create Cargo.toml. \
|
|
Do not explain, plan, or output JSON—immediately call write_file.",
|
|
requirement.name,
|
|
project_dir.display(),
|
|
requirement.description
|
|
)));
|
|
|
|
logs.push(BuildLog {
|
|
timestamp: Utc::now(),
|
|
phase: BuildPhase::Analyzing,
|
|
message: "Starting build process".into(),
|
|
details: None,
|
|
});
|
|
|
|
// Main build loop
|
|
let mut current_phase = BuildPhase::Scaffolding;
|
|
let mut last_error: Option<String> = None;
|
|
let mut tools_executed = false;
|
|
let mut consecutive_text_responses = 0;
|
|
|
|
loop {
|
|
iteration += 1;
|
|
|
|
if iteration > self.config.max_iterations {
|
|
logs.push(BuildLog {
|
|
timestamp: Utc::now(),
|
|
phase: BuildPhase::Failed,
|
|
message: "Maximum iterations exceeded".into(),
|
|
details: last_error.clone(),
|
|
});
|
|
|
|
return Ok(BuildResult {
|
|
build_id,
|
|
requirement: requirement.clone(),
|
|
artifact_path: project_dir.to_path_buf(),
|
|
logs,
|
|
success: false,
|
|
error: Some("Maximum iterations exceeded".into()),
|
|
started_at,
|
|
completed_at: Utc::now(),
|
|
iterations: iteration,
|
|
validation_warnings: Vec::new(),
|
|
tests_passed: 0,
|
|
tests_failed: 0,
|
|
registered: false,
|
|
});
|
|
}
|
|
|
|
// Refresh tool definitions each iteration
|
|
reason_ctx.available_tools = self.get_build_tools().await;
|
|
|
|
// Get response from LLM (may be text or tool calls)
|
|
let result = reasoning
|
|
.respond_with_tools(&reason_ctx)
|
|
.await
|
|
.map_err(|e| {
|
|
AgentToolError::BuilderFailed(format!("LLM response failed: {}", e))
|
|
})?;
|
|
|
|
match result.result {
|
|
RespondResult::Text(response) => {
|
|
reason_ctx.messages.push(ChatMessage::assistant(&response));
|
|
|
|
// If tools haven't been executed yet, we're stuck in planning mode
|
|
if !tools_executed {
|
|
consecutive_text_responses += 1;
|
|
|
|
// Fail fast after 2 consecutive text-only responses
|
|
if consecutive_text_responses >= 2 {
|
|
logs.push(BuildLog {
|
|
timestamp: Utc::now(),
|
|
phase: BuildPhase::Failed,
|
|
message: "Builder stuck in planning mode".into(),
|
|
details: Some(format!(
|
|
"LLM returned {} consecutive text responses without calling tools. \
|
|
Try a more specific requirement.",
|
|
consecutive_text_responses
|
|
)),
|
|
});
|
|
|
|
return Ok(BuildResult {
|
|
build_id,
|
|
requirement: requirement.clone(),
|
|
artifact_path: project_dir.to_path_buf(),
|
|
logs,
|
|
success: false,
|
|
error: Some(
|
|
"LLM not executing tools - stuck in planning mode".into(),
|
|
),
|
|
started_at,
|
|
completed_at: Utc::now(),
|
|
iterations: iteration,
|
|
validation_warnings: Vec::new(),
|
|
tests_passed: 0,
|
|
tests_failed: 0,
|
|
registered: false,
|
|
});
|
|
}
|
|
|
|
tracing::debug!(
|
|
"Builder: no tools executed (text response #{}/2), forcing tool use",
|
|
consecutive_text_responses
|
|
);
|
|
reason_ctx.messages.push(ChatMessage::user(
|
|
"STOP. Do NOT output text, JSON specs, or explanations. \
|
|
Call the write_file tool RIGHT NOW to create Cargo.toml. \
|
|
Just call the tool—no commentary.",
|
|
));
|
|
continue;
|
|
}
|
|
|
|
// Reset counter when tools have been executed (we're in completion phase)
|
|
consecutive_text_responses = 0;
|
|
|
|
// Check for completion signals
|
|
let response_lower = response.to_lowercase();
|
|
if response_lower.contains("build complete")
|
|
|| response_lower.contains("successfully built")
|
|
|| response_lower.contains("all tests pass")
|
|
|| response_lower.contains("complete")
|
|
{
|
|
logs.push(BuildLog {
|
|
timestamp: Utc::now(),
|
|
phase: BuildPhase::Complete,
|
|
message: "Build completed successfully".into(),
|
|
details: Some(response),
|
|
});
|
|
|
|
// Determine artifact path
|
|
let artifact_path = self.find_artifact(requirement, project_dir).await;
|
|
|
|
return Ok(BuildResult {
|
|
build_id,
|
|
requirement: requirement.clone(),
|
|
artifact_path,
|
|
logs,
|
|
success: true,
|
|
error: None,
|
|
started_at,
|
|
completed_at: Utc::now(),
|
|
iterations: iteration,
|
|
validation_warnings: Vec::new(),
|
|
tests_passed: 0,
|
|
tests_failed: 0,
|
|
registered: false,
|
|
});
|
|
}
|
|
|
|
// Ask for next steps
|
|
reason_ctx
|
|
.messages
|
|
.push(ChatMessage::user("Continue with the next step."));
|
|
}
|
|
RespondResult::ToolCalls {
|
|
tool_calls,
|
|
content,
|
|
} => {
|
|
tools_executed = true;
|
|
|
|
// Add assistant message with tool_calls (OpenAI protocol)
|
|
reason_ctx
|
|
.messages
|
|
.push(ChatMessage::assistant_with_tool_calls(
|
|
content,
|
|
tool_calls.clone(),
|
|
));
|
|
|
|
// Execute each tool call
|
|
for tc in tool_calls {
|
|
logs.push(BuildLog {
|
|
timestamp: Utc::now(),
|
|
phase: current_phase,
|
|
message: format!("Executing: {}", tc.name),
|
|
details: Some(format!("{:?}", tc.arguments)),
|
|
});
|
|
|
|
// Execute tool
|
|
let tool_result = self
|
|
.execute_build_tool(&tc.name, &tc.arguments, project_dir)
|
|
.await;
|
|
|
|
match tool_result {
|
|
Ok(output) => {
|
|
let output_str = serde_json::to_string_pretty(&output.result)
|
|
.unwrap_or_default();
|
|
|
|
// Add to context
|
|
reason_ctx.messages.push(ChatMessage::tool_result(
|
|
&tc.id,
|
|
&tc.name,
|
|
output_str.clone(),
|
|
));
|
|
|
|
// Update phase based on tool
|
|
current_phase = match tc.name.as_str() {
|
|
"write_file" => BuildPhase::Implementing,
|
|
"shell" if tc.arguments.to_string().contains("build") => {
|
|
BuildPhase::Building
|
|
}
|
|
"shell" if tc.arguments.to_string().contains("test") => {
|
|
BuildPhase::Testing
|
|
}
|
|
_ => current_phase,
|
|
};
|
|
|
|
// Check for build/test errors in output
|
|
if output_str.to_lowercase().contains("error:")
|
|
|| output_str.to_lowercase().contains("error[")
|
|
|| output_str.to_lowercase().contains("failed")
|
|
{
|
|
last_error = Some(output_str);
|
|
current_phase = BuildPhase::Fixing;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let error_msg = format!("Tool error: {}", e);
|
|
last_error = Some(error_msg.clone());
|
|
|
|
reason_ctx.messages.push(ChatMessage::tool_result(
|
|
&tc.id,
|
|
&tc.name,
|
|
format!("Error: {}", e),
|
|
));
|
|
|
|
logs.push(BuildLog {
|
|
timestamp: Utc::now(),
|
|
phase: BuildPhase::Fixing,
|
|
message: "Tool execution failed".into(),
|
|
details: Some(error_msg),
|
|
});
|
|
|
|
current_phase = BuildPhase::Fixing;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Execute a build tool.
|
|
async fn execute_build_tool(
|
|
&self,
|
|
tool_name: &str,
|
|
params: &serde_json::Value,
|
|
_project_dir: &Path,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let tool =
|
|
self.tools.get(tool_name).await.ok_or_else(|| {
|
|
ToolError::ExecutionFailed(format!("Tool not found: {}", tool_name))
|
|
})?;
|
|
|
|
// Execute with a dummy context (build tools don't need job context)
|
|
let ctx = JobContext::default();
|
|
tool.execute(params.clone(), &ctx).await
|
|
}
|
|
|
|
/// Find the build artifact based on project type.
|
|
async fn find_artifact(&self, requirement: &BuildRequirement, project_dir: &Path) -> PathBuf {
|
|
match (&requirement.software_type, &requirement.language) {
|
|
(SoftwareType::WasmTool, Language::Rust) => {
|
|
// WASM output location
|
|
project_dir.join(format!(
|
|
"target/wasm32-wasip2/release/{}.wasm",
|
|
requirement.name.replace('-', "_")
|
|
))
|
|
}
|
|
(SoftwareType::CliBinary, Language::Rust) => project_dir.join(format!(
|
|
"target/release/{}",
|
|
requirement.name.replace('-', "_")
|
|
)),
|
|
(SoftwareType::Script, Language::Python) => {
|
|
project_dir.join(format!("{}.py", requirement.name))
|
|
}
|
|
(SoftwareType::Script, Language::Bash) => {
|
|
project_dir.join(format!("{}.sh", requirement.name))
|
|
}
|
|
_ => project_dir.to_path_buf(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SoftwareBuilder for LlmSoftwareBuilder {
|
|
async fn analyze(&self, description: &str) -> Result<BuildRequirement, AgentToolError> {
|
|
// Use LLM to parse the description
|
|
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
|
|
|
|
let prompt = format!(
|
|
r#"Analyze this software requirement and extract structured information.
|
|
|
|
Description: {}
|
|
|
|
IMPORTANT: If this is a "tool" that the agent will use (e.g., "calendar tool", "email tool",
|
|
"API client tool"), you MUST use:
|
|
- software_type: "wasm_tool"
|
|
- language: "rust"
|
|
|
|
Only use cli_binary/script/library for software meant for human end-users, not agent tools.
|
|
|
|
Respond with a JSON object containing:
|
|
- name: A short identifier (snake_case)
|
|
- description: What the software should do
|
|
- software_type: One of "wasm_tool", "cli_binary", "library", "script", "web_service"
|
|
(PREFER "wasm_tool" for agent-usable tools)
|
|
- language: One of "rust", "python", "typescript", "javascript", "go", "bash"
|
|
(PREFER "rust" for wasm_tool)
|
|
- input_spec: Expected input format (optional)
|
|
- output_spec: Expected output format (optional)
|
|
- dependencies: List of external dependencies needed
|
|
- capabilities: For WASM tools, list needed capabilities (http, workspace, secrets)
|
|
|
|
JSON:"#,
|
|
description
|
|
);
|
|
|
|
let ctx = ReasoningContext::new().with_message(ChatMessage::user(&prompt));
|
|
|
|
let response = reasoning
|
|
.respond(&ctx)
|
|
.await
|
|
.map_err(|e| AgentToolError::BuilderFailed(format!("Analysis failed: {}", e)))?;
|
|
|
|
// Extract JSON from response
|
|
let json_start = response.find('{').unwrap_or(0);
|
|
let json_end = response.rfind('}').map(|i| i + 1).unwrap_or(response.len());
|
|
let json_str = &response[json_start..json_end];
|
|
|
|
serde_json::from_str(json_str).map_err(|e| {
|
|
AgentToolError::BuilderFailed(format!("Failed to parse requirement: {}", e))
|
|
})
|
|
}
|
|
|
|
async fn build(&self, requirement: &BuildRequirement) -> Result<BuildResult, AgentToolError> {
|
|
// Create project directory
|
|
let project_dir = self.config.build_dir.join(&requirement.name);
|
|
if project_dir.exists() {
|
|
std::fs::remove_dir_all(&project_dir).map_err(|e| {
|
|
AgentToolError::BuilderFailed(format!("Failed to clean project dir: {}", e))
|
|
})?;
|
|
}
|
|
std::fs::create_dir_all(&project_dir).map_err(|e| {
|
|
AgentToolError::BuilderFailed(format!("Failed to create project dir: {}", e))
|
|
})?;
|
|
|
|
// Run the build loop with timeout
|
|
let result = tokio::time::timeout(
|
|
self.config.timeout,
|
|
self.execute_build_loop(requirement, &project_dir),
|
|
)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok(build_result)) => Ok(build_result),
|
|
Ok(Err(e)) => Err(e),
|
|
Err(_) => Err(AgentToolError::BuilderFailed("Build timed out".into())),
|
|
}
|
|
}
|
|
|
|
async fn repair(
|
|
&self,
|
|
result: &BuildResult,
|
|
error: &str,
|
|
) -> Result<BuildResult, AgentToolError> {
|
|
// Create a new requirement with repair context
|
|
let mut requirement = result.requirement.clone();
|
|
requirement.description = format!(
|
|
"{}\n\nPrevious build failed with error:\n{}\n\nFix the issues and rebuild.",
|
|
requirement.description, error
|
|
);
|
|
|
|
// Rebuild (preserving project directory if it exists)
|
|
self.build(&requirement).await
|
|
}
|
|
}
|
|
|
|
/// Tool that allows the agent to build software on demand.
|
|
pub struct BuildSoftwareTool {
|
|
builder: Arc<dyn SoftwareBuilder>,
|
|
}
|
|
|
|
impl BuildSoftwareTool {
|
|
pub fn new(builder: Arc<dyn SoftwareBuilder>) -> Self {
|
|
Self { builder }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for BuildSoftwareTool {
|
|
fn name(&self) -> &str {
|
|
"build_software"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Build software from a description. IMPORTANT: For tools the agent will use, \
|
|
ALWAYS build Rust WASM tools (type: wasm_tool, language: rust). Only use cli_binary, \
|
|
script, or other types for software meant for human users. The builder scaffolds, \
|
|
implements, compiles, and tests iteratively."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"description": {
|
|
"type": "string",
|
|
"description": "Natural language description of what to build"
|
|
},
|
|
"type": {
|
|
"type": "string",
|
|
"enum": ["wasm_tool", "cli_binary", "library", "script"],
|
|
"description": "Type of software to build (optional, will be inferred)"
|
|
},
|
|
"language": {
|
|
"type": "string",
|
|
"enum": ["rust", "python", "typescript", "bash"],
|
|
"description": "Programming language to use (optional, will be inferred)"
|
|
}
|
|
},
|
|
"required": ["description"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let description = params
|
|
.get("description")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| ToolError::InvalidParameters("missing 'description'".into()))?;
|
|
|
|
let start = std::time::Instant::now();
|
|
|
|
// Analyze the requirement
|
|
let mut requirement = self
|
|
.builder
|
|
.analyze(description)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(format!("Analysis failed: {}", e)))?;
|
|
|
|
// Override type/language if specified
|
|
if let Some(type_str) = params.get("type").and_then(|v| v.as_str()) {
|
|
requirement.software_type = match type_str {
|
|
"wasm_tool" => SoftwareType::WasmTool,
|
|
"cli_binary" => SoftwareType::CliBinary,
|
|
"library" => SoftwareType::Library,
|
|
"script" => SoftwareType::Script,
|
|
_ => requirement.software_type,
|
|
};
|
|
}
|
|
|
|
if let Some(lang_str) = params.get("language").and_then(|v| v.as_str()) {
|
|
requirement.language = match lang_str {
|
|
"rust" => Language::Rust,
|
|
"python" => Language::Python,
|
|
"typescript" => Language::TypeScript,
|
|
"bash" => Language::Bash,
|
|
_ => requirement.language,
|
|
};
|
|
}
|
|
|
|
// Build
|
|
let result = self
|
|
.builder
|
|
.build(&requirement)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(format!("Build failed: {}", e)))?;
|
|
|
|
let output = serde_json::json!({
|
|
"build_id": result.build_id.to_string(),
|
|
"name": result.requirement.name,
|
|
"success": result.success,
|
|
"artifact_path": result.artifact_path.display().to_string(),
|
|
"iterations": result.iterations,
|
|
"error": result.error,
|
|
"phases": result.logs.iter().map(|l| format!("{:?}: {}", l.phase, l.message)).collect::<Vec<_>>()
|
|
});
|
|
|
|
Ok(ToolOutput::success(output, start.elapsed()))
|
|
}
|
|
|
|
fn requires_approval(&self) -> bool {
|
|
true // Building software should require approval
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_language_extensions() {
|
|
assert_eq!(Language::Rust.extension(), "rs");
|
|
assert_eq!(Language::Python.extension(), "py");
|
|
assert_eq!(Language::TypeScript.extension(), "ts");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_commands() {
|
|
assert!(Language::Rust.build_command("/tmp/project").is_some());
|
|
assert!(Language::Python.build_command("/tmp/project").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_software_type_serialization() {
|
|
let json = serde_json::to_string(&SoftwareType::WasmTool).unwrap();
|
|
assert_eq!(json, "\"wasm_tool\"");
|
|
}
|
|
}
|