yoi/crates/memory/src/tool/delete.rs

153 lines
5.2 KiB
Rust

//! `MemoryDelete` tool for removing memory / knowledge records with audit logging.
use std::sync::Arc;
use async_trait::async_trait;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use serde::Deserialize;
use crate::audit::{AuditStatus, RecordOperationAudit, append_record_operation, file_hash};
use crate::tool::MemoryToolKind;
use crate::workspace::WorkspaceLayout;
const DESCRIPTION: &str = "Delete an existing memory or knowledge record selected by `kind` + `slug`. \
For `summary` omit `slug`; for the others `slug` is required. The delete is audited and cannot target \
workflow or staging/log files.";
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct DeleteParams {
/// Kind of record to delete.
kind: MemoryToolKind,
/// Slug. Required for everything except `summary`; forbidden for `summary`.
#[serde(default)]
slug: Option<String>,
}
struct MemoryDeleteTool {
layout: WorkspaceLayout,
}
#[async_trait]
impl Tool for MemoryDeleteTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: DeleteParams = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(format!("invalid MemoryDelete input: {e}")))?;
let path = params
.kind
.resolve_path(&self.layout, params.slug.as_deref())?;
let kind = params.kind.to_string();
let slug = audit_slug(&params.kind, params.slug.as_deref());
let before_hash = file_hash(&path).ok().flatten();
if before_hash.is_none() {
let reason = format!("record not found: {}", path.display());
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "delete".to_string(),
status: AuditStatus::Failed,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::ExecutionFailed(reason));
}
if let Err(err) = std::fs::remove_file(&path) {
let reason = format!("failed to delete {}: {err}", path.display());
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "delete".to_string(),
status: AuditStatus::Failed,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: Some(reason.clone()),
},
);
return Err(ToolError::ExecutionFailed(reason));
}
let _ = append_record_operation(
&self.layout,
RecordOperationAudit {
op: "delete".to_string(),
status: AuditStatus::Success,
kind,
slug,
path: path.display().to_string(),
before_hash,
after_hash: None,
reason: None,
},
);
Ok(ToolOutput {
summary: format!("Deleted {}", path.display()),
content: None,
})
}
}
pub fn delete_tool(layout: WorkspaceLayout) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(DeleteParams);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("MemoryDelete")
.description(DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(MemoryDeleteTool {
layout: layout.clone(),
});
(meta, tool)
})
}
fn audit_slug(kind: &MemoryToolKind, slug: Option<&str>) -> String {
match kind {
MemoryToolKind::Summary => "summary".to_string(),
_ => slug.unwrap_or("<missing>").to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use tempfile::TempDir;
#[tokio::test]
async fn delete_removes_file_and_audits() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
std::fs::create_dir_all(layout.decisions_dir()).unwrap();
let path = layout.decisions_dir().join("obsolete.md");
let now = Utc::now().to_rfc3339();
std::fs::write(
&path,
format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nold"
),
)
.unwrap();
let (_, tool) = delete_tool(layout.clone())();
let out = tool
.execute(r#"{"kind":"decision","slug":"obsolete"}"#)
.await
.unwrap();
assert!(out.summary.contains("Deleted"));
assert!(!path.exists());
let log = std::fs::read_to_string(layout.audit_current_log_path()).unwrap();
assert!(log.contains(r#""event":"record_operation""#));
assert!(log.contains(r#""op":"delete""#));
assert!(log.contains(r#""status":"success""#));
}
}