メモリーに関するクレート作成・ファイル構造の実装
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
//! `MemoryEdit` tool — partial string replacement on an existing memory record.
|
||||
//!
|
||||
//! Reads current content, applies the replacement, runs the Linter on
|
||||
//! the result, writes only on success. The current-then-write window
|
||||
//! is single-tool-call narrow; an external tracker is intentionally
|
||||
//! omitted (memory tools are self-contained, no `tools` crate dep).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::linter::{LintReport, Linter, WriteMode};
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
const DESCRIPTION: &str = "Replace a substring in an existing memory or knowledge \
|
||||
record file. By default `old_string` must be unique in the file; set \
|
||||
`replace_all: true` to replace every occurrence. The resulting content is \
|
||||
re-validated by the memory linter; failure leaves the file untouched. Path \
|
||||
must be absolute and lie inside the workspace's `memory/` or `knowledge/` tree.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct EditParams {
|
||||
/// Absolute path under the workspace's `memory/` or `knowledge/` tree.
|
||||
file_path: PathBuf,
|
||||
/// String to replace. Must be unique in the file unless `replace_all` is true.
|
||||
old_string: String,
|
||||
/// Replacement string. Must differ from `old_string`.
|
||||
new_string: String,
|
||||
/// Replace all occurrences. Defaults to false.
|
||||
#[serde(default)]
|
||||
replace_all: bool,
|
||||
}
|
||||
|
||||
struct EditTool {
|
||||
linter: Linter,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for EditTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: EditParams = serde_json::from_str(input_json).map_err(|e| {
|
||||
ToolError::InvalidArgument(format!("invalid MemoryEdit input: {e}"))
|
||||
})?;
|
||||
|
||||
if !params.file_path.is_absolute() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"file_path must be absolute: {}",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
if params.old_string.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"old_string must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if params.old_string == params.new_string {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"old_string and new_string are identical".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Path-shape check; the layout::classify also runs inside the
|
||||
// linter but we want a crisp error before reading the file.
|
||||
if self
|
||||
.linter
|
||||
.layout()
|
||||
.classify(¶ms.file_path)
|
||||
.map_err(|e| ToolError::InvalidArgument(e.to_string()))?
|
||||
.is_none()
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"path is not under the memory tree: {}",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let current_bytes = std::fs::read(¶ms.file_path).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => ToolError::ExecutionFailed(format!(
|
||||
"file not found (use MemoryWrite to create): {}",
|
||||
params.file_path.display()
|
||||
)),
|
||||
_ => ToolError::ExecutionFailed(format!(
|
||||
"read failed at {}: {e}",
|
||||
params.file_path.display()
|
||||
)),
|
||||
})?;
|
||||
let current_text = std::str::from_utf8(¤t_bytes).map_err(|_| {
|
||||
ToolError::InvalidArgument(format!(
|
||||
"file is not valid UTF-8: {}",
|
||||
params.file_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let count = current_text.matches(¶ms.old_string).count();
|
||||
if count == 0 {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"old_string not found in {}",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
if !params.replace_all && count > 1 {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"old_string occurs {count} times in {}; pass replace_all: true or narrow the snippet",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let new_text = if params.replace_all {
|
||||
current_text.replace(¶ms.old_string, ¶ms.new_string)
|
||||
} else {
|
||||
current_text.replacen(¶ms.old_string, ¶ms.new_string, 1)
|
||||
};
|
||||
let occurrences = if params.replace_all { count } else { 1 };
|
||||
|
||||
let report = self.linter.lint(¶ms.file_path, &new_text, WriteMode::Update);
|
||||
if report.has_errors() {
|
||||
return Err(ToolError::InvalidArgument(format_report(&report)));
|
||||
}
|
||||
|
||||
std::fs::write(¶ms.file_path, new_text.as_bytes()).map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"failed to write {}: {e}",
|
||||
params.file_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let summary = format!(
|
||||
"Edited {} ({} replacement{}){}",
|
||||
params.file_path.display(),
|
||||
occurrences,
|
||||
if occurrences == 1 { "" } else { "s" },
|
||||
warning_tail(&report),
|
||||
);
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report(report: &LintReport) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut buf = String::from("memory linter rejected the edit:");
|
||||
for e in &report.errors {
|
||||
let _ = write!(&mut buf, "\n - {e}");
|
||||
}
|
||||
if !report.warnings.is_empty() {
|
||||
let _ = write!(&mut buf, "\nwarnings (informational):");
|
||||
for w in &report.warnings {
|
||||
let _ = write!(&mut buf, "\n - {w}");
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn warning_tail(report: &LintReport) -> String {
|
||||
if report.warnings.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut s = format!(" [{} warning(s)]", report.warnings.len());
|
||||
for w in &report.warnings {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut s, " {w};");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn edit_tool(layout: WorkspaceLayout) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(EditParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("MemoryEdit")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(EditTool {
|
||||
linter: Linter::new(layout.clone()),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn now() -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
fn setup() -> (TempDir, WorkspaceLayout, PathBuf) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let initial = format!(
|
||||
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\nbody body\n",
|
||||
n = now()
|
||||
);
|
||||
std::fs::write(&path, &initial).unwrap();
|
||||
(dir, layout, path)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_simple_replace() {
|
||||
let (_dir, layout, path) = setup();
|
||||
let (meta, tool) = edit_tool(layout)();
|
||||
assert_eq!(meta.name, "MemoryEdit");
|
||||
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"old_string": "body body",
|
||||
"new_string": "edited",
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("1 replacement"));
|
||||
let after = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(after.contains("edited"));
|
||||
assert!(!after.contains("body body"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_resulting_invalid_frontmatter_rolled_back() {
|
||||
let (_dir, layout, path) = setup();
|
||||
let (_, tool) = edit_tool(layout)();
|
||||
|
||||
// Drop the `status` field by replacing it with nothing.
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"old_string": "status: open\n",
|
||||
"new_string": "",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("status") || msg.contains("missing"));
|
||||
|
||||
// File untouched.
|
||||
let after = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(after.contains("status: open"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_missing_file() {
|
||||
let (dir, layout, _) = setup();
|
||||
let other = dir.path().join("memory/decisions/ghost.md");
|
||||
let (_, tool) = edit_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": other.to_str().unwrap(),
|
||||
"old_string": "x",
|
||||
"new_string": "y",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::ExecutionFailed(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_outside_memory_tree_rejected() {
|
||||
let (dir, layout, _) = setup();
|
||||
let other = dir.path().join("src/lib.rs");
|
||||
std::fs::create_dir_all(other.parent().unwrap()).unwrap();
|
||||
std::fs::write(&other, "fn main() {}").unwrap();
|
||||
let (_, tool) = edit_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": other.to_str().unwrap(),
|
||||
"old_string": "fn",
|
||||
"new_string": "pub fn",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_workflow_path_rejected() {
|
||||
let (dir, layout, _) = setup();
|
||||
let path = dir.path().join("memory/workflow/wf.md");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let initial = format!(
|
||||
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\n---\nbody\n",
|
||||
n = now()
|
||||
);
|
||||
std::fs::write(&path, &initial).unwrap();
|
||||
|
||||
let (_, tool) = edit_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"old_string": "body",
|
||||
"new_string": "edited",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.to_lowercase().contains("workflow"), "{msg}");
|
||||
// Original untouched.
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("body"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Tool implementations stub. Filled in once the linter compiles green.
|
||||
|
||||
mod edit;
|
||||
mod read;
|
||||
mod write;
|
||||
|
||||
pub use edit::edit_tool;
|
||||
pub use read::read_tool;
|
||||
pub use write::write_tool;
|
||||
@@ -0,0 +1,195 @@
|
||||
//! `MemoryRead` tool.
|
||||
//!
|
||||
//! Constrained to `<workspace>/memory/` and `<workspace>/knowledge/`
|
||||
//! paths. Returns line-numbered content (1-based), like the generic
|
||||
//! Read tool, but rejects anything outside the memory tree so the
|
||||
//! agent can't sneak in a non-memory read through this surface.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
const DESCRIPTION: &str = "Read a memory or knowledge record file under the \
|
||||
workspace's `memory/` or `knowledge/` tree. Returns line-numbered output \
|
||||
(1-based). Paths must be absolute and lie inside the memory tree.";
|
||||
|
||||
const DEFAULT_LIMIT: usize = 2000;
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct ReadParams {
|
||||
/// Absolute path to a file under the workspace's `memory/` or `knowledge/` tree.
|
||||
file_path: PathBuf,
|
||||
/// 0-based line offset from the start. Defaults to 0.
|
||||
#[serde(default)]
|
||||
offset: Option<usize>,
|
||||
/// Maximum number of lines to return. Defaults to 2000.
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
struct ReadTool {
|
||||
layout: WorkspaceLayout,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ReadTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: ReadParams = serde_json::from_str(input_json).map_err(|e| {
|
||||
ToolError::InvalidArgument(format!("invalid MemoryRead input: {e}"))
|
||||
})?;
|
||||
|
||||
if !params.file_path.is_absolute() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"file_path must be absolute: {}",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
if self
|
||||
.layout
|
||||
.classify(¶ms.file_path)
|
||||
.map_err(|e| ToolError::InvalidArgument(e.to_string()))?
|
||||
.is_none()
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"path is not under the memory tree: {}",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(¶ms.file_path).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => ToolError::ExecutionFailed(format!(
|
||||
"file not found: {}",
|
||||
params.file_path.display()
|
||||
)),
|
||||
_ => ToolError::ExecutionFailed(format!(
|
||||
"read failed at {}: {e}",
|
||||
params.file_path.display()
|
||||
)),
|
||||
})?;
|
||||
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
||||
let rendered = render_numbered(&text, offset, limit);
|
||||
|
||||
let summary = if rendered.truncated {
|
||||
format!(
|
||||
"Read {} line(s) [{}..{}] of {} from {}",
|
||||
rendered.line_count,
|
||||
offset + 1,
|
||||
offset + rendered.line_count,
|
||||
rendered.total_lines,
|
||||
params.file_path.display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Read {} line(s) from {}",
|
||||
rendered.line_count,
|
||||
params.file_path.display()
|
||||
)
|
||||
};
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(rendered.body),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Rendered {
|
||||
body: String,
|
||||
line_count: usize,
|
||||
total_lines: usize,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
fn render_numbered(text: &str, offset: usize, limit: usize) -> Rendered {
|
||||
let all_lines: Vec<&str> = text.lines().collect();
|
||||
let total_lines = all_lines.len();
|
||||
let start = offset.min(total_lines);
|
||||
let end = start.saturating_add(limit).min(total_lines);
|
||||
let slice = &all_lines[start..end];
|
||||
let line_count = slice.len();
|
||||
|
||||
use std::fmt::Write as _;
|
||||
let mut body = String::with_capacity(text.len().saturating_add(line_count * 8));
|
||||
for (i, line) in slice.iter().enumerate() {
|
||||
let lineno = start + i + 1;
|
||||
let _ = writeln!(&mut body, "{:>6}\t{}", lineno, line);
|
||||
}
|
||||
|
||||
Rendered {
|
||||
body,
|
||||
line_count,
|
||||
total_lines,
|
||||
truncated: start > 0 || end < total_lines,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_tool(layout: WorkspaceLayout) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(ReadParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("MemoryRead")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(ReadTool {
|
||||
layout: layout.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, WorkspaceLayout) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
(dir, layout)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_returns_numbered_lines() {
|
||||
let (dir, layout) = setup();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, "alpha\nbeta\n").unwrap();
|
||||
|
||||
let (_meta, tool) = read_tool(layout)();
|
||||
let inp = serde_json::json!({ "file_path": path.to_str().unwrap() });
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains(" 1\talpha"));
|
||||
assert!(body.contains(" 2\tbeta"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_outside_memory_tree() {
|
||||
let (dir, layout) = setup();
|
||||
let other = dir.path().join("src/main.rs");
|
||||
std::fs::create_dir_all(other.parent().unwrap()).unwrap();
|
||||
std::fs::write(&other, "fn main() {}").unwrap();
|
||||
|
||||
let (_, tool) = read_tool(layout)();
|
||||
let inp = serde_json::json!({ "file_path": other.to_str().unwrap() });
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_relative_path() {
|
||||
let (_dir, layout) = setup();
|
||||
let (_, tool) = read_tool(layout)();
|
||||
let inp = serde_json::json!({ "file_path": "memory/summary.md" });
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! `MemoryWrite` tool.
|
||||
//!
|
||||
//! Creates or overwrites a memory or knowledge record with full content.
|
||||
//! Pre-write Linter validates frontmatter, slug uniqueness (Create only),
|
||||
//! reference integrity, size limits, and the workflow-write ban. On any
|
||||
//! Linter error the tool returns `ToolError::InvalidArgument` with all
|
||||
//! violations aggregated and the file is **not** written.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::linter::{LintReport, Linter, WriteMode};
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
const DESCRIPTION: &str = "Create or overwrite a memory or knowledge record file. \
|
||||
Path must be absolute and lie inside the workspace's `memory/` or `knowledge/` \
|
||||
tree. Frontmatter is validated before the file is written; on validation \
|
||||
failure no write occurs and every violation is returned in the error message.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct WriteParams {
|
||||
/// Absolute path under the workspace's `memory/` or `knowledge/` tree.
|
||||
file_path: PathBuf,
|
||||
/// Full file contents (frontmatter + body).
|
||||
content: String,
|
||||
}
|
||||
|
||||
struct WriteTool {
|
||||
linter: Linter,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WriteTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: WriteParams = serde_json::from_str(input_json).map_err(|e| {
|
||||
ToolError::InvalidArgument(format!("invalid MemoryWrite input: {e}"))
|
||||
})?;
|
||||
|
||||
if !params.file_path.is_absolute() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"file_path must be absolute: {}",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let already_exists = params.file_path.exists();
|
||||
let mode = if already_exists {
|
||||
WriteMode::Update
|
||||
} else {
|
||||
WriteMode::Create
|
||||
};
|
||||
|
||||
let report = self.linter.lint(¶ms.file_path, ¶ms.content, mode);
|
||||
if report.has_errors() {
|
||||
return Err(ToolError::InvalidArgument(format_report(&report)));
|
||||
}
|
||||
|
||||
if let Some(parent) = params.file_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"failed to create directory {}: {e}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
std::fs::write(¶ms.file_path, params.content.as_bytes()).map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"failed to write {}: {e}",
|
||||
params.file_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let summary = format!(
|
||||
"{} {}{}",
|
||||
if already_exists { "Overwrote" } else { "Created" },
|
||||
params.file_path.display(),
|
||||
warning_tail(&report),
|
||||
);
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report(report: &LintReport) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut buf = String::from("memory linter rejected the write:");
|
||||
for e in &report.errors {
|
||||
let _ = write!(&mut buf, "\n - {e}");
|
||||
}
|
||||
if !report.warnings.is_empty() {
|
||||
let _ = write!(&mut buf, "\nwarnings (informational):");
|
||||
for w in &report.warnings {
|
||||
let _ = write!(&mut buf, "\n - {w}");
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn warning_tail(report: &LintReport) -> String {
|
||||
if report.warnings.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut s = format!(" [{} warning(s)]", report.warnings.len());
|
||||
for w in &report.warnings {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut s, " {w};");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn write_tool(layout: WorkspaceLayout) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(WriteParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("MemoryWrite")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(WriteTool {
|
||||
linter: Linter::new(layout.clone()),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn now() -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
fn setup() -> (TempDir, WorkspaceLayout) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
(dir, layout)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_creates_summary() {
|
||||
let (dir, layout) = setup();
|
||||
let path = dir.path().join("memory/summary.md");
|
||||
let content = format!("---\nupdated_at: {n}\n---\nbody\n", n = now());
|
||||
|
||||
let (meta, tool) = write_tool(layout)();
|
||||
assert_eq!(meta.name, "MemoryWrite");
|
||||
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"content": content,
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("Created"));
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_rejects_workflow() {
|
||||
let (dir, layout) = setup();
|
||||
let path = dir.path().join("memory/workflow/wf.md");
|
||||
let content = format!(
|
||||
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\n---\n",
|
||||
n = now()
|
||||
);
|
||||
let (_, tool) = write_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"content": content,
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("workflow"), "unexpected error: {msg}");
|
||||
assert!(!path.exists(), "workflow file must not be written");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_aggregates_multiple_errors() {
|
||||
let (dir, layout) = setup();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
// Missing required `status` field AND body too long.
|
||||
let huge = "x".repeat(8001);
|
||||
let content = format!(
|
||||
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\n---\n{huge}",
|
||||
n = now()
|
||||
);
|
||||
let (_, tool) = write_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"content": content,
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("status") || msg.contains("missing"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_blocks_create_when_existing() {
|
||||
let (dir, layout) = setup();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let initial = format!(
|
||||
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\nold\n",
|
||||
n = now()
|
||||
);
|
||||
std::fs::write(&path, &initial).unwrap();
|
||||
|
||||
// Same content as a re-write should pass (Update mode).
|
||||
let (_, tool) = write_tool(layout.clone())();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"content": initial,
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("Overwrote"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_rejects_non_absolute() {
|
||||
let (_dir, layout) = setup();
|
||||
let (_, tool) = write_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": "memory/summary.md",
|
||||
"content": "ignored",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_does_not_persist_on_lint_failure() {
|
||||
let (dir, layout) = setup();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
let bad = "no frontmatter at all";
|
||||
let (_, tool) = write_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"content": bad,
|
||||
});
|
||||
assert!(tool.execute(&inp.to_string()).await.is_err());
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user