Files
zclaw_openfang/crates/zclaw-runtime/src/tool/builtin/file_write.rs
iven f89b2263d1
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(runtime,kernel): HandTool 空壳修复 — 桥接到 HandRegistry 真实执行
B-HAND-1 修复: LLM 调用 hand_quiz/hand_researcher 等 Hand 工具后,
HandTool::execute() 原来返回假成功 JSON, 实际 Hand 并不执行.

修复方案 (沿用 SkillExecutor 模式):
- tool.rs: 新增 HandExecutor trait + ToolContext.hand_executor 字段
- hand_tool.rs: execute() 通过 context.hand_executor 分发到真实执行
- loop_runner.rs: AgentLoop 新增 hand_executor 字段 + builder + 3处 ToolContext 传递
- adapters.rs: 新增 KernelHandExecutor 桥接 HandRegistry.execute()
- kernel/mod.rs: 初始化 KernelHandExecutor + 注册到 AgentLoop
- messaging.rs: 两处 AgentLoop 构建添加 .with_hand_executor()

数据流: LLM tool call → HandTool::execute() → ToolContext.hand_executor
         → KernelHandExecutor → HandRegistry.execute() → Hand trait impl

809 tests passed, 0 failed.
2026-04-20 12:50:47 +08:00

233 lines
7.4 KiB
Rust

//! File write tool with path validation
use async_trait::async_trait;
use serde_json::{json, Value};
use zclaw_types::{Result, ZclawError};
use std::fs;
use std::io::Write;
use crate::tool::{Tool, ToolContext};
use super::path_validator::PathValidator;
pub struct FileWriteTool;
impl FileWriteTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Tool for FileWriteTool {
fn name(&self) -> &str {
"file_write"
}
fn description(&self) -> &str {
"Write content to a file on the filesystem. The file must be within allowed paths."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the file to write"
},
"content": {
"type": "string",
"description": "The content to write to the file"
},
"mode": {
"type": "string",
"description": "Write mode: 'create' (fail if exists), 'overwrite' (replace), 'append' (add to end)",
"enum": ["create", "overwrite", "append"],
"default": "create"
},
"encoding": {
"type": "string",
"description": "Content encoding (default: utf-8)",
"enum": ["utf-8", "base64"]
}
},
"required": ["path", "content"]
})
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value> {
let path = input["path"].as_str()
.ok_or_else(|| ZclawError::InvalidInput("Missing 'path' parameter".into()))?;
let content = input["content"].as_str()
.ok_or_else(|| ZclawError::InvalidInput("Missing 'content' parameter".into()))?;
let mode = input["mode"].as_str().unwrap_or("create");
let encoding = input["encoding"].as_str().unwrap_or("utf-8");
// Validate path using context's path validator or create default
let validator = context.path_validator.as_ref()
.map(|v| v.clone())
.unwrap_or_else(|| {
// Create default validator with workspace as allowed path
let mut validator = PathValidator::new();
if let Some(ref workspace) = context.working_directory {
validator = validator.with_workspace(std::path::PathBuf::from(workspace));
}
validator
});
// Validate path for write access
let validated_path = validator.validate_write(path)?;
// Decode content based on encoding
let bytes = match encoding {
"base64" => {
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
BASE64.decode(content)
.map_err(|e| ZclawError::InvalidInput(format!("Invalid base64 content: {}", e)))?
}
_ => content.as_bytes().to_vec()
};
// Check if file exists and handle mode
let file_exists = validated_path.exists();
if file_exists && mode == "create" {
return Err(ZclawError::InvalidInput(format!(
"File already exists: {}",
validated_path.display()
)));
}
// Write file
let mut file = match mode {
"append" => {
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&validated_path)
.map_err(|e| ZclawError::ToolError(format!("Failed to open file for append: {}", e)))?
}
_ => {
// create or overwrite
fs::File::create(&validated_path)
.map_err(|e| ZclawError::ToolError(format!("Failed to create file: {}", e)))?
}
};
file.write_all(&bytes)
.map_err(|e| ZclawError::ToolError(format!("Failed to write file: {}", e)))?;
// Echo content preview in output for artifact auto-creation in frontend
let content_preview: String = if content.len() <= 5000 {
content.to_string()
} else {
let truncation_point = content.char_indices()
.take_while(|(i, _)| *i < 5000)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(5000.min(content.len()));
format!("{}...[truncated, {} total bytes]", &content[..truncation_point], content.len())
};
Ok(json!({
"success": true,
"bytes_written": bytes.len(),
"path": validated_path.to_string_lossy(),
"mode": mode,
"content": content_preview
}))
}
}
impl Default for FileWriteTool {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
use crate::tool::builtin::PathValidator;
fn create_test_context_with_tempdir(dir: &std::path::Path) -> ToolContext {
// Use canonicalized path to handle Windows extended-length paths
let workspace = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
let path_validator = Some(PathValidator::new().with_workspace(workspace));
ToolContext {
agent_id: zclaw_types::AgentId::new(),
working_directory: None,
session_id: None,
skill_executor: None,
hand_executor: None,
path_validator,
event_sender: None,
}
}
#[tokio::test]
async fn test_write_new_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.txt").to_str().unwrap().to_string();
let input = json!({
"path": path,
"content": "Hello, World!"
});
let context = create_test_context_with_tempdir(dir.path());
let tool = FileWriteTool::new();
let result = tool.execute(input, &context).await.unwrap();
assert!(result["success"].as_bool().unwrap());
assert_eq!(result["bytes_written"].as_u64().unwrap(), 13);
}
#[tokio::test]
async fn test_create_mode_fails_on_existing() {
let dir = tempdir().unwrap();
let path = dir.path().join("existing.txt");
fs::write(&path, "existing content").unwrap();
let input = json!({
"path": path.to_str().unwrap(),
"content": "new content",
"mode": "create"
});
let context = create_test_context_with_tempdir(dir.path());
let tool = FileWriteTool::new();
let result = tool.execute(input, &context).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_overwrite_mode() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.txt");
fs::write(&path, "old content").unwrap();
let input = json!({
"path": path.to_str().unwrap(),
"content": "new content",
"mode": "overwrite"
});
let context = create_test_context_with_tempdir(dir.path());
let tool = FileWriteTool::new();
let result = tool.execute(input, &context).await.unwrap();
assert!(result["success"].as_bool().unwrap());
let content = fs::read_to_string(&path).unwrap();
assert_eq!(content, "new content");
}
}