Adding builder capability

This commit is contained in:
Illia Polosukhin
2026-02-03 08:36:55 -08:00
parent 343782524f
commit c9ebb117ab
19 changed files with 3689 additions and 210 deletions
+898
View File
@@ -0,0 +1,898 @@
//! 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, 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("near-agent-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 tool for an autonomous agent. The tool must:
1. **Implement the guest interface** - Export a `run` function that takes JSON input and returns JSON output
2. **Use only available host functions**:
- `host_log(level, message)` - Log messages (levels: debug, info, warn, error)
- `host_time()` - Get current Unix timestamp
- `host_http_request(method, url, headers, body)` - Make HTTP requests (if capability granted)
- `host_workspace_read(path)` - Read from workspace (if capability granted)
- `host_workspace_write(path, content)` - Write to workspace (if capability granted)
- `host_get_secret(name)` - Get injected secret (if capability granted)
3. **Handle errors gracefully** - Return error results, never panic
4. **Be deterministic** - Same input should produce same output (except for time/HTTP)
## WASM Tool Template (Rust)
```rust
// Cargo.toml
[package]
name = "tool_name"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
// src/lib.rs
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Input {
// Define your input parameters
}
#[derive(Serialize)]
struct Output {
// Define your output structure
}
// Host function imports
extern "C" {
fn host_log(level: i32, ptr: *const u8, len: usize);
}
fn log_info(msg: &str) {
unsafe { host_log(1, msg.as_ptr(), msg.len()); }
}
#[no_mangle]
pub extern "C" fn run(input_ptr: *const u8, input_len: usize) -> *mut u8 {
// Parse input
let input_bytes = unsafe { std::slice::from_raw_parts(input_ptr, input_len) };
let input: Input = match serde_json::from_slice(input_bytes) {
Ok(i) => i,
Err(e) => return error_response(&format!("Invalid input: {}", e)),
};
// Your implementation here
let output = Output { /* ... */ };
// Return output
let json = serde_json::to_vec(&output).unwrap();
let ptr = json.as_ptr() as *mut u8;
std::mem::forget(json);
ptr
}
fn error_response(msg: &str) -> *mut u8 {
let json = serde_json::json!({"error": msg}).to_string();
let ptr = json.as_ptr() as *mut u8;
std::mem::forget(json);
ptr
}
```
## Build Commands for WASM
```bash
# Add WASM target
rustup target add wasm32-wasip2
# Build
cargo build --target wasm32-wasip2 --release
# Output will be at: target/wasm32-wasip2/release/tool_name.wasm
```
## Tool Capabilities
When defining capabilities for your tool, specify which host functions it needs:
- `http`: Allows HTTP requests to specified endpoints
- `workspace`: Allows reading/writing workspace files
- `secrets`: Allows accessing injected secrets
"#
.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
reason_ctx.messages.push(ChatMessage::user(format!(
"Build the {} in directory: {}\n\nRequirements:\n- {}\n\nStart by creating the project structure.",
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;
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,
});
}
// Get next action from LLM
let selections = reasoning.select_tools(&reason_ctx).await.map_err(|e| {
AgentToolError::BuilderFailed(format!("LLM tool selection failed: {}", e))
})?;
if selections.is_empty() {
// No tools selected - get response and check if done
let response = reasoning.respond(&reason_ctx).await.map_err(|e| {
AgentToolError::BuilderFailed(format!("LLM response failed: {}", e))
})?;
reason_ctx.messages.push(ChatMessage::assistant(&response));
// 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")
{
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."));
continue;
}
// Execute selected tools
for selection in &selections {
logs.push(BuildLog {
timestamp: Utc::now(),
phase: current_phase,
message: format!("Executing: {}", selection.tool_name),
details: Some(selection.reasoning.clone()),
});
// Execute tool
let tool_result = self
.execute_build_tool(&selection.tool_name, &selection.parameters, 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(
"tool_call",
&selection.tool_name,
output_str.clone(),
));
// Update phase based on tool
current_phase = match selection.tool_name.as_str() {
"write_file" => BuildPhase::Implementing,
"shell" if selection.parameters.to_string().contains("build") => {
BuildPhase::Building
}
"shell" if selection.parameters.to_string().contains("test") => {
BuildPhase::Testing
}
_ => current_phase,
};
// Check for build/test errors in output
if output_str.contains("error") || output_str.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(
"tool_call",
&selection.tool_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: {}
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"
- language: One of "rust", "python", "typescript", "javascript", "go", "bash"
- 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. Can create WASM tools, CLI applications, scripts, \
and more. The builder will scaffold, implement, compile, and test the software 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\"");
}
}
+37
View File
@@ -0,0 +1,37 @@
//! 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. Validate ─▶ For WASM tools, verify interface compliance │
//! │ 8. Package ─▶ Produce final artifact │
//! └─────────────────────────────────────────────────────────────────────────────┘
//! ```
mod core;
mod templates;
mod testing;
mod validation;
pub use core::{
BuildLog, BuildPhase, BuildRequirement, BuildResult, BuildSoftwareTool, BuilderConfig,
Language, LlmSoftwareBuilder, SoftwareBuilder, SoftwareType,
};
pub use templates::{Template, TemplateEngine, TemplateType};
pub use testing::{TestCase, TestHarness, TestResult, TestSuite};
pub use validation::{ValidationError, ValidationResult, WasmValidator};
+501
View File
@@ -0,0 +1,501 @@
//! Code templates for common tool patterns.
//!
//! Templates provide scaffolding that the LLM fills in, reducing the chance
//! of structural errors and ensuring consistent patterns.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// Type of template.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TemplateType {
/// WASM tool with HTTP capability.
WasmHttpTool,
/// WASM tool for data transformation.
WasmTransformTool,
/// WASM tool for computation.
WasmComputeTool,
/// CLI application.
CliBinary,
/// Python script.
PythonScript,
/// Bash script.
BashScript,
}
/// A code template with placeholders.
#[derive(Debug, Clone)]
pub struct Template {
pub template_type: TemplateType,
pub name: &'static str,
pub description: &'static str,
pub files: Vec<TemplateFile>,
}
/// A file within a template.
#[derive(Debug, Clone)]
pub struct TemplateFile {
pub path: &'static str,
pub content: &'static str,
pub is_required: bool,
}
/// Engine for rendering templates with variable substitution.
#[derive(Debug, Default)]
pub struct TemplateEngine {
variables: HashMap<String, String>,
}
impl TemplateEngine {
pub fn new() -> Self {
Self::default()
}
/// Set a template variable.
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
self.variables.insert(key.into(), value.into());
self
}
/// Render a template string, replacing {{variable}} placeholders.
pub fn render(&self, template: &str) -> String {
let mut result = template.to_string();
for (key, value) in &self.variables {
let placeholder = format!("{{{{{}}}}}", key);
result = result.replace(&placeholder, value);
}
result
}
/// Render all files in a template.
pub fn render_template(&self, template: &Template) -> Vec<(String, String)> {
template
.files
.iter()
.map(|f| (self.render(f.path), self.render(f.content)))
.collect()
}
}
impl Template {
/// Get template by type.
pub fn get(template_type: TemplateType) -> Self {
match template_type {
TemplateType::WasmHttpTool => Self::wasm_http_tool(),
TemplateType::WasmTransformTool => Self::wasm_transform_tool(),
TemplateType::WasmComputeTool => Self::wasm_compute_tool(),
TemplateType::CliBinary => Self::cli_binary(),
TemplateType::PythonScript => Self::python_script(),
TemplateType::BashScript => Self::bash_script(),
}
}
fn wasm_http_tool() -> Self {
Self {
template_type: TemplateType::WasmHttpTool,
name: "WASM HTTP Tool",
description: "A WASM tool that makes HTTP requests to external APIs",
files: vec![
TemplateFile {
path: "Cargo.toml",
content: WASM_CARGO_TOML,
is_required: true,
},
TemplateFile {
path: "src/lib.rs",
content: WASM_HTTP_LIB_RS,
is_required: true,
},
],
}
}
fn wasm_transform_tool() -> Self {
Self {
template_type: TemplateType::WasmTransformTool,
name: "WASM Transform Tool",
description: "A WASM tool that transforms data (JSON, text, etc.)",
files: vec![
TemplateFile {
path: "Cargo.toml",
content: WASM_CARGO_TOML,
is_required: true,
},
TemplateFile {
path: "src/lib.rs",
content: WASM_TRANSFORM_LIB_RS,
is_required: true,
},
],
}
}
fn wasm_compute_tool() -> Self {
Self {
template_type: TemplateType::WasmComputeTool,
name: "WASM Compute Tool",
description: "A WASM tool for pure computation (no I/O)",
files: vec![
TemplateFile {
path: "Cargo.toml",
content: WASM_CARGO_TOML,
is_required: true,
},
TemplateFile {
path: "src/lib.rs",
content: WASM_COMPUTE_LIB_RS,
is_required: true,
},
],
}
}
fn cli_binary() -> Self {
Self {
template_type: TemplateType::CliBinary,
name: "CLI Binary",
description: "A command-line application with argument parsing",
files: vec![
TemplateFile {
path: "Cargo.toml",
content: CLI_CARGO_TOML,
is_required: true,
},
TemplateFile {
path: "src/main.rs",
content: CLI_MAIN_RS,
is_required: true,
},
],
}
}
fn python_script() -> Self {
Self {
template_type: TemplateType::PythonScript,
name: "Python Script",
description: "A Python script with argument parsing",
files: vec![TemplateFile {
path: "{{name}}.py",
content: PYTHON_SCRIPT,
is_required: true,
}],
}
}
fn bash_script() -> Self {
Self {
template_type: TemplateType::BashScript,
name: "Bash Script",
description: "A Bash script with argument handling",
files: vec![TemplateFile {
path: "{{name}}.sh",
content: BASH_SCRIPT,
is_required: true,
}],
}
}
}
// =============================================================================
// WASM Templates
// =============================================================================
const WASM_CARGO_TOML: &str = r##"[package]
name = "{{name}}"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
"##;
const WASM_HTTP_LIB_RS: &str = r##"//! {{description}}
//!
//! This WASM tool makes HTTP requests to external APIs.
use serde::{Deserialize, Serialize};
// Host function imports
#[link(wasm_import_module = "env")]
extern "C" {
fn host_log(level: i32, ptr: *const u8, len: usize);
fn host_http_request(
method_ptr: *const u8, method_len: usize,
url_ptr: *const u8, url_len: usize,
headers_ptr: *const u8, headers_len: usize,
body_ptr: *const u8, body_len: usize,
response_ptr: *mut u8, response_max_len: usize,
) -> i32;
}
fn log_info(msg: &str) {
unsafe { host_log(1, msg.as_ptr(), msg.len()); }
}
fn http_get(url: &str) -> Result<String, String> {
let method = "GET";
let mut response_buf = vec![0u8; 65536];
let result = unsafe {
host_http_request(
method.as_ptr(), method.len(),
url.as_ptr(), url.len(),
std::ptr::null(), 0,
std::ptr::null(), 0,
response_buf.as_mut_ptr(), response_buf.len(),
)
};
if result < 0 { return Err(format!("HTTP error: {}", result)); }
response_buf.truncate(result as usize);
String::from_utf8(response_buf).map_err(|e| e.to_string())
}
#[derive(Deserialize)]
struct Input {
{{input_fields}}
}
#[derive(Serialize)]
struct Output {
{{output_fields}}
}
#[no_mangle]
pub extern "C" fn run(input_ptr: *const u8, input_len: usize) -> u64 {
let result = run_inner(input_ptr, input_len);
let json = match result {
Ok(output) => serde_json::to_string(&output).unwrap_or_else(|e| {
format!("{{\"error\":\"serialize: {}\"}}", e)
}),
Err(e) => format!("{{\"error\":\"{}\"}}", e.replace('"', "'")),
};
let bytes = json.into_bytes();
let ptr = bytes.as_ptr() as u64;
let len = bytes.len() as u64;
std::mem::forget(bytes);
(len << 32) | ptr
}
fn run_inner(input_ptr: *const u8, input_len: usize) -> Result<Output, String> {
let input_bytes = unsafe { std::slice::from_raw_parts(input_ptr, input_len) };
let input: Input = serde_json::from_slice(input_bytes)
.map_err(|e| format!("Invalid input: {}", e))?;
log_info("Processing request...");
{{implementation}}
Ok(Output {
{{output_construction}}
})
}
"##;
const WASM_TRANSFORM_LIB_RS: &str = r##"//! {{description}}
//!
//! This WASM tool transforms input data.
use serde::{Deserialize, Serialize};
#[link(wasm_import_module = "env")]
extern "C" {
fn host_log(level: i32, ptr: *const u8, len: usize);
}
fn log_info(msg: &str) {
unsafe { host_log(1, msg.as_ptr(), msg.len()); }
}
#[derive(Deserialize)]
struct Input {
{{input_fields}}
}
#[derive(Serialize)]
struct Output {
{{output_fields}}
}
#[no_mangle]
pub extern "C" fn run(input_ptr: *const u8, input_len: usize) -> u64 {
let result = run_inner(input_ptr, input_len);
let json = match result {
Ok(output) => serde_json::to_string(&output).unwrap_or_else(|e| {
format!("{{\"error\":\"serialize: {}\"}}", e)
}),
Err(e) => format!("{{\"error\":\"{}\"}}", e.replace('"', "'")),
};
let bytes = json.into_bytes();
let ptr = bytes.as_ptr() as u64;
let len = bytes.len() as u64;
std::mem::forget(bytes);
(len << 32) | ptr
}
fn run_inner(input_ptr: *const u8, input_len: usize) -> Result<Output, String> {
let input_bytes = unsafe { std::slice::from_raw_parts(input_ptr, input_len) };
let input: Input = serde_json::from_slice(input_bytes)
.map_err(|e| format!("Invalid input: {}", e))?;
log_info("Transforming data...");
{{implementation}}
Ok(Output {
{{output_construction}}
})
}
"##;
const WASM_COMPUTE_LIB_RS: &str = r##"//! {{description}}
//!
//! This WASM tool performs pure computation.
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Input {
{{input_fields}}
}
#[derive(Serialize)]
struct Output {
{{output_fields}}
}
#[no_mangle]
pub extern "C" fn run(input_ptr: *const u8, input_len: usize) -> u64 {
let result = run_inner(input_ptr, input_len);
let json = match result {
Ok(output) => serde_json::to_string(&output).unwrap_or_else(|e| {
format!("{{\"error\":\"serialize: {}\"}}", e)
}),
Err(e) => format!("{{\"error\":\"{}\"}}", e.replace('"', "'")),
};
let bytes = json.into_bytes();
let ptr = bytes.as_ptr() as u64;
let len = bytes.len() as u64;
std::mem::forget(bytes);
(len << 32) | ptr
}
fn run_inner(input_ptr: *const u8, input_len: usize) -> Result<Output, String> {
let input_bytes = unsafe { std::slice::from_raw_parts(input_ptr, input_len) };
let input: Input = serde_json::from_slice(input_bytes)
.map_err(|e| format!("Invalid input: {}", e))?;
{{implementation}}
Ok(Output {
{{output_construction}}
})
}
"##;
// =============================================================================
// CLI Templates
// =============================================================================
const CLI_CARGO_TOML: &str = r##"[package]
name = "{{name}}"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
"##;
const CLI_MAIN_RS: &str = r##"//! {{description}}
use clap::Parser;
use anyhow::Result;
#[derive(Parser, Debug)]
#[command(name = "{{name}}")]
#[command(about = "{{description}}")]
struct Args {
{{cli_args}}
}
fn main() -> Result<()> {
let args = Args::parse();
{{implementation}}
Ok(())
}
"##;
// =============================================================================
// Script Templates
// =============================================================================
const PYTHON_SCRIPT: &str = r##"#!/usr/bin/env python3
"""{{description}}"""
import argparse
import json
import sys
def main():
parser = argparse.ArgumentParser(description="{{description}}")
{{python_args}}
args = parser.parse_args()
{{implementation}}
if __name__ == "__main__":
main()
"##;
const BASH_SCRIPT: &str = r##"#!/bin/bash
# {{description}}
set -euo pipefail
usage() {
echo "Usage: $0 {{bash_usage}}"
exit 1
}
{{bash_arg_parsing}}
{{implementation}}
"##;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_template_engine() {
let mut engine = TemplateEngine::new();
engine.set("name", "my_tool");
engine.set("description", "A cool tool");
let result = engine.render("Name: {{name}}, Desc: {{description}}");
assert_eq!(result, "Name: my_tool, Desc: A cool tool");
}
#[test]
fn test_get_template() {
let template = Template::get(TemplateType::WasmHttpTool);
assert_eq!(template.name, "WASM HTTP Tool");
assert!(!template.files.is_empty());
}
}
+527
View File
@@ -0,0 +1,527 @@
//! Testing harness for built tools.
//!
//! Provides automated testing of generated tools before registration,
//! ensuring they work correctly with various inputs.
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::context::JobContext;
use crate::tools::tool::Tool;
use crate::tools::wasm::{Capabilities, WasmError, WasmToolRuntime, WasmToolWrapper};
/// Errors during testing.
#[derive(Debug, Error)]
pub enum TestError {
#[error("Failed to load WASM module: {0}")]
LoadError(#[from] WasmError),
#[error("Test execution failed: {0}")]
ExecutionFailed(String),
#[error("Test timed out after {0:?}")]
Timeout(Duration),
#[error("Output mismatch: expected {expected}, got {actual}")]
OutputMismatch { expected: String, actual: String },
#[error("Test assertion failed: {0}")]
AssertionFailed(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
/// A single test case.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestCase {
/// Name of the test.
pub name: String,
/// Description of what this test verifies.
pub description: Option<String>,
/// Input JSON to pass to the tool.
pub input: serde_json::Value,
/// Expected output (if exact match required).
pub expected_output: Option<serde_json::Value>,
/// Expected fields in output (partial match).
pub expected_fields: Option<Vec<ExpectedField>>,
/// Whether the tool should return an error.
pub expect_error: bool,
/// Expected error message substring (if expect_error is true).
pub error_contains: Option<String>,
/// Timeout for this specific test.
pub timeout_ms: Option<u64>,
}
/// An expected field in the output.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExpectedField {
/// JSON path to the field (e.g., "result.value" or "data[0].name").
pub path: String,
/// Expected value at that path.
pub value: Option<serde_json::Value>,
/// Just check that the field exists (if value is None).
pub exists: bool,
}
/// Result of running a single test.
#[derive(Debug, Clone)]
pub struct TestResult {
/// Name of the test.
pub name: String,
/// Whether the test passed.
pub passed: bool,
/// Duration of the test.
pub duration: Duration,
/// Error message if failed.
pub error: Option<String>,
/// Actual output from the tool.
pub actual_output: Option<serde_json::Value>,
}
/// A suite of tests for a tool.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestSuite {
/// Name of the test suite.
pub name: String,
/// Description of the suite.
pub description: Option<String>,
/// Test cases in the suite.
pub tests: Vec<TestCase>,
/// Default timeout for tests in milliseconds.
pub default_timeout_ms: u64,
}
impl Default for TestSuite {
fn default() -> Self {
Self {
name: "default".to_string(),
description: None,
tests: Vec::new(),
default_timeout_ms: 5000,
}
}
}
impl TestSuite {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
..Default::default()
}
}
/// Add a test case.
pub fn add_test(&mut self, test: TestCase) -> &mut Self {
self.tests.push(test);
self
}
/// Add a simple input/output test.
pub fn add_io_test(
&mut self,
name: impl Into<String>,
input: serde_json::Value,
expected: serde_json::Value,
) -> &mut Self {
self.tests.push(TestCase {
name: name.into(),
description: None,
input,
expected_output: Some(expected),
expected_fields: None,
expect_error: false,
error_contains: None,
timeout_ms: None,
});
self
}
/// Add a test that expects an error.
pub fn add_error_test(
&mut self,
name: impl Into<String>,
input: serde_json::Value,
error_contains: impl Into<String>,
) -> &mut Self {
self.tests.push(TestCase {
name: name.into(),
description: None,
input,
expected_output: None,
expected_fields: None,
expect_error: true,
error_contains: Some(error_contains.into()),
timeout_ms: None,
});
self
}
}
/// Harness for running tests against WASM tools.
pub struct TestHarness {
runtime: Arc<WasmToolRuntime>,
capabilities: Capabilities,
default_timeout: Duration,
}
impl TestHarness {
pub fn new(runtime: Arc<WasmToolRuntime>) -> Self {
Self {
runtime,
capabilities: Capabilities::none(),
default_timeout: Duration::from_secs(5),
}
}
/// Set capabilities for test execution.
pub fn with_capabilities(mut self, caps: Capabilities) -> Self {
self.capabilities = caps;
self
}
/// Set default timeout.
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.default_timeout = timeout;
self
}
/// Run a test suite against a WASM file.
pub async fn run_suite_file(
&self,
wasm_path: &Path,
suite: &TestSuite,
) -> Result<Vec<TestResult>, TestError> {
let bytes = tokio::fs::read(wasm_path).await?;
self.run_suite_bytes(&bytes, suite).await
}
/// Run a test suite against WASM bytes.
pub async fn run_suite_bytes(
&self,
wasm_bytes: &[u8],
suite: &TestSuite,
) -> Result<Vec<TestResult>, TestError> {
// Prepare the module
let prepared = self.runtime.prepare(&suite.name, wasm_bytes, None).await?;
// Create a tool wrapper for execution
let tool = WasmToolWrapper::new(
Arc::clone(&self.runtime),
prepared,
self.capabilities.clone(),
);
let mut results = Vec::with_capacity(suite.tests.len());
for test in &suite.tests {
let result = self.run_test(&tool, test, suite.default_timeout_ms).await;
results.push(result);
}
Ok(results)
}
/// Run a single test case.
async fn run_test(
&self,
tool: &WasmToolWrapper,
test: &TestCase,
default_timeout_ms: u64,
) -> TestResult {
let timeout = Duration::from_millis(test.timeout_ms.unwrap_or(default_timeout_ms));
let start = Instant::now();
let ctx = JobContext::default();
// Execute with timeout
let exec_result = tokio::time::timeout(timeout, async {
tool.execute(test.input.clone(), &ctx).await
})
.await;
let duration = start.elapsed();
match exec_result {
Err(_) => TestResult {
name: test.name.clone(),
passed: false,
duration,
error: Some(format!("Test timed out after {:?}", timeout)),
actual_output: None,
},
Ok(Err(e)) => {
// Execution error
if test.expect_error {
let error_str = e.to_string();
let matches = test
.error_contains
.as_ref()
.is_none_or(|expected| error_str.contains(expected));
TestResult {
name: test.name.clone(),
passed: matches,
duration,
error: if matches {
None
} else {
Some(format!(
"Expected error containing '{}', got: {}",
test.error_contains.as_deref().unwrap_or(""),
error_str
))
},
actual_output: None,
}
} else {
TestResult {
name: test.name.clone(),
passed: false,
duration,
error: Some(format!("Unexpected error: {}", e)),
actual_output: None,
}
}
}
Ok(Ok(output)) => {
let actual = output.result;
// Check if output contains an error field
if let Some(error_val) = actual.get("error") {
if test.expect_error {
let error_str = error_val.as_str().unwrap_or("");
let matches = test
.error_contains
.as_ref()
.is_none_or(|expected| error_str.contains(expected));
return TestResult {
name: test.name.clone(),
passed: matches,
duration,
error: if matches {
None
} else {
Some(format!(
"Expected error containing '{}', got: {}",
test.error_contains.as_deref().unwrap_or(""),
error_str
))
},
actual_output: Some(actual),
};
} else {
return TestResult {
name: test.name.clone(),
passed: false,
duration,
error: Some(format!("Unexpected error in output: {}", error_val)),
actual_output: Some(actual),
};
}
}
// Verify expected output
if let Some(ref expected) = test.expected_output {
if &actual != expected {
return TestResult {
name: test.name.clone(),
passed: false,
duration,
error: Some(format!(
"Output mismatch:\nExpected: {}\nActual: {}",
serde_json::to_string_pretty(expected).unwrap_or_default(),
serde_json::to_string_pretty(&actual).unwrap_or_default()
)),
actual_output: Some(actual),
};
}
}
// Verify expected fields
if let Some(ref fields) = test.expected_fields {
for field in fields {
let field_value = get_json_path(&actual, &field.path);
if field.exists && field_value.is_none() {
return TestResult {
name: test.name.clone(),
passed: false,
duration,
error: Some(format!("Missing expected field: {}", field.path)),
actual_output: Some(actual),
};
}
if let Some(ref expected_value) = field.value {
if field_value != Some(expected_value) {
return TestResult {
name: test.name.clone(),
passed: false,
duration,
error: Some(format!(
"Field '{}' mismatch: expected {:?}, got {:?}",
field.path, expected_value, field_value
)),
actual_output: Some(actual),
};
}
}
}
}
TestResult {
name: test.name.clone(),
passed: true,
duration,
error: None,
actual_output: Some(actual),
}
}
}
}
}
/// Get a value from a JSON object by path (e.g., "foo.bar[0].baz").
fn get_json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
let mut current = value;
for segment in path.split('.') {
// Handle array indexing like "items[0]"
if let Some(bracket_pos) = segment.find('[') {
let key = &segment[..bracket_pos];
let index_str = &segment[bracket_pos + 1..segment.len() - 1];
if !key.is_empty() {
current = current.get(key)?;
}
let index: usize = index_str.parse().ok()?;
current = current.get(index)?;
} else {
current = current.get(segment)?;
}
}
Some(current)
}
/// Generate basic test cases for a tool based on its schema.
pub fn generate_basic_tests(name: &str, input_schema: &serde_json::Value) -> TestSuite {
let mut suite = TestSuite::new(format!("{}_basic_tests", name));
suite.description = Some("Auto-generated basic tests".to_string());
// Test with empty input
suite.add_error_test("empty_input", serde_json::json!({}), "");
// Test with null values for required fields
if let Some(required) = input_schema.get("required").and_then(|r| r.as_array()) {
let mut null_input = serde_json::Map::new();
for req in required {
if let Some(field_name) = req.as_str() {
null_input.insert(field_name.to_string(), serde_json::Value::Null);
}
}
suite.add_error_test(
"null_required_fields",
serde_json::Value::Object(null_input),
"",
);
}
// Test with valid minimal input (if we can construct it)
if let Some(properties) = input_schema.get("properties").and_then(|p| p.as_object()) {
let mut minimal_input = serde_json::Map::new();
for (name, prop) in properties {
if let Some(prop_type) = prop.get("type").and_then(|t| t.as_str()) {
let value = match prop_type {
"string" => serde_json::Value::String("test".to_string()),
"integer" | "number" => serde_json::Value::Number(0.into()),
"boolean" => serde_json::Value::Bool(false),
"array" => serde_json::Value::Array(vec![]),
"object" => serde_json::Value::Object(serde_json::Map::new()),
_ => continue,
};
minimal_input.insert(name.clone(), value);
}
}
suite.tests.push(TestCase {
name: "minimal_valid_input".to_string(),
description: Some("Test with minimal valid input".to_string()),
input: serde_json::Value::Object(minimal_input),
expected_output: None,
expected_fields: None,
expect_error: false,
error_contains: None,
timeout_ms: None,
});
}
suite
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_json_path() {
let json = serde_json::json!({
"foo": {
"bar": [1, 2, 3],
"baz": "hello"
}
});
assert_eq!(
get_json_path(&json, "foo.baz"),
Some(&serde_json::json!("hello"))
);
assert_eq!(
get_json_path(&json, "foo.bar[0]"),
Some(&serde_json::json!(1))
);
assert_eq!(
get_json_path(&json, "foo.bar[2]"),
Some(&serde_json::json!(3))
);
assert_eq!(get_json_path(&json, "foo.missing"), None);
}
#[test]
fn test_test_suite_builder() {
let mut suite = TestSuite::new("my_tests");
suite
.add_io_test(
"basic",
serde_json::json!({"x": 1}),
serde_json::json!({"y": 2}),
)
.add_error_test("invalid", serde_json::json!({}), "required");
assert_eq!(suite.tests.len(), 2);
assert!(!suite.tests[0].expect_error);
assert!(suite.tests[1].expect_error);
}
#[test]
fn test_generate_basic_tests() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {"type": "string"},
"count": {"type": "integer"}
},
"required": ["name"]
});
let suite = generate_basic_tests("my_tool", &schema);
assert!(!suite.tests.is_empty());
}
}
+319
View File
@@ -0,0 +1,319 @@
//! WASM tool validation.
//!
//! Validates that built WASM modules conform to the expected tool interface
//! before they can be registered with the agent.
use std::path::Path;
use thiserror::Error;
/// Errors during WASM validation.
#[derive(Debug, Error)]
pub enum ValidationError {
#[error("Failed to read WASM file: {0}")]
IoError(#[from] std::io::Error),
#[error("Invalid WASM module: {0}")]
InvalidModule(String),
#[error("Missing required export: {0}")]
MissingExport(String),
#[error("Invalid export signature for '{name}': expected {expected}, got {actual}")]
InvalidSignature {
name: String,
expected: String,
actual: String,
},
#[error("Module uses disallowed import: {module}::{name}")]
DisallowedImport { module: String, name: String },
#[error("Module exceeds size limit: {size} bytes (max: {max} bytes)")]
TooLarge { size: u64, max: u64 },
#[error("Validation failed: {0}")]
Other(String),
}
/// Result of WASM validation.
#[derive(Debug)]
pub struct ValidationResult {
/// Whether the module is valid.
pub is_valid: bool,
/// List of validation errors (empty if valid).
pub errors: Vec<ValidationError>,
/// List of warnings (non-fatal issues).
pub warnings: Vec<String>,
/// Detected exports.
pub exports: Vec<ExportInfo>,
/// Detected imports.
pub imports: Vec<ImportInfo>,
/// Module size in bytes.
pub size_bytes: u64,
}
/// Information about an exported function.
#[derive(Debug, Clone)]
pub struct ExportInfo {
pub name: String,
pub kind: ExportKind,
}
/// Kind of export.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportKind {
Function,
Memory,
Table,
Global,
}
/// Information about an imported function.
#[derive(Debug, Clone)]
pub struct ImportInfo {
pub module: String,
pub name: String,
pub kind: ImportKind,
}
/// Kind of import.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportKind {
Function,
Memory,
Table,
Global,
}
/// Validator for WASM tool modules.
pub struct WasmValidator {
/// Maximum module size in bytes.
max_size: u64,
/// Required exports that must be present.
required_exports: Vec<String>,
/// Allowed import modules.
allowed_import_modules: Vec<String>,
}
impl Default for WasmValidator {
fn default() -> Self {
Self {
max_size: 10 * 1024 * 1024, // 10 MB
required_exports: vec!["run".to_string()],
allowed_import_modules: vec![
"env".to_string(),
"wasi_snapshot_preview1".to_string(),
"wasi".to_string(),
],
}
}
}
impl WasmValidator {
pub fn new() -> Self {
Self::default()
}
/// Set maximum module size.
pub fn with_max_size(mut self, max_bytes: u64) -> Self {
self.max_size = max_bytes;
self
}
/// Add a required export.
pub fn with_required_export(mut self, name: impl Into<String>) -> Self {
self.required_exports.push(name.into());
self
}
/// Add an allowed import module.
pub fn with_allowed_import(mut self, module: impl Into<String>) -> Self {
self.allowed_import_modules.push(module.into());
self
}
/// Validate a WASM file.
pub async fn validate_file(&self, path: &Path) -> Result<ValidationResult, ValidationError> {
let bytes = tokio::fs::read(path).await?;
self.validate_bytes(&bytes)
}
/// Validate WASM bytes.
pub fn validate_bytes(&self, bytes: &[u8]) -> Result<ValidationResult, ValidationError> {
let mut errors = Vec::new();
let mut warnings = Vec::new();
let mut exports = Vec::new();
let mut imports = Vec::new();
let size_bytes = bytes.len() as u64;
// Check size
if size_bytes > self.max_size {
errors.push(ValidationError::TooLarge {
size: size_bytes,
max: self.max_size,
});
}
// Parse WASM module
let parser = wasmparser::Parser::new(0);
for payload in parser.parse_all(bytes) {
match payload {
Ok(wasmparser::Payload::ExportSection(reader)) => {
for export in reader {
match export {
Ok(exp) => {
let kind = match exp.kind {
wasmparser::ExternalKind::Func => ExportKind::Function,
wasmparser::ExternalKind::Memory => ExportKind::Memory,
wasmparser::ExternalKind::Table => ExportKind::Table,
wasmparser::ExternalKind::Global => ExportKind::Global,
wasmparser::ExternalKind::Tag => continue,
};
exports.push(ExportInfo {
name: exp.name.to_string(),
kind,
});
}
Err(e) => {
errors.push(ValidationError::InvalidModule(format!(
"Failed to parse export: {}",
e
)));
}
}
}
}
Ok(wasmparser::Payload::ImportSection(reader)) => {
for import in reader {
match import {
Ok(imp) => {
let kind = match imp.ty {
wasmparser::TypeRef::Func(_) => ImportKind::Function,
wasmparser::TypeRef::Memory(_) => ImportKind::Memory,
wasmparser::TypeRef::Table(_) => ImportKind::Table,
wasmparser::TypeRef::Global(_) => ImportKind::Global,
wasmparser::TypeRef::Tag(_) => continue,
};
imports.push(ImportInfo {
module: imp.module.to_string(),
name: imp.name.to_string(),
kind,
});
// Check if import module is allowed
if !self
.allowed_import_modules
.contains(&imp.module.to_string())
{
errors.push(ValidationError::DisallowedImport {
module: imp.module.to_string(),
name: imp.name.to_string(),
});
}
}
Err(e) => {
errors.push(ValidationError::InvalidModule(format!(
"Failed to parse import: {}",
e
)));
}
}
}
}
Ok(_) => {
// Other sections are OK
}
Err(e) => {
errors.push(ValidationError::InvalidModule(format!(
"Failed to parse WASM: {}",
e
)));
break;
}
}
}
// Check required exports
for required in &self.required_exports {
if !exports.iter().any(|e| &e.name == required) {
errors.push(ValidationError::MissingExport(required.clone()));
}
}
// Check for common issues (warnings)
if !exports
.iter()
.any(|e| e.name == "memory" && e.kind == ExportKind::Memory)
{
warnings
.push("Module does not export memory - host cannot read/write data".to_string());
}
// Check for potentially dangerous imports
for import in &imports {
if import.module == "wasi_snapshot_preview1" {
match import.name.as_str() {
"fd_write" | "fd_read" | "path_open" | "path_create_directory" => {
warnings.push(format!(
"Module uses WASI filesystem function '{}' - ensure this is intended",
import.name
));
}
"sock_send" | "sock_recv" | "sock_accept" => {
warnings.push(format!(
"Module uses WASI socket function '{}' - ensure this is intended",
import.name
));
}
_ => {}
}
}
}
Ok(ValidationResult {
is_valid: errors.is_empty(),
errors,
warnings,
exports,
imports,
size_bytes,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validator_default() {
let validator = WasmValidator::new();
assert_eq!(validator.max_size, 10 * 1024 * 1024);
assert!(validator.required_exports.contains(&"run".to_string()));
}
#[test]
fn test_validator_builder() {
let validator = WasmValidator::new()
.with_max_size(1024)
.with_required_export("custom_export")
.with_allowed_import("custom_module");
assert_eq!(validator.max_size, 1024);
assert!(
validator
.required_exports
.contains(&"custom_export".to_string())
);
assert!(
validator
.allowed_import_modules
.contains(&"custom_module".to_string())
);
}
// Note: Full WASM parsing tests would require actual WASM binaries
}