memory: simplify document consolidation tools
This commit is contained in:
parent
e751a11a92
commit
a5d207821d
|
|
@ -8,7 +8,6 @@
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
|
|
@ -21,7 +20,6 @@ use crate::extract::{
|
||||||
ExtractedCandidate, ExtractedPayload, StagingEvidence, write_staging, write_staging_candidate,
|
ExtractedCandidate, ExtractedPayload, StagingEvidence, write_staging, write_staging_candidate,
|
||||||
};
|
};
|
||||||
use crate::schema::{SourceEvidenceRef, SourceRef};
|
use crate::schema::{SourceEvidenceRef, SourceRef};
|
||||||
use crate::tool::MemoryToolKind;
|
|
||||||
use crate::workspace::WorkspaceLayout;
|
use crate::workspace::WorkspaceLayout;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -30,10 +28,6 @@ pub enum MemoryBackendOperation {
|
||||||
Query(MemoryQueryOperation),
|
Query(MemoryQueryOperation),
|
||||||
ReadDocument(MemoryDocumentReadOperation),
|
ReadDocument(MemoryDocumentReadOperation),
|
||||||
UpdateDocument(MemoryDocumentUpdateOperation),
|
UpdateDocument(MemoryDocumentUpdateOperation),
|
||||||
Read(MemoryReadOperation),
|
|
||||||
Write(MemoryWriteOperation),
|
|
||||||
Edit(MemoryEditOperation),
|
|
||||||
Delete(MemoryDeleteOperation),
|
|
||||||
ResidentSummary(MemoryResidentSummaryOperation),
|
ResidentSummary(MemoryResidentSummaryOperation),
|
||||||
AppendAudit(MemoryAppendAuditOperation),
|
AppendAudit(MemoryAppendAuditOperation),
|
||||||
StageCandidate(MemoryStageCandidateOperation),
|
StageCandidate(MemoryStageCandidateOperation),
|
||||||
|
|
@ -85,46 +79,12 @@ pub struct MemoryDocumentReadOperation {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct MemoryDocumentUpdateOperation {
|
pub struct MemoryDocumentUpdateOperation {
|
||||||
pub body_md: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MemoryReadOperation {
|
|
||||||
pub kind: MemoryToolKind,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub slug: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub offset: Option<usize>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub limit: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MemoryWriteOperation {
|
|
||||||
pub kind: MemoryToolKind,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub slug: Option<String>,
|
|
||||||
pub content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MemoryEditOperation {
|
|
||||||
pub kind: MemoryToolKind,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub slug: Option<String>,
|
|
||||||
pub old_string: String,
|
pub old_string: String,
|
||||||
pub new_string: String,
|
pub new_string: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub replace_all: bool,
|
pub replace_all: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MemoryDeleteOperation {
|
|
||||||
pub kind: MemoryToolKind,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub slug: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct MemoryResidentSummaryOperation {}
|
pub struct MemoryResidentSummaryOperation {}
|
||||||
|
|
||||||
|
|
@ -182,19 +142,13 @@ pub enum MemoryStagingCloseAction {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct MemoryStagingAffectedMemory {
|
pub struct MemoryStagingAffectedMemory {
|
||||||
pub kind: MemoryToolKind,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub slug: Option<String>,
|
|
||||||
pub operation: MemoryStagingAffectedMemoryOperation,
|
pub operation: MemoryStagingAffectedMemoryOperation,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum MemoryStagingAffectedMemoryOperation {
|
pub enum MemoryStagingAffectedMemoryOperation {
|
||||||
Read,
|
|
||||||
Write,
|
|
||||||
Edit,
|
Edit,
|
||||||
Delete,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -244,9 +198,9 @@ pub fn execute_memory_backend_operation(
|
||||||
operation: MemoryBackendOperation,
|
operation: MemoryBackendOperation,
|
||||||
) -> io::Result<MemoryBackendOperationResult> {
|
) -> io::Result<MemoryBackendOperationResult> {
|
||||||
match operation {
|
match operation {
|
||||||
MemoryBackendOperation::Query(operation) => {
|
MemoryBackendOperation::Query(operation) => Ok(MemoryBackendOperationResult::ToolOutput(
|
||||||
execute_query(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
execute_query(layout, operation),
|
||||||
}
|
)),
|
||||||
MemoryBackendOperation::ReadDocument(_operation) => Err(io::Error::new(
|
MemoryBackendOperation::ReadDocument(_operation) => Err(io::Error::new(
|
||||||
io::ErrorKind::Unsupported,
|
io::ErrorKind::Unsupported,
|
||||||
"Memory document operations require WorkspaceAuthority-backed executor",
|
"Memory document operations require WorkspaceAuthority-backed executor",
|
||||||
|
|
@ -255,18 +209,6 @@ pub fn execute_memory_backend_operation(
|
||||||
io::ErrorKind::Unsupported,
|
io::ErrorKind::Unsupported,
|
||||||
"Memory document operations require WorkspaceAuthority-backed executor",
|
"Memory document operations require WorkspaceAuthority-backed executor",
|
||||||
)),
|
)),
|
||||||
MemoryBackendOperation::Read(operation) => {
|
|
||||||
execute_read(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
|
||||||
}
|
|
||||||
MemoryBackendOperation::Write(operation) => {
|
|
||||||
execute_write(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
|
||||||
}
|
|
||||||
MemoryBackendOperation::Edit(operation) => {
|
|
||||||
execute_edit(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
|
||||||
}
|
|
||||||
MemoryBackendOperation::Delete(operation) => {
|
|
||||||
execute_delete(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
|
|
||||||
}
|
|
||||||
MemoryBackendOperation::ResidentSummary(_operation) => Ok(
|
MemoryBackendOperation::ResidentSummary(_operation) => Ok(
|
||||||
MemoryBackendOperationResult::ToolOutput(execute_resident_summary(layout)),
|
MemoryBackendOperationResult::ToolOutput(execute_resident_summary(layout)),
|
||||||
),
|
),
|
||||||
|
|
@ -328,185 +270,30 @@ fn execute_resident_summary(layout: &WorkspaceLayout) -> MemoryToolOutput {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_query(
|
fn execute_query(layout: &WorkspaceLayout, operation: MemoryQueryOperation) -> MemoryToolOutput {
|
||||||
layout: &WorkspaceLayout,
|
let Some(document) = crate::collect_resident_summary(layout) else {
|
||||||
operation: MemoryQueryOperation,
|
|
||||||
) -> io::Result<MemoryToolOutput> {
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
for kind in [
|
|
||||||
MemoryToolKind::Summary,
|
|
||||||
MemoryToolKind::Decision,
|
|
||||||
MemoryToolKind::Request,
|
|
||||||
] {
|
|
||||||
entries.extend(query_kind(layout, kind, operation.query.as_deref())?);
|
|
||||||
}
|
|
||||||
|
|
||||||
if entries.is_empty() {
|
|
||||||
let suffix = operation
|
let suffix = operation
|
||||||
.query
|
.query
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|query| format!(" matching {query:?}"))
|
.map(|query| format!(" matching {query:?}"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
return Ok(MemoryToolOutput {
|
return MemoryToolOutput {
|
||||||
summary: format!("No memory records found{suffix}"),
|
summary: format!("No memory document content found{suffix}"),
|
||||||
content: None,
|
content: None,
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
|
||||||
let body = entries.join("\n\n");
|
|
||||||
Ok(tool_output_from_string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query_kind(
|
|
||||||
layout: &WorkspaceLayout,
|
|
||||||
kind: MemoryToolKind,
|
|
||||||
query: Option<&str>,
|
|
||||||
) -> io::Result<Vec<String>> {
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
match kind {
|
|
||||||
MemoryToolKind::Summary => {
|
|
||||||
let path = memory_path(layout, kind, None)?;
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(entries);
|
|
||||||
}
|
|
||||||
let content = fs::read_to_string(&path)?;
|
|
||||||
if matches_query(&content, query) {
|
|
||||||
entries.push(format!("kind=summary\n{}", excerpt(&content, query)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MemoryToolKind::Decision | MemoryToolKind::Request => {
|
|
||||||
let dir = record_dir(layout, kind);
|
|
||||||
if !dir.exists() {
|
|
||||||
return Ok(entries);
|
|
||||||
}
|
|
||||||
let mut files: Vec<PathBuf> = fs::read_dir(&dir)?
|
|
||||||
.filter_map(Result::ok)
|
|
||||||
.map(|entry| entry.path())
|
|
||||||
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
|
|
||||||
.collect();
|
|
||||||
files.sort();
|
|
||||||
for path in files {
|
|
||||||
let content = fs::read_to_string(&path)?;
|
|
||||||
if matches_query(&content, query) {
|
|
||||||
let slug = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|value| value.to_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
entries.push(format!(
|
|
||||||
"kind={} slug={}\n{}",
|
|
||||||
kind.as_str(),
|
|
||||||
slug,
|
|
||||||
excerpt(&content, query)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn execute_read(
|
|
||||||
layout: &WorkspaceLayout,
|
|
||||||
operation: MemoryReadOperation,
|
|
||||||
) -> io::Result<MemoryToolOutput> {
|
|
||||||
let path = memory_path(layout, operation.kind, operation.slug.as_deref())?;
|
|
||||||
let content = fs::read_to_string(&path)?;
|
|
||||||
let offset = operation.offset.unwrap_or(0);
|
|
||||||
let limit = operation.limit.unwrap_or(2000);
|
|
||||||
let lines: Vec<&str> = content.lines().collect();
|
|
||||||
let selected = lines
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.skip(offset)
|
|
||||||
.take(limit)
|
|
||||||
.map(|(index, line)| format!("{:>6}\t{}", index + 1, line))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
let total = lines.len();
|
|
||||||
Ok(MemoryToolOutput {
|
|
||||||
summary: format!(
|
|
||||||
"Read {} lines from {}{}",
|
|
||||||
selected.lines().count(),
|
|
||||||
operation.kind.as_str(),
|
|
||||||
operation
|
|
||||||
.slug
|
|
||||||
.as_deref()
|
|
||||||
.map(|slug| format!("/{slug}"))
|
|
||||||
.unwrap_or_default()
|
|
||||||
),
|
|
||||||
content: Some(format!(
|
|
||||||
"total_lines={total} offset={offset} limit={limit}\n{selected}"
|
|
||||||
)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn execute_write(
|
|
||||||
layout: &WorkspaceLayout,
|
|
||||||
operation: MemoryWriteOperation,
|
|
||||||
) -> io::Result<MemoryToolOutput> {
|
|
||||||
let path = memory_path(layout, operation.kind, operation.slug.as_deref())?;
|
|
||||||
validate_slug_rules(operation.kind, operation.slug.as_deref())?;
|
|
||||||
validate_memory_content(
|
|
||||||
operation.kind,
|
|
||||||
operation.slug.as_deref(),
|
|
||||||
&operation.content,
|
|
||||||
)?;
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
fs::write(&path, operation.content)?;
|
|
||||||
Ok(MemoryToolOutput {
|
|
||||||
summary: format!("Wrote {} record", operation.kind.as_str()),
|
|
||||||
content: Some(path.display().to_string()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn execute_edit(
|
|
||||||
layout: &WorkspaceLayout,
|
|
||||||
operation: MemoryEditOperation,
|
|
||||||
) -> io::Result<MemoryToolOutput> {
|
|
||||||
validate_slug_rules(operation.kind, operation.slug.as_deref())?;
|
|
||||||
if operation.old_string == operation.new_string {
|
|
||||||
return Err(invalid_input("old_string and new_string must differ"));
|
|
||||||
}
|
|
||||||
let path = memory_path(layout, operation.kind, operation.slug.as_deref())?;
|
|
||||||
let original = fs::read_to_string(&path)?;
|
|
||||||
let count = original.matches(&operation.old_string).count();
|
|
||||||
if count == 0 {
|
|
||||||
return Err(invalid_input("old_string was not found"));
|
|
||||||
}
|
|
||||||
if count > 1 && !operation.replace_all {
|
|
||||||
return Err(invalid_input(
|
|
||||||
"old_string matched more than once; set replace_all=true to replace every occurrence",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let updated = if operation.replace_all {
|
|
||||||
original.replace(&operation.old_string, &operation.new_string)
|
|
||||||
} else {
|
|
||||||
original.replacen(&operation.old_string, &operation.new_string, 1)
|
|
||||||
};
|
};
|
||||||
validate_memory_content(operation.kind, operation.slug.as_deref(), &updated)?;
|
if !matches_query(&document, operation.query.as_deref()) {
|
||||||
fs::write(&path, updated)?;
|
let suffix = operation
|
||||||
Ok(MemoryToolOutput {
|
.query
|
||||||
summary: format!(
|
.as_deref()
|
||||||
"Edited {} occurrence(s)",
|
.map(|query| format!(" matching {query:?}"))
|
||||||
if operation.replace_all { count } else { 1 }
|
.unwrap_or_default();
|
||||||
),
|
return MemoryToolOutput {
|
||||||
content: Some(path.display().to_string()),
|
summary: format!("No memory document content found{suffix}"),
|
||||||
})
|
content: None,
|
||||||
}
|
};
|
||||||
|
}
|
||||||
fn execute_delete(
|
tool_output_from_string(excerpt(&document, operation.query.as_deref()))
|
||||||
layout: &WorkspaceLayout,
|
|
||||||
operation: MemoryDeleteOperation,
|
|
||||||
) -> io::Result<MemoryToolOutput> {
|
|
||||||
validate_slug_rules(operation.kind, operation.slug.as_deref())?;
|
|
||||||
let path = memory_path(layout, operation.kind, operation.slug.as_deref())?;
|
|
||||||
fs::remove_file(&path)?;
|
|
||||||
Ok(MemoryToolOutput {
|
|
||||||
summary: format!("Deleted {} record", operation.kind.as_str()),
|
|
||||||
content: Some(path.display().to_string()),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_staging_list(
|
fn execute_staging_list(
|
||||||
|
|
@ -563,7 +350,6 @@ fn execute_staging_close(
|
||||||
if operation.reason.trim().is_empty() {
|
if operation.reason.trim().is_empty() {
|
||||||
return Err(invalid_input("reason is required"));
|
return Err(invalid_input("reason is required"));
|
||||||
}
|
}
|
||||||
validate_affected_memory(&operation.affected_memory)?;
|
|
||||||
let entry = find_staging_entry(layout, &operation.candidate_id)?;
|
let entry = find_staging_entry(layout, &operation.candidate_id)?;
|
||||||
let disposition = MemoryStagingCloseDispositionRecord {
|
let disposition = MemoryStagingCloseDispositionRecord {
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
|
|
@ -605,107 +391,6 @@ fn find_staging_entry(
|
||||||
.ok_or_else(|| invalid_input(format!("staging candidate not found: {candidate_id}")))
|
.ok_or_else(|| invalid_input(format!("staging candidate not found: {candidate_id}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_affected_memory(records: &[MemoryStagingAffectedMemory]) -> io::Result<()> {
|
|
||||||
for record in records {
|
|
||||||
validate_slug_rules(record.kind, record.slug.as_deref())?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn memory_path(
|
|
||||||
layout: &WorkspaceLayout,
|
|
||||||
kind: MemoryToolKind,
|
|
||||||
slug: Option<&str>,
|
|
||||||
) -> io::Result<PathBuf> {
|
|
||||||
match kind {
|
|
||||||
MemoryToolKind::Summary => {
|
|
||||||
if slug.is_some() {
|
|
||||||
return Err(invalid_input("summary records do not accept slug"));
|
|
||||||
}
|
|
||||||
Ok(layout.memory_dir().join("summary.md"))
|
|
||||||
}
|
|
||||||
MemoryToolKind::Decision => {
|
|
||||||
Ok(record_dir(layout, kind).join(format!("{}.md", required_slug(slug)?)))
|
|
||||||
}
|
|
||||||
MemoryToolKind::Request => {
|
|
||||||
Ok(record_dir(layout, kind).join(format!("{}.md", required_slug(slug)?)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_dir(layout: &WorkspaceLayout, kind: MemoryToolKind) -> PathBuf {
|
|
||||||
match kind {
|
|
||||||
MemoryToolKind::Summary => layout.memory_dir(),
|
|
||||||
MemoryToolKind::Decision => layout.memory_dir().join("decisions"),
|
|
||||||
MemoryToolKind::Request => layout.memory_dir().join("requests"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn required_slug(slug: Option<&str>) -> io::Result<&str> {
|
|
||||||
slug.filter(|value| !value.trim().is_empty())
|
|
||||||
.ok_or_else(|| invalid_input("slug is required for this memory kind"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_slug_rules(kind: MemoryToolKind, slug: Option<&str>) -> io::Result<()> {
|
|
||||||
match kind {
|
|
||||||
MemoryToolKind::Summary => {
|
|
||||||
if slug.is_some() {
|
|
||||||
return Err(invalid_input("summary records do not accept slug"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MemoryToolKind::Decision | MemoryToolKind::Request => {
|
|
||||||
validate_slug(required_slug(slug)?)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_slug(slug: &str) -> io::Result<()> {
|
|
||||||
if slug.is_empty()
|
|
||||||
|| slug.contains('/')
|
|
||||||
|| slug.contains('\\')
|
|
||||||
|| slug == "."
|
|
||||||
|| slug == ".."
|
|
||||||
|| slug.starts_with('.')
|
|
||||||
|| !slug
|
|
||||||
.chars()
|
|
||||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
|
||||||
{
|
|
||||||
return Err(invalid_input(
|
|
||||||
"slug must use lowercase ASCII letters, digits, and '-' only",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_memory_content(
|
|
||||||
kind: MemoryToolKind,
|
|
||||||
slug: Option<&str>,
|
|
||||||
content: &str,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
if content.trim().is_empty() {
|
|
||||||
return Err(invalid_input("content must not be empty"));
|
|
||||||
}
|
|
||||||
match kind {
|
|
||||||
MemoryToolKind::Summary => {
|
|
||||||
if !content.trim_start().starts_with("# Memory summary") {
|
|
||||||
return Err(invalid_input(
|
|
||||||
"summary content must start with '# Memory summary'",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MemoryToolKind::Decision | MemoryToolKind::Request => {
|
|
||||||
validate_slug_rules(kind, slug)?;
|
|
||||||
if !content.trim_start().starts_with("---") {
|
|
||||||
return Err(invalid_input(
|
|
||||||
"memory record content must start with YAML frontmatter",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn matches_query(content: &str, query: Option<&str>) -> bool {
|
fn matches_query(content: &str, query: Option<&str>) -> bool {
|
||||||
match query.map(str::trim).filter(|query| !query.is_empty()) {
|
match query.map(str::trim).filter(|query| !query.is_empty()) {
|
||||||
Some(query) => content.to_lowercase().contains(&query.to_lowercase()),
|
Some(query) => content.to_lowercase().contains(&query.to_lowercase()),
|
||||||
|
|
@ -822,10 +507,8 @@ mod tests {
|
||||||
MemoryBackendOperation::StagingClose(MemoryStagingCloseOperation {
|
MemoryBackendOperation::StagingClose(MemoryStagingCloseOperation {
|
||||||
candidate_id: candidate_id.clone(),
|
candidate_id: candidate_id.clone(),
|
||||||
action: MemoryStagingCloseAction::Applied,
|
action: MemoryStagingCloseAction::Applied,
|
||||||
reason: "Merged into durable request memory.".into(),
|
reason: "Merged into the Memory document.".into(),
|
||||||
affected_memory: vec![MemoryStagingAffectedMemory {
|
affected_memory: vec![MemoryStagingAffectedMemory {
|
||||||
kind: MemoryToolKind::Request,
|
|
||||||
slug: Some("review-preferences".into()),
|
|
||||||
operation: MemoryStagingAffectedMemoryOperation::Edit,
|
operation: MemoryStagingAffectedMemoryOperation::Edit,
|
||||||
}],
|
}],
|
||||||
}),
|
}),
|
||||||
|
|
@ -837,6 +520,6 @@ mod tests {
|
||||||
let resolutions =
|
let resolutions =
|
||||||
fs::read_to_string(layout.memory_dir().join(STAGING_RESOLUTIONS_FILE)).unwrap();
|
fs::read_to_string(layout.memory_dir().join(STAGING_RESOLUTIONS_FILE)).unwrap();
|
||||||
assert!(resolutions.contains(&candidate_id));
|
assert!(resolutions.contains(&candidate_id));
|
||||||
assert!(resolutions.contains("Merged into durable request memory."));
|
assert!(resolutions.contains("Merged into the Memory document."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
//! Memory subsystem: persistence layer for `memory/*` records.
|
//! Memory subsystem support shared by Worker and Workspace authority paths.
|
||||||
//!
|
//!
|
||||||
//! Self-contained: provides its own Tool implementations (read/write/edit)
|
//! Normal runtime Memory access is mediated through Workspace-backed backend
|
||||||
//! that target `<workspace>/memory/` only, with a pre-write Linter built in.
|
//! operations. Generic filesystem tools must not touch local memory paths; Worker
|
||||||
//! Generic CRUD tools (in the `tools` crate) must not touch this directory —
|
//! is responsible for denying them at the Scope level when memory is enabled.
|
||||||
//! Worker is responsible for denying it at the Scope level when memory is enabled.
|
|
||||||
|
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
|
|
@ -14,7 +13,6 @@ pub mod linter;
|
||||||
pub mod resident;
|
pub mod resident;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod scope;
|
pub mod scope;
|
||||||
pub mod tool;
|
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
//! `MemoryDelete` tool for removing memory records with audit logging.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use llm_engine::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 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,
|
|
||||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
|
||||||
) -> 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(¶ms.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"}"#,
|
|
||||||
Default::default(),
|
|
||||||
)
|
|
||||||
.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""#));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,391 +0,0 @@
|
||||||
//! `MemoryEdit` tool — partial string replacement on an existing memory record.
|
|
||||||
//!
|
|
||||||
//! Reads current content by `(kind, slug)`, 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::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::audit::{
|
|
||||||
AuditStatus, RecordOperationAudit, append_record_operation, file_hash, hash_bytes,
|
|
||||||
};
|
|
||||||
use crate::linter::{LintReport, Linter, WriteMode};
|
|
||||||
use crate::tool::MemoryToolKind;
|
|
||||||
use crate::workspace::WorkspaceLayout;
|
|
||||||
|
|
||||||
const DESCRIPTION: &str = "Replace a substring in an existing memory \
|
|
||||||
record selected by `kind` + `slug`. 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.";
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
|
||||||
struct EditParams {
|
|
||||||
/// Record kind: `summary` | `decision` | `request`.
|
|
||||||
kind: MemoryToolKind,
|
|
||||||
/// Slug. Required for everything except `summary`; forbidden for `summary`.
|
|
||||||
#[serde(default)]
|
|
||||||
slug: Option<String>,
|
|
||||||
/// 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 {
|
|
||||||
layout: WorkspaceLayout,
|
|
||||||
linter: Linter,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for EditTool {
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
input_json: &str,
|
|
||||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let params: EditParams = serde_json::from_str(input_json)
|
|
||||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid MemoryEdit input: {e}")))?;
|
|
||||||
|
|
||||||
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(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = params
|
|
||||||
.kind
|
|
||||||
.resolve_path(&self.layout, params.slug.as_deref())?;
|
|
||||||
let kind = params.kind.to_string();
|
|
||||||
let slug = audit_slug(¶ms.kind, params.slug.as_deref());
|
|
||||||
|
|
||||||
let current_bytes = match std::fs::read(&path) {
|
|
||||||
Ok(bytes) => bytes,
|
|
||||||
Err(e) => {
|
|
||||||
let reason = match e.kind() {
|
|
||||||
std::io::ErrorKind::NotFound => format!(
|
|
||||||
"record not found (use MemoryWrite to create): {}",
|
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
_ => format!("read failed at {}: {e}", path.display()),
|
|
||||||
};
|
|
||||||
let _ = append_record_operation(
|
|
||||||
&self.layout,
|
|
||||||
RecordOperationAudit {
|
|
||||||
op: "edit".to_string(),
|
|
||||||
status: AuditStatus::Failed,
|
|
||||||
kind,
|
|
||||||
slug,
|
|
||||||
path: path.display().to_string(),
|
|
||||||
before_hash: None,
|
|
||||||
after_hash: None,
|
|
||||||
reason: Some(reason.clone()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return Err(ToolError::ExecutionFailed(reason));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let before_hash = Some(hash_bytes(¤t_bytes));
|
|
||||||
let current_text = match std::str::from_utf8(¤t_bytes) {
|
|
||||||
Ok(text) => text,
|
|
||||||
Err(_) => {
|
|
||||||
let reason = format!("file is not valid UTF-8: {}", path.display());
|
|
||||||
let _ = append_record_operation(
|
|
||||||
&self.layout,
|
|
||||||
RecordOperationAudit {
|
|
||||||
op: "edit".to_string(),
|
|
||||||
status: AuditStatus::Failed,
|
|
||||||
kind,
|
|
||||||
slug,
|
|
||||||
path: path.display().to_string(),
|
|
||||||
before_hash,
|
|
||||||
after_hash: None,
|
|
||||||
reason: Some(reason.clone()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return Err(ToolError::InvalidArgument(reason));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let count = current_text.matches(¶ms.old_string).count();
|
|
||||||
if count == 0 {
|
|
||||||
let reason = format!("old_string not found in {}", path.display());
|
|
||||||
let _ = append_record_operation(
|
|
||||||
&self.layout,
|
|
||||||
RecordOperationAudit {
|
|
||||||
op: "edit".to_string(),
|
|
||||||
status: AuditStatus::Failed,
|
|
||||||
kind,
|
|
||||||
slug,
|
|
||||||
path: path.display().to_string(),
|
|
||||||
before_hash,
|
|
||||||
after_hash: None,
|
|
||||||
reason: Some(reason.clone()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return Err(ToolError::InvalidArgument(reason));
|
|
||||||
}
|
|
||||||
if !params.replace_all && count > 1 {
|
|
||||||
let reason = format!(
|
|
||||||
"old_string occurs {count} times in {}; pass replace_all: true or narrow the snippet",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
let _ = append_record_operation(
|
|
||||||
&self.layout,
|
|
||||||
RecordOperationAudit {
|
|
||||||
op: "edit".to_string(),
|
|
||||||
status: AuditStatus::Failed,
|
|
||||||
kind,
|
|
||||||
slug,
|
|
||||||
path: path.display().to_string(),
|
|
||||||
before_hash,
|
|
||||||
after_hash: None,
|
|
||||||
reason: Some(reason.clone()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return Err(ToolError::InvalidArgument(reason));
|
|
||||||
}
|
|
||||||
|
|
||||||
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(&path, &new_text, WriteMode::Update);
|
|
||||||
if report.has_errors() {
|
|
||||||
let reason = format_report(&report);
|
|
||||||
let _ = append_record_operation(
|
|
||||||
&self.layout,
|
|
||||||
RecordOperationAudit {
|
|
||||||
op: "edit".to_string(),
|
|
||||||
status: AuditStatus::Failed,
|
|
||||||
kind,
|
|
||||||
slug,
|
|
||||||
path: path.display().to_string(),
|
|
||||||
before_hash,
|
|
||||||
after_hash: None,
|
|
||||||
reason: Some(reason.clone()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return Err(ToolError::InvalidArgument(reason));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = std::fs::write(&path, new_text.as_bytes()) {
|
|
||||||
let reason = format!("failed to write {}: {e}", path.display());
|
|
||||||
let _ = append_record_operation(
|
|
||||||
&self.layout,
|
|
||||||
RecordOperationAudit {
|
|
||||||
op: "edit".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 after_hash = file_hash(&path).ok().flatten();
|
|
||||||
let _ = append_record_operation(
|
|
||||||
&self.layout,
|
|
||||||
RecordOperationAudit {
|
|
||||||
op: "edit".to_string(),
|
|
||||||
status: AuditStatus::Success,
|
|
||||||
kind,
|
|
||||||
slug,
|
|
||||||
path: path.display().to_string(),
|
|
||||||
before_hash,
|
|
||||||
after_hash,
|
|
||||||
reason: if report.warnings.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(format!("{} warning(s)", report.warnings.len()))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let summary = format!(
|
|
||||||
"Edited {} ({} replacement{}){}",
|
|
||||||
path.display(),
|
|
||||||
occurrences,
|
|
||||||
if occurrences == 1 { "" } else { "s" },
|
|
||||||
warning_tail(&report),
|
|
||||||
);
|
|
||||||
Ok(ToolOutput {
|
|
||||||
summary,
|
|
||||||
content: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn audit_slug(kind: &MemoryToolKind, slug: Option<&str>) -> String {
|
|
||||||
match kind {
|
|
||||||
MemoryToolKind::Summary => "summary".to_string(),
|
|
||||||
_ => slug.unwrap_or("<missing>").to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
layout: layout.clone(),
|
|
||||||
linter: Linter::new(layout.clone()),
|
|
||||||
});
|
|
||||||
(meta, tool)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use chrono::Utc;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
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(".yoi/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!({
|
|
||||||
"kind": "decision",
|
|
||||||
"slug": "foo",
|
|
||||||
"old_string": "body body",
|
|
||||||
"new_string": "edited",
|
|
||||||
});
|
|
||||||
let out = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.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!({
|
|
||||||
"kind": "decision",
|
|
||||||
"slug": "foo",
|
|
||||||
"old_string": "status: open\n",
|
|
||||||
"new_string": "",
|
|
||||||
});
|
|
||||||
let err = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.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_record() {
|
|
||||||
let (_dir, layout, _) = setup();
|
|
||||||
let (_, tool) = edit_tool(layout)();
|
|
||||||
let inp = serde_json::json!({
|
|
||||||
"kind": "decision",
|
|
||||||
"slug": "ghost",
|
|
||||||
"old_string": "x",
|
|
||||||
"new_string": "y",
|
|
||||||
});
|
|
||||||
let err = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, ToolError::ExecutionFailed(_)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn edit_workflow_kind_rejected() {
|
|
||||||
// Workflow is not exposed via MemoryToolKind, so deserialization fails.
|
|
||||||
let (_dir, layout, _) = setup();
|
|
||||||
let (_, tool) = edit_tool(layout)();
|
|
||||||
let inp = serde_json::json!({
|
|
||||||
"kind": "workflow",
|
|
||||||
"slug": "wf",
|
|
||||||
"old_string": "x",
|
|
||||||
"new_string": "y",
|
|
||||||
});
|
|
||||||
let err = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
//! Memory-scoped tools: Read / Write / Edit / Search.
|
|
||||||
//!
|
|
||||||
//! All four take `kind` + `slug` (Summary takes only `kind`) and
|
|
||||||
//! resolve the path through [`WorkspaceLayout`]. The agent never has
|
|
||||||
//! to know the on-disk layout — Search returns `{slug, kind, ...}` and
|
|
||||||
//! that pair feeds straight into Read / Edit.
|
|
||||||
|
|
||||||
mod delete;
|
|
||||||
mod edit;
|
|
||||||
mod query;
|
|
||||||
mod read;
|
|
||||||
mod write;
|
|
||||||
|
|
||||||
pub use delete::delete_tool;
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use llm_engine::tool::ToolError;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::Slug;
|
|
||||||
use crate::workspace::{RecordKind, WorkspaceLayout};
|
|
||||||
|
|
||||||
pub use edit::edit_tool;
|
|
||||||
pub use query::{QueryConfig, memory_query_tool};
|
|
||||||
pub use read::{read_tool, read_tool_with_usage};
|
|
||||||
pub use write::write_tool;
|
|
||||||
|
|
||||||
/// Kinds the memory tools accept as input. `Workflow` is intentionally
|
|
||||||
/// excluded — workflows are sub-Engine context, not agent-editable.
|
|
||||||
#[derive(
|
|
||||||
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize, schemars::JsonSchema,
|
|
||||||
)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum MemoryToolKind {
|
|
||||||
Summary,
|
|
||||||
Decision,
|
|
||||||
Request,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for MemoryToolKind {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_str(match self {
|
|
||||||
Self::Summary => "summary",
|
|
||||||
Self::Decision => "decision",
|
|
||||||
Self::Request => "request",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MemoryToolKind {
|
|
||||||
pub fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Summary => "summary",
|
|
||||||
Self::Decision => "decision",
|
|
||||||
Self::Request => "request",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_kind(self) -> RecordKind {
|
|
||||||
match self {
|
|
||||||
Self::Summary => RecordKind::Summary,
|
|
||||||
Self::Decision => RecordKind::Decision,
|
|
||||||
Self::Request => RecordKind::Request,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve `(kind, slug)` to an absolute path under the workspace.
|
|
||||||
/// Summary forbids a slug; the per-record kinds require one.
|
|
||||||
pub fn resolve_path(
|
|
||||||
self,
|
|
||||||
layout: &WorkspaceLayout,
|
|
||||||
slug: Option<&str>,
|
|
||||||
) -> Result<PathBuf, ToolError> {
|
|
||||||
match self {
|
|
||||||
Self::Summary => {
|
|
||||||
if slug.is_some() {
|
|
||||||
return Err(ToolError::InvalidArgument(
|
|
||||||
"kind=summary does not accept a slug".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(layout.summary_path())
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
let raw = slug.ok_or_else(|| {
|
|
||||||
ToolError::InvalidArgument(format!("kind={} requires `slug`", other.as_str()))
|
|
||||||
})?;
|
|
||||||
let parsed =
|
|
||||||
Slug::parse(raw).map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
|
|
||||||
Ok(match other {
|
|
||||||
Self::Decision => layout.decision_path(&parsed),
|
|
||||||
Self::Request => layout.request_path(&parsed),
|
|
||||||
Self::Summary => unreachable!(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,517 +0,0 @@
|
||||||
//! `MemoryQuery` tool.
|
|
||||||
//!
|
|
||||||
//! Performs a case-insensitive substring scan over markdown record
|
|
||||||
//! files. With a `query` set, returns `{slug, kind, ..., excerpt}` hits
|
|
||||||
//! with `excerpt_lines` lines of context around each match. With `query`
|
|
||||||
//! omitted, returns one entry per file (no excerpt) so the agent can
|
|
||||||
//! enumerate what records exist without knowing what's inside them.
|
|
||||||
//!
|
|
||||||
//! - `MemoryQuery` walks `.yoi/memory/{summary.md,decisions/,
|
|
||||||
//! requests/}`. `.yoi/memory/_staging/`,
|
|
||||||
//! `.yoi/memory/_usage/`, and `.yoi/memory/_logs/` are excluded
|
|
||||||
//! by construction.
|
|
||||||
//!
|
|
||||||
//! No derived index — the file tree is the source of truth and is
|
|
||||||
//! re-scanned per call. 出現順: within a file by line order, across
|
|
||||||
//! files by sorted filename.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::audit::{AuditStatus, RecordUsageAudit, append_record_usage};
|
|
||||||
use crate::workspace::WorkspaceLayout;
|
|
||||||
|
|
||||||
const DEFAULT_RESULT_LIMIT: usize = 20;
|
|
||||||
const DEFAULT_EXCERPT_LINES: usize = 3;
|
|
||||||
|
|
||||||
const MEMORY_QUERY_DESCRIPTION: &str = "Inspect memory records (summary / decisions / \
|
|
||||||
requests). With `query` set, returns substring hits as `{slug, kind, excerpt}` entries \
|
|
||||||
with line context. Omit `query` to list every record (one entry per file, no excerpt) \
|
|
||||||
when you don't yet know what's in there. Result count is capped (configurable via the \
|
|
||||||
manifest's `[memory]` section). Use the returned `slug` + `kind` with MemoryRead to fetch \
|
|
||||||
the full record. Workflow and staging directories are not visible.";
|
|
||||||
|
|
||||||
/// Tunables passed in from the manifest.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct QueryConfig {
|
|
||||||
pub result_limit: usize,
|
|
||||||
/// Lines of context before and after each matched line. Ignored
|
|
||||||
/// when the request omits `query`.
|
|
||||||
pub excerpt_lines: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for QueryConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
result_limit: DEFAULT_RESULT_LIMIT,
|
|
||||||
excerpt_lines: DEFAULT_EXCERPT_LINES,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&manifest::MemoryConfig> for QueryConfig {
|
|
||||||
fn from(cfg: &manifest::MemoryConfig) -> Self {
|
|
||||||
let mut out = Self::default();
|
|
||||||
if let Some(n) = cfg.query_result_limit {
|
|
||||||
out.result_limit = n;
|
|
||||||
}
|
|
||||||
if let Some(n) = cfg.query_excerpt_lines {
|
|
||||||
out.excerpt_lines = n;
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
|
||||||
struct MemoryQueryParams {
|
|
||||||
/// Optional substring filter. Case-insensitive. Omit to list every
|
|
||||||
/// record under the query scope.
|
|
||||||
#[serde(default)]
|
|
||||||
query: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct MemoryRecord {
|
|
||||||
slug: String,
|
|
||||||
kind: &'static str,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
excerpt: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MemoryQueryTool {
|
|
||||||
layout: WorkspaceLayout,
|
|
||||||
config: QueryConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for MemoryQueryTool {
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
input_json: &str,
|
|
||||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let params: MemoryQueryParams = serde_json::from_str(input_json)
|
|
||||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid MemoryQuery input: {e}")))?;
|
|
||||||
let needle = match params.query.as_deref() {
|
|
||||||
Some(q) => match validate_query(q) {
|
|
||||||
Ok(q) => Some(q),
|
|
||||||
Err(err) => {
|
|
||||||
let _ = append_record_usage(
|
|
||||||
&self.layout,
|
|
||||||
RecordUsageAudit {
|
|
||||||
op: "query".to_string(),
|
|
||||||
status: AuditStatus::Failed,
|
|
||||||
kind: "memory".to_string(),
|
|
||||||
slug: None,
|
|
||||||
path: None,
|
|
||||||
query: params.query.clone(),
|
|
||||||
result_count: None,
|
|
||||||
reason: Some(err.to_string()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut records: Vec<MemoryRecord> = Vec::new();
|
|
||||||
let limit = self.config.result_limit;
|
|
||||||
let ctx = self.config.excerpt_lines;
|
|
||||||
|
|
||||||
// summary
|
|
||||||
if records.len() < limit {
|
|
||||||
let summary_path = self.layout.summary_path();
|
|
||||||
if summary_path.is_file() {
|
|
||||||
collect_memory_records(
|
|
||||||
&summary_path,
|
|
||||||
"summary",
|
|
||||||
"summary",
|
|
||||||
needle.as_deref(),
|
|
||||||
ctx,
|
|
||||||
limit - records.len(),
|
|
||||||
&mut records,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// decisions
|
|
||||||
if records.len() < limit {
|
|
||||||
for (path, slug) in list_md_files(&self.layout.decisions_dir()) {
|
|
||||||
if records.len() >= limit {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
collect_memory_records(
|
|
||||||
&path,
|
|
||||||
&slug,
|
|
||||||
"decision",
|
|
||||||
needle.as_deref(),
|
|
||||||
ctx,
|
|
||||||
limit - records.len(),
|
|
||||||
&mut records,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// requests
|
|
||||||
if records.len() < limit {
|
|
||||||
for (path, slug) in list_md_files(&self.layout.requests_dir()) {
|
|
||||||
if records.len() >= limit {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
collect_memory_records(
|
|
||||||
&path,
|
|
||||||
&slug,
|
|
||||||
"request",
|
|
||||||
needle.as_deref(),
|
|
||||||
ctx,
|
|
||||||
limit - records.len(),
|
|
||||||
&mut records,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = serde_json::to_string_pretty(&records)
|
|
||||||
.map_err(|e| ToolError::ExecutionFailed(format!("serialize records: {e}")))?;
|
|
||||||
let summary = match params.query.as_deref() {
|
|
||||||
Some(q) => format!("{} hit(s) for {q:?}", records.len()),
|
|
||||||
None => format!("{} record(s)", records.len()),
|
|
||||||
};
|
|
||||||
let _ = append_record_usage(
|
|
||||||
&self.layout,
|
|
||||||
RecordUsageAudit {
|
|
||||||
op: "query".to_string(),
|
|
||||||
status: AuditStatus::Success,
|
|
||||||
kind: "memory".to_string(),
|
|
||||||
slug: None,
|
|
||||||
path: None,
|
|
||||||
query: params.query.clone(),
|
|
||||||
result_count: Some(records.len()),
|
|
||||||
reason: if records.len() >= limit {
|
|
||||||
Some("result_limit_reached".to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Ok(ToolOutput {
|
|
||||||
summary,
|
|
||||||
content: Some(body),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_memory_records(
|
|
||||||
path: &Path,
|
|
||||||
slug: &str,
|
|
||||||
kind: &'static str,
|
|
||||||
needle_lower: Option<&str>,
|
|
||||||
ctx: usize,
|
|
||||||
remaining: usize,
|
|
||||||
out: &mut Vec<MemoryRecord>,
|
|
||||||
) {
|
|
||||||
if remaining == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match needle_lower {
|
|
||||||
Some(n) => {
|
|
||||||
scan_file(path, n, ctx, remaining, |excerpt| {
|
|
||||||
out.push(MemoryRecord {
|
|
||||||
slug: slug.to_string(),
|
|
||||||
kind,
|
|
||||||
excerpt: Some(excerpt),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
out.push(MemoryRecord {
|
|
||||||
slug: slug.to_string(),
|
|
||||||
kind,
|
|
||||||
excerpt: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_query(query: &str) -> Result<String, ToolError> {
|
|
||||||
if query.trim().is_empty() {
|
|
||||||
return Err(ToolError::InvalidArgument(
|
|
||||||
"query must not be empty when provided; omit it to list all records".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(query.to_lowercase())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sorted list of `(path, slug)` for `*.md` files directly under `dir`.
|
|
||||||
/// Returns empty if the directory doesn't exist.
|
|
||||||
fn list_md_files(dir: &Path) -> Vec<(PathBuf, String)> {
|
|
||||||
let mut out: Vec<(PathBuf, String)> = Vec::new();
|
|
||||||
let entries = match std::fs::read_dir(dir) {
|
|
||||||
Ok(it) => it,
|
|
||||||
Err(_) => return out,
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if !path.is_file() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
|
||||||
Some(n) => n,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let slug = match name.strip_suffix(".md") {
|
|
||||||
Some(s) => s.to_string(),
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
out.push((path, slug));
|
|
||||||
}
|
|
||||||
out.sort_by(|a, b| a.1.cmp(&b.1));
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scan_file(
|
|
||||||
path: &Path,
|
|
||||||
needle_lower: &str,
|
|
||||||
ctx: usize,
|
|
||||||
remaining: usize,
|
|
||||||
mut on_match: impl FnMut(String),
|
|
||||||
) {
|
|
||||||
if remaining == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let text = match std::fs::read_to_string(path) {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
scan_text(&text, needle_lower, ctx, remaining, |e| on_match(e));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scan_text(
|
|
||||||
text: &str,
|
|
||||||
needle_lower: &str,
|
|
||||||
ctx: usize,
|
|
||||||
remaining: usize,
|
|
||||||
mut on_match: impl FnMut(String),
|
|
||||||
) {
|
|
||||||
if remaining == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
|
||||||
let mut produced = 0;
|
|
||||||
for (i, line) in lines.iter().enumerate() {
|
|
||||||
if produced >= remaining {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if line.to_lowercase().contains(needle_lower) {
|
|
||||||
let start = i.saturating_sub(ctx);
|
|
||||||
let end = i.saturating_add(ctx + 1).min(lines.len());
|
|
||||||
let excerpt = lines[start..end].join("\n");
|
|
||||||
on_match(excerpt);
|
|
||||||
produced += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn memory_query_tool(layout: WorkspaceLayout, config: QueryConfig) -> ToolDefinition {
|
|
||||||
Arc::new(move || {
|
|
||||||
let schema = schemars::schema_for!(MemoryQueryParams);
|
|
||||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
|
||||||
let meta = ToolMeta::new("MemoryQuery")
|
|
||||||
.description(MEMORY_QUERY_DESCRIPTION)
|
|
||||||
.input_schema(schema_value);
|
|
||||||
let tool: Arc<dyn Tool> = Arc::new(MemoryQueryTool {
|
|
||||||
layout: layout.clone(),
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
(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());
|
|
||||||
std::fs::create_dir_all(dir.path().join(".yoi/memory/decisions")).unwrap();
|
|
||||||
std::fs::create_dir_all(dir.path().join(".yoi/memory/requests")).unwrap();
|
|
||||||
std::fs::create_dir_all(dir.path().join(".yoi/memory/_staging")).unwrap();
|
|
||||||
(dir, layout)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_decision(dir: &Path, slug: &str, body: &str) {
|
|
||||||
let path = dir.join(".yoi/memory/decisions").join(format!("{slug}.md"));
|
|
||||||
let content = format!(
|
|
||||||
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n{body}",
|
|
||||||
n = now()
|
|
||||||
);
|
|
||||||
std::fs::write(path, content).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct OwnedMemoryRecord {
|
|
||||||
slug: String,
|
|
||||||
kind: String,
|
|
||||||
#[serde(default)]
|
|
||||||
excerpt: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_records(out: &ToolOutput) -> Vec<OwnedMemoryRecord> {
|
|
||||||
let text = out.content.as_ref().unwrap_or(&out.summary);
|
|
||||||
serde_json::from_str(text).unwrap()
|
|
||||||
}
|
|
||||||
#[tokio::test]
|
|
||||||
async fn memory_query_finds_decision_body() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
write_decision(dir.path(), "alpha", "we chose Ollama because it works\n");
|
|
||||||
write_decision(dir.path(), "beta", "no match here\n");
|
|
||||||
let (_, tool) = memory_query_tool(layout, QueryConfig::default())();
|
|
||||||
let inp = serde_json::json!({ "query": "ollama" });
|
|
||||||
let out = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let records: Vec<OwnedMemoryRecord> = parse_records(&out);
|
|
||||||
assert_eq!(records.len(), 1);
|
|
||||||
assert_eq!(records[0].slug, "alpha");
|
|
||||||
assert_eq!(records[0].kind, "decision");
|
|
||||||
assert!(
|
|
||||||
records[0]
|
|
||||||
.excerpt
|
|
||||||
.as_deref()
|
|
||||||
.unwrap()
|
|
||||||
.to_lowercase()
|
|
||||||
.contains("ollama")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn memory_query_without_query_lists_all_records() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
write_decision(dir.path(), "alpha", "body\n");
|
|
||||||
write_decision(dir.path(), "beta", "body\n");
|
|
||||||
let summary_path = dir.path().join(".yoi/memory/summary.md");
|
|
||||||
std::fs::write(
|
|
||||||
&summary_path,
|
|
||||||
format!("---\nupdated_at: {n}\n---\nhello\n", n = now()),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (_, tool) = memory_query_tool(layout, QueryConfig::default())();
|
|
||||||
let out = tool.execute("{}", Default::default()).await.unwrap();
|
|
||||||
let records: Vec<OwnedMemoryRecord> = parse_records(&out);
|
|
||||||
let mut slugs: Vec<&str> = records.iter().map(|r| r.slug.as_str()).collect();
|
|
||||||
slugs.sort();
|
|
||||||
assert_eq!(slugs, vec!["alpha", "beta", "summary"]);
|
|
||||||
// No excerpts when listing.
|
|
||||||
assert!(records.iter().all(|r| r.excerpt.is_none()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn memory_query_finds_summary() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
let summary_path = dir.path().join(".yoi/memory/summary.md");
|
|
||||||
std::fs::write(
|
|
||||||
&summary_path,
|
|
||||||
format!("---\nupdated_at: {n}\n---\nthe needle is here\n", n = now()),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let (_, tool) = memory_query_tool(layout, QueryConfig::default())();
|
|
||||||
let inp = serde_json::json!({ "query": "needle" });
|
|
||||||
let out = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let records: Vec<OwnedMemoryRecord> = parse_records(&out);
|
|
||||||
assert_eq!(records.len(), 1);
|
|
||||||
assert_eq!(records[0].slug, "summary");
|
|
||||||
assert_eq!(records[0].kind, "summary");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn query_hits_do_not_log_usage() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
write_decision(dir.path(), "alpha", "needle line\n");
|
|
||||||
let (_, memory_tool) = memory_query_tool(layout.clone(), QueryConfig::default())();
|
|
||||||
let inp = serde_json::json!({ "query": "needle" });
|
|
||||||
memory_tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let report = crate::usage::build_usage_report(&layout).unwrap();
|
|
||||||
assert!(report.records.is_empty());
|
|
||||||
assert!(!layout.usage_events_path().exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn memory_query_respects_result_limit() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
for i in 0..10 {
|
|
||||||
write_decision(dir.path(), &format!("rec-{i}"), "needle line\n");
|
|
||||||
}
|
|
||||||
let cfg = QueryConfig {
|
|
||||||
result_limit: 3,
|
|
||||||
excerpt_lines: 1,
|
|
||||||
};
|
|
||||||
let (_, tool) = memory_query_tool(layout, cfg)();
|
|
||||||
let inp = serde_json::json!({ "query": "needle" });
|
|
||||||
let out = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let records: Vec<OwnedMemoryRecord> = parse_records(&out);
|
|
||||||
assert_eq!(records.len(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn memory_query_excerpt_includes_context_lines() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
write_decision(
|
|
||||||
dir.path(),
|
|
||||||
"ctx",
|
|
||||||
"line a\nline b\nNEEDLE here\nline d\nline e\n",
|
|
||||||
);
|
|
||||||
let cfg = QueryConfig {
|
|
||||||
result_limit: 5,
|
|
||||||
excerpt_lines: 1,
|
|
||||||
};
|
|
||||||
let (_, tool) = memory_query_tool(layout, cfg)();
|
|
||||||
let inp = serde_json::json!({ "query": "needle" });
|
|
||||||
let out = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let records: Vec<OwnedMemoryRecord> = parse_records(&out);
|
|
||||||
assert_eq!(records.len(), 1);
|
|
||||||
let e = records[0].excerpt.as_deref().unwrap();
|
|
||||||
assert!(e.contains("line b"));
|
|
||||||
assert!(e.contains("NEEDLE here"));
|
|
||||||
assert!(e.contains("line d"));
|
|
||||||
assert!(!e.contains("line a"));
|
|
||||||
assert!(!e.contains("line e"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn memory_query_blank_query_rejected() {
|
|
||||||
let (_dir, layout) = setup();
|
|
||||||
let (_, tool) = memory_query_tool(layout, QueryConfig::default())();
|
|
||||||
let inp = serde_json::json!({ "query": " " });
|
|
||||||
let err = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,327 +0,0 @@
|
||||||
//! `MemoryRead` tool.
|
|
||||||
//!
|
|
||||||
//! Reads a memory record by `(kind, slug)`. Returns
|
|
||||||
//! line-numbered content (1-based), like the generic Read tool. The
|
|
||||||
//! agent never names a path — `MemoryQuery` returns `{kind, slug, ...}`
|
|
||||||
//! and that pair feeds straight into Read.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::audit::{AuditStatus, RecordUsageAudit, append_record_usage};
|
|
||||||
use crate::tool::MemoryToolKind;
|
|
||||||
use crate::usage::{self, UsageSource};
|
|
||||||
use crate::workspace::WorkspaceLayout;
|
|
||||||
|
|
||||||
const DESCRIPTION: &str = "Read a memory record by `kind` + `slug`. \
|
|
||||||
`kind` is one of: summary, decision, request. \
|
|
||||||
For `summary` omit `slug`; for the others `slug` is required. \
|
|
||||||
Returns line-numbered output (1-based).";
|
|
||||||
|
|
||||||
const DEFAULT_LIMIT: usize = 2000;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
|
||||||
struct ReadParams {
|
|
||||||
/// Record kind: `summary` | `decision` | `request`.
|
|
||||||
kind: MemoryToolKind,
|
|
||||||
/// Slug. Required for everything except `summary`; forbidden for `summary`.
|
|
||||||
#[serde(default)]
|
|
||||||
slug: Option<String>,
|
|
||||||
/// 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,
|
|
||||||
usage_session_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for ReadTool {
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
input_json: &str,
|
|
||||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let params: ReadParams = serde_json::from_str(input_json)
|
|
||||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid MemoryRead input: {e}")))?;
|
|
||||||
|
|
||||||
let path = params
|
|
||||||
.kind
|
|
||||||
.resolve_path(&self.layout, params.slug.as_deref())?;
|
|
||||||
let kind = params.kind.to_string();
|
|
||||||
let slug = audit_slug(¶ms.kind, params.slug.as_deref());
|
|
||||||
|
|
||||||
let bytes = match std::fs::read(&path) {
|
|
||||||
Ok(bytes) => bytes,
|
|
||||||
Err(e) => {
|
|
||||||
let reason = match e.kind() {
|
|
||||||
std::io::ErrorKind::NotFound => format!("record not found: {}", path.display()),
|
|
||||||
_ => format!("read failed at {}: {e}", path.display()),
|
|
||||||
};
|
|
||||||
let _ = append_record_usage(
|
|
||||||
&self.layout,
|
|
||||||
RecordUsageAudit {
|
|
||||||
op: "read".to_string(),
|
|
||||||
status: AuditStatus::Failed,
|
|
||||||
kind,
|
|
||||||
slug: Some(slug),
|
|
||||||
path: Some(path.display().to_string()),
|
|
||||||
query: None,
|
|
||||||
result_count: None,
|
|
||||||
reason: Some(reason.clone()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return Err(ToolError::ExecutionFailed(reason));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
|
||||||
if let Some(segment_id) = self.usage_session_id.as_deref() {
|
|
||||||
let usage_slug = params.slug.as_deref().unwrap_or("summary");
|
|
||||||
let snapshot = usage::snapshot_record_from_bytes(
|
|
||||||
params.kind.record_kind(),
|
|
||||||
usage_slug.to_string(),
|
|
||||||
&bytes,
|
|
||||||
);
|
|
||||||
if let Err(err) = usage::append_use_event(
|
|
||||||
&self.layout,
|
|
||||||
segment_id.to_string(),
|
|
||||||
UsageSource::MemoryRead,
|
|
||||||
vec![snapshot],
|
|
||||||
) {
|
|
||||||
tracing::warn!(error = %err, "failed to append MemoryRead usage event");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"Read {} line(s) from {}",
|
|
||||||
rendered.line_count,
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = append_record_usage(
|
|
||||||
&self.layout,
|
|
||||||
RecordUsageAudit {
|
|
||||||
op: "read".to_string(),
|
|
||||||
status: AuditStatus::Success,
|
|
||||||
kind,
|
|
||||||
slug: Some(slug),
|
|
||||||
path: Some(path.display().to_string()),
|
|
||||||
query: None,
|
|
||||||
result_count: Some(rendered.line_count),
|
|
||||||
reason: if rendered.truncated {
|
|
||||||
Some("truncated".to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(ToolOutput {
|
|
||||||
summary,
|
|
||||||
content: Some(rendered.body),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn audit_slug(kind: &MemoryToolKind, slug: Option<&str>) -> String {
|
|
||||||
match kind {
|
|
||||||
MemoryToolKind::Summary => "summary".to_string(),
|
|
||||||
_ => slug.unwrap_or("<missing>").to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
read_tool_inner(layout, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_tool_with_usage(
|
|
||||||
layout: WorkspaceLayout,
|
|
||||||
segment_id: impl Into<String>,
|
|
||||||
) -> ToolDefinition {
|
|
||||||
read_tool_inner(layout, Some(segment_id.into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_tool_inner(layout: WorkspaceLayout, usage_session_id: Option<String>) -> 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(),
|
|
||||||
usage_session_id: usage_session_id.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_decision_by_slug() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
let path = dir.path().join(".yoi/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!({ "kind": "decision", "slug": "foo" });
|
|
||||||
let out = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let body = out.content.unwrap();
|
|
||||||
assert!(body.contains(" 1\talpha"));
|
|
||||||
assert!(body.contains(" 2\tbeta"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn read_summary_without_slug() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
let path = dir.path().join(".yoi/memory/summary.md");
|
|
||||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
||||||
std::fs::write(&path, "summary body\n").unwrap();
|
|
||||||
|
|
||||||
let (_, tool) = read_tool(layout)();
|
|
||||||
let inp = serde_json::json!({ "kind": "summary" });
|
|
||||||
let out = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(out.content.unwrap().contains("summary body"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn summary_with_slug_rejected() {
|
|
||||||
let (_dir, layout) = setup();
|
|
||||||
let (_, tool) = read_tool(layout)();
|
|
||||||
let inp = serde_json::json!({ "kind": "summary", "slug": "x" });
|
|
||||||
let err = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn decision_without_slug_rejected() {
|
|
||||||
let (_dir, layout) = setup();
|
|
||||||
let (_, tool) = read_tool(layout)();
|
|
||||||
let inp = serde_json::json!({ "kind": "decision" });
|
|
||||||
let err = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn invalid_slug_rejected() {
|
|
||||||
let (_dir, layout) = setup();
|
|
||||||
let (_, tool) = read_tool(layout)();
|
|
||||||
let inp = serde_json::json!({ "kind": "decision", "slug": "Bad-Slug" });
|
|
||||||
let err = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn read_logs_explicit_use_when_usage_session_is_set() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
let path = dir.path().join(".yoi/memory/decisions/foo.md");
|
|
||||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
||||||
std::fs::write(&path, "alpha\n").unwrap();
|
|
||||||
|
|
||||||
let (_, tool) = read_tool_with_usage(layout.clone(), "session-1")();
|
|
||||||
let inp = serde_json::json!({ "kind": "decision", "slug": "foo" });
|
|
||||||
tool.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let report = usage::build_usage_report(&layout).unwrap();
|
|
||||||
assert_eq!(report.records.len(), 1);
|
|
||||||
let record = &report.records[0];
|
|
||||||
assert_eq!(record.kind, "decision");
|
|
||||||
assert_eq!(record.slug, "foo");
|
|
||||||
assert_eq!(record.use_count, 1);
|
|
||||||
assert_eq!(record.source_breakdown["MemoryRead"], 1);
|
|
||||||
assert_eq!(record.resident_exposure_count, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn missing_file_returns_execution_failed() {
|
|
||||||
let (_dir, layout) = setup();
|
|
||||||
let (_, tool) = read_tool(layout)();
|
|
||||||
let inp = serde_json::json!({ "kind": "decision", "slug": "missing" });
|
|
||||||
let err = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, ToolError::ExecutionFailed(_)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,341 +0,0 @@
|
||||||
//! `MemoryWrite` tool.
|
|
||||||
//!
|
|
||||||
//! Creates or overwrites a memory record by `(kind, slug)`.
|
|
||||||
//! Pre-write Linter validates frontmatter, slug uniqueness (Create only),
|
|
||||||
//! reference integrity, size limits. On any
|
|
||||||
//! Linter error the tool returns `ToolError::InvalidArgument` with all
|
|
||||||
//! violations aggregated and the file is **not** written.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::audit::{
|
|
||||||
AuditStatus, RecordOperationAudit, append_record_operation, file_hash, hash_bytes,
|
|
||||||
};
|
|
||||||
use crate::linter::{LintReport, Linter, WriteMode};
|
|
||||||
use crate::tool::MemoryToolKind;
|
|
||||||
use crate::workspace::WorkspaceLayout;
|
|
||||||
|
|
||||||
const DESCRIPTION: &str = "Create or overwrite a memory record by \
|
|
||||||
`kind` + `slug`. `kind`: summary | decision | request. For `summary` \
|
|
||||||
omit `slug`. Frontmatter is validated before write; on validation failure no \
|
|
||||||
write occurs and every violation is returned in the error message.";
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
|
||||||
struct WriteParams {
|
|
||||||
/// Record kind: `summary` | `decision` | `request`.
|
|
||||||
kind: MemoryToolKind,
|
|
||||||
/// Slug. Required for everything except `summary`; forbidden for `summary`.
|
|
||||||
#[serde(default)]
|
|
||||||
slug: Option<String>,
|
|
||||||
/// Full file contents (frontmatter + body).
|
|
||||||
content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WriteTool {
|
|
||||||
layout: WorkspaceLayout,
|
|
||||||
linter: Linter,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for WriteTool {
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
input_json: &str,
|
|
||||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let params: WriteParams = serde_json::from_str(input_json)
|
|
||||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid MemoryWrite input: {e}")))?;
|
|
||||||
|
|
||||||
let path = params
|
|
||||||
.kind
|
|
||||||
.resolve_path(&self.layout, params.slug.as_deref())?;
|
|
||||||
let kind = params.kind.to_string();
|
|
||||||
let slug = audit_slug(¶ms.kind, params.slug.as_deref());
|
|
||||||
|
|
||||||
let before_hash = file_hash(&path).ok().flatten();
|
|
||||||
let already_exists = before_hash.is_some();
|
|
||||||
let mode = if already_exists {
|
|
||||||
WriteMode::Update
|
|
||||||
} else {
|
|
||||||
WriteMode::Create
|
|
||||||
};
|
|
||||||
|
|
||||||
let report = self.linter.lint(&path, ¶ms.content, mode);
|
|
||||||
if report.has_errors() {
|
|
||||||
let reason = format_report(&report);
|
|
||||||
let _ = append_record_operation(
|
|
||||||
&self.layout,
|
|
||||||
RecordOperationAudit {
|
|
||||||
op: "write".to_string(),
|
|
||||||
status: AuditStatus::Failed,
|
|
||||||
kind,
|
|
||||||
slug,
|
|
||||||
path: path.display().to_string(),
|
|
||||||
before_hash,
|
|
||||||
after_hash: None,
|
|
||||||
reason: Some(reason.clone()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return Err(ToolError::InvalidArgument(reason));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
|
||||||
let reason = format!("failed to create directory {}: {e}", parent.display());
|
|
||||||
let _ = append_record_operation(
|
|
||||||
&self.layout,
|
|
||||||
RecordOperationAudit {
|
|
||||||
op: "write".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(e) = std::fs::write(&path, params.content.as_bytes()) {
|
|
||||||
let reason = format!("failed to write {}: {e}", path.display());
|
|
||||||
let _ = append_record_operation(
|
|
||||||
&self.layout,
|
|
||||||
RecordOperationAudit {
|
|
||||||
op: "write".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 after_hash = Some(hash_bytes(params.content.as_bytes()));
|
|
||||||
let _ = append_record_operation(
|
|
||||||
&self.layout,
|
|
||||||
RecordOperationAudit {
|
|
||||||
op: "write".to_string(),
|
|
||||||
status: AuditStatus::Success,
|
|
||||||
kind,
|
|
||||||
slug,
|
|
||||||
path: path.display().to_string(),
|
|
||||||
before_hash,
|
|
||||||
after_hash,
|
|
||||||
reason: if report.warnings.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(format!("{} warning(s)", report.warnings.len()))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let summary = format!(
|
|
||||||
"{} {}{}",
|
|
||||||
if already_exists {
|
|
||||||
"Overwrote"
|
|
||||||
} else {
|
|
||||||
"Created"
|
|
||||||
},
|
|
||||||
path.display(),
|
|
||||||
warning_tail(&report),
|
|
||||||
);
|
|
||||||
Ok(ToolOutput {
|
|
||||||
summary,
|
|
||||||
content: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn audit_slug(kind: &MemoryToolKind, slug: Option<&str>) -> String {
|
|
||||||
match kind {
|
|
||||||
MemoryToolKind::Summary => "summary".to_string(),
|
|
||||||
_ => slug.unwrap_or("<missing>").to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
layout: layout.clone(),
|
|
||||||
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(".yoi/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!({
|
|
||||||
"kind": "summary",
|
|
||||||
"content": content,
|
|
||||||
});
|
|
||||||
let out = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(out.summary.contains("Created"));
|
|
||||||
assert!(path.exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn write_aggregates_multiple_errors() {
|
|
||||||
let (_dir, layout) = setup();
|
|
||||||
// Missing required `status` field for decisions.
|
|
||||||
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!({
|
|
||||||
"kind": "decision",
|
|
||||||
"slug": "foo",
|
|
||||||
"content": content,
|
|
||||||
});
|
|
||||||
let err = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
let msg = format!("{err}");
|
|
||||||
assert!(msg.contains("status") || msg.contains("missing"), "{msg}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn write_update_existing() {
|
|
||||||
let (dir, layout) = setup();
|
|
||||||
let path = dir.path().join(".yoi/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();
|
|
||||||
|
|
||||||
let (_, tool) = write_tool(layout.clone())();
|
|
||||||
let inp = serde_json::json!({
|
|
||||||
"kind": "decision",
|
|
||||||
"slug": "foo",
|
|
||||||
"content": initial,
|
|
||||||
});
|
|
||||||
let out = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(out.summary.contains("Overwrote"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn write_decision_requires_slug() {
|
|
||||||
let (_dir, layout) = setup();
|
|
||||||
let (_, tool) = write_tool(layout)();
|
|
||||||
let inp = serde_json::json!({
|
|
||||||
"kind": "decision",
|
|
||||||
"content": "ignored",
|
|
||||||
});
|
|
||||||
let err = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.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(".yoi/memory/decisions/foo.md");
|
|
||||||
let bad = "no frontmatter at all";
|
|
||||||
let (_, tool) = write_tool(layout)();
|
|
||||||
let inp = serde_json::json!({
|
|
||||||
"kind": "decision",
|
|
||||||
"slug": "foo",
|
|
||||||
"content": bad,
|
|
||||||
});
|
|
||||||
assert!(
|
|
||||||
tool.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert!(!path.exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn workflow_kind_not_acceptable() {
|
|
||||||
// The MemoryToolKind enum doesn't include Workflow, so deserialization fails.
|
|
||||||
let (_dir, layout) = setup();
|
|
||||||
let (_, tool) = write_tool(layout)();
|
|
||||||
let inp = serde_json::json!({
|
|
||||||
"kind": "workflow",
|
|
||||||
"slug": "wf",
|
|
||||||
"content": "---\n---\n",
|
|
||||||
});
|
|
||||||
let err = tool
|
|
||||||
.execute(&inp.to_string(), Default::default())
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -324,7 +324,7 @@ fn tool_output(output: MemoryToolOutput) -> ToolOutput {
|
||||||
const READ_DOCUMENT_DESCRIPTION: &str =
|
const READ_DOCUMENT_DESCRIPTION: &str =
|
||||||
"Read the Workspace memory Markdown document through Workspace authority.";
|
"Read the Workspace memory Markdown document through Workspace authority.";
|
||||||
const UPDATE_DOCUMENT_DESCRIPTION: &str =
|
const UPDATE_DOCUMENT_DESCRIPTION: &str =
|
||||||
"Replace the Workspace memory Markdown document through Workspace authority.";
|
"Edit the Workspace memory Markdown document by replacing an exact old_string with new_string.";
|
||||||
const QUERY_DESCRIPTION: &str = "Query the Workspace memory document through Workspace authority.";
|
const QUERY_DESCRIPTION: &str = "Query the Workspace memory document through Workspace authority.";
|
||||||
const STAGING_LIST_DESCRIPTION: &str =
|
const STAGING_LIST_DESCRIPTION: &str =
|
||||||
"List pending Memory staging candidates without loading full record payloads.";
|
"List pending Memory staging candidates without loading full record payloads.";
|
||||||
|
|
@ -346,9 +346,11 @@ fn document_update_schema() -> serde_json::Value {
|
||||||
json!({
|
json!({
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required":["body_md"],
|
"required":["old_string", "new_string"],
|
||||||
"properties":{
|
"properties":{
|
||||||
"body_md":{"type":"string"}
|
"old_string":{"type":"string", "minLength": 1},
|
||||||
|
"new_string":{"type":"string"},
|
||||||
|
"replace_all":{"type":"boolean", "default": false}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -377,6 +379,15 @@ mod tests {
|
||||||
names
|
names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_meta(definitions: Vec<ToolDefinition>, name: &str) -> serde_json::Value {
|
||||||
|
definitions
|
||||||
|
.into_iter()
|
||||||
|
.map(|tool| tool().0)
|
||||||
|
.find(|meta| meta.name == name)
|
||||||
|
.unwrap_or_else(|| panic!("missing tool meta for {name}"))
|
||||||
|
.input_schema
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normal_workspace_memory_tools_do_not_include_staging_tools() {
|
fn normal_workspace_memory_tools_do_not_include_staging_tools() {
|
||||||
let names = tool_names(workspace_http_memory_tools(
|
let names = tool_names(workspace_http_memory_tools(
|
||||||
|
|
@ -396,6 +407,37 @@ mod tests {
|
||||||
assert!(!names.contains(&"MemoryStagingClose".to_string()));
|
assert!(!names.contains(&"MemoryStagingClose".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_update_schema_is_edit_like_and_staging_close_has_no_legacy_kinds() {
|
||||||
|
let update_schema = tool_meta(
|
||||||
|
workspace_http_memory_tools("workspace".to_string(), "http://backend".to_string()),
|
||||||
|
"MemoryUpdateDocument",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
update_schema["required"],
|
||||||
|
serde_json::json!(["old_string", "new_string"])
|
||||||
|
);
|
||||||
|
assert!(update_schema["properties"].get("old_string").is_some());
|
||||||
|
assert!(update_schema["properties"].get("new_string").is_some());
|
||||||
|
assert!(update_schema["properties"].get("replace_all").is_some());
|
||||||
|
assert!(update_schema["properties"].get("body_md").is_none());
|
||||||
|
|
||||||
|
let close_schema_text = tool_meta(
|
||||||
|
workspace_http_memory_consolidation_tools(
|
||||||
|
"workspace".to_string(),
|
||||||
|
"http://backend".to_string(),
|
||||||
|
),
|
||||||
|
"MemoryStagingClose",
|
||||||
|
)
|
||||||
|
.to_string();
|
||||||
|
assert!(close_schema_text.contains("affected_memory"));
|
||||||
|
assert!(close_schema_text.contains("edit"));
|
||||||
|
assert!(!close_schema_text.contains("summary"));
|
||||||
|
assert!(!close_schema_text.contains("decision"));
|
||||||
|
assert!(!close_schema_text.contains("request"));
|
||||||
|
assert!(!close_schema_text.contains("slug"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn consolidation_workspace_memory_tools_include_staging_tools() {
|
fn consolidation_workspace_memory_tools_include_staging_tools() {
|
||||||
let names = tool_names(workspace_http_memory_consolidation_tools(
|
let names = tool_names(workspace_http_memory_consolidation_tools(
|
||||||
|
|
|
||||||
|
|
@ -541,6 +541,28 @@ mod tests {
|
||||||
assert!(consolidate.contains("`language`: `Japanese`"));
|
assert!(consolidate.contains("`language`: `Japanese`"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_consolidation_prompt_describes_document_edit_policy_without_storage_details() {
|
||||||
|
let cat = PromptCatalog::builtins_only().unwrap();
|
||||||
|
let consolidate = cat.memory_consolidation_system("Japanese").unwrap();
|
||||||
|
assert!(consolidate.contains("Durable Memory is one Markdown document"));
|
||||||
|
assert!(consolidate.contains("existing `##` section"));
|
||||||
|
assert!(consolidate.contains("MemoryUpdateDocument"));
|
||||||
|
assert!(consolidate.contains("old_string"));
|
||||||
|
assert!(consolidate.contains("new_string"));
|
||||||
|
assert!(consolidate.contains("MemoryStagingClose"));
|
||||||
|
assert!(consolidate.contains("`applied`"));
|
||||||
|
assert!(consolidate.contains(r#"{"operation":"edit"}"#));
|
||||||
|
assert!(consolidate.contains("Do not create Knowledge, Skill, Ticket"));
|
||||||
|
assert!(consolidate.contains("Do not create separate categorized Memory records"));
|
||||||
|
assert!(!consolidate.contains("SQLite"));
|
||||||
|
assert!(!consolidate.contains("SQL"));
|
||||||
|
assert!(!consolidate.contains("storage"));
|
||||||
|
assert!(!consolidate.contains("decision record"));
|
||||||
|
assert!(!consolidate.contains("request record"));
|
||||||
|
assert!(!consolidate.contains("summary record"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn notify_wrapper_interpolates_message() {
|
fn notify_wrapper_interpolates_message() {
|
||||||
let cat = PromptCatalog::builtins_only().unwrap();
|
let cat = PromptCatalog::builtins_only().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -528,7 +528,7 @@ mod tests {
|
||||||
let staging = authority
|
let staging = authority
|
||||||
.upsert_memory_staging_record(
|
.upsert_memory_staging_record(
|
||||||
"candidate-1",
|
"candidate-1",
|
||||||
r#"{"kind":"summary","body_md":"Candidate"}"#,
|
r#"{"claim":"Candidate"}"#,
|
||||||
Some("memory/_staging/candidate-1.json"),
|
Some("memory/_staging/candidate-1.json"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
use memory::backend::{
|
use memory::backend::{
|
||||||
MemoryBackendAckOutput, MemoryBackendOperation, MemoryBackendOperationResult,
|
MemoryBackendAckOutput, MemoryBackendOperation, MemoryBackendOperationResult,
|
||||||
MemoryDocumentReadOperation, MemoryDocumentUpdateOperation, MemoryQueryOperation,
|
MemoryDocumentReadOperation, MemoryDocumentUpdateOperation, MemoryQueryOperation,
|
||||||
MemoryReadOperation, MemoryStagingCloseAction, MemoryStagingCloseOperation,
|
MemoryStagingCloseAction, MemoryStagingCloseOperation, MemoryStagingListOperation,
|
||||||
MemoryStagingListOperation, MemoryStagingReadOperation, MemoryStagingWriteOutput,
|
MemoryStagingReadOperation, MemoryStagingWriteOutput, MemoryToolOutput,
|
||||||
MemoryToolOutput,
|
|
||||||
};
|
};
|
||||||
use memory::extract::{ExtractedCandidate, StagingEvidence, StagingRecord};
|
use memory::extract::{ExtractedCandidate, StagingEvidence, StagingRecord};
|
||||||
use memory::schema::{SourceEvidenceRef, SourceRef};
|
use memory::schema::{SourceEvidenceRef, SourceRef};
|
||||||
|
|
@ -24,10 +23,13 @@ pub fn execute_memory_backend_operation_with_authority<A: MemoryAuthority>(
|
||||||
MemoryBackendOperation::Query(operation) => {
|
MemoryBackendOperation::Query(operation) => {
|
||||||
execute_query(authority, operation).map(MemoryBackendOperationResult::ToolOutput)
|
execute_query(authority, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||||
}
|
}
|
||||||
MemoryBackendOperation::ReadDocument(operation) => execute_read_document(authority, operation)
|
MemoryBackendOperation::ReadDocument(operation) => {
|
||||||
.map(MemoryBackendOperationResult::ToolOutput),
|
execute_read_document(authority, operation)
|
||||||
|
.map(MemoryBackendOperationResult::ToolOutput)
|
||||||
|
}
|
||||||
MemoryBackendOperation::UpdateDocument(operation) => {
|
MemoryBackendOperation::UpdateDocument(operation) => {
|
||||||
execute_update_document(authority, operation).map(MemoryBackendOperationResult::ToolOutput)
|
execute_update_document(authority, operation)
|
||||||
|
.map(MemoryBackendOperationResult::ToolOutput)
|
||||||
}
|
}
|
||||||
MemoryBackendOperation::ResidentSummary(_operation) => Ok(
|
MemoryBackendOperation::ResidentSummary(_operation) => Ok(
|
||||||
MemoryBackendOperationResult::ToolOutput(execute_resident_summary(authority)?),
|
MemoryBackendOperationResult::ToolOutput(execute_resident_summary(authority)?),
|
||||||
|
|
@ -73,19 +75,16 @@ pub fn execute_memory_backend_operation_with_authority<A: MemoryAuthority>(
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
MemoryBackendOperation::StagingList(operation) => execute_staging_list(authority, operation)
|
MemoryBackendOperation::StagingList(operation) => {
|
||||||
.map(MemoryBackendOperationResult::ToolOutput),
|
execute_staging_list(authority, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||||
MemoryBackendOperation::StagingRead(operation) => execute_staging_read(authority, operation)
|
}
|
||||||
.map(MemoryBackendOperationResult::ToolOutput),
|
MemoryBackendOperation::StagingRead(operation) => {
|
||||||
MemoryBackendOperation::StagingClose(operation) => execute_staging_close(authority, operation)
|
execute_staging_read(authority, operation).map(MemoryBackendOperationResult::ToolOutput)
|
||||||
.map(MemoryBackendOperationResult::ToolOutput),
|
}
|
||||||
MemoryBackendOperation::Read(operation) => execute_legacy_read(authority, operation)
|
MemoryBackendOperation::StagingClose(operation) => {
|
||||||
.map(MemoryBackendOperationResult::ToolOutput),
|
execute_staging_close(authority, operation)
|
||||||
MemoryBackendOperation::Write(_)
|
.map(MemoryBackendOperationResult::ToolOutput)
|
||||||
| MemoryBackendOperation::Edit(_)
|
}
|
||||||
| MemoryBackendOperation::Delete(_) => Err(Error::Store(
|
|
||||||
"legacy summary/decision/request Memory mutation operations are not supported by Workspace authority; use MemoryUpdateDocument".to_string(),
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,24 +134,6 @@ fn execute_query<A: MemoryAuthority>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_legacy_read<A: MemoryAuthority>(
|
|
||||||
authority: &A,
|
|
||||||
operation: MemoryReadOperation,
|
|
||||||
) -> Result<MemoryToolOutput> {
|
|
||||||
if operation.kind.as_str() != "summary" || operation.slug.is_some() {
|
|
||||||
return Err(Error::Store(
|
|
||||||
"legacy kind/slug Memory reads are not supported by Workspace authority; use MemoryReadDocument".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
execute_read_document(
|
|
||||||
authority,
|
|
||||||
MemoryDocumentReadOperation {
|
|
||||||
offset: operation.offset,
|
|
||||||
limit: operation.limit,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn execute_read_document<A: MemoryAuthority>(
|
fn execute_read_document<A: MemoryAuthority>(
|
||||||
authority: &A,
|
authority: &A,
|
||||||
operation: MemoryDocumentReadOperation,
|
operation: MemoryDocumentReadOperation,
|
||||||
|
|
@ -185,18 +166,57 @@ fn execute_update_document<A: MemoryAuthority>(
|
||||||
authority: &A,
|
authority: &A,
|
||||||
operation: MemoryDocumentUpdateOperation,
|
operation: MemoryDocumentUpdateOperation,
|
||||||
) -> Result<MemoryToolOutput> {
|
) -> Result<MemoryToolOutput> {
|
||||||
if operation.body_md.trim().is_empty() {
|
if operation.old_string == operation.new_string {
|
||||||
return Err(Error::Store(
|
return Err(Error::Store(
|
||||||
"memory document body_md must not be empty".to_string(),
|
"memory document old_string and new_string must differ".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let document = authority.update_memory_document(&operation.body_md)?;
|
if operation.old_string.is_empty() {
|
||||||
|
return Err(Error::Store(
|
||||||
|
"memory document old_string must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let document = authority.ensure_memory_document()?;
|
||||||
|
let match_count = document.body_md.matches(&operation.old_string).count();
|
||||||
|
if match_count == 0 {
|
||||||
|
return Err(Error::Store(
|
||||||
|
"memory document old_string was not found".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if match_count > 1 && !operation.replace_all {
|
||||||
|
return Err(Error::Store(
|
||||||
|
"memory document old_string matched more than once; set replace_all=true to replace every occurrence".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let updated_body = if operation.replace_all {
|
||||||
|
document
|
||||||
|
.body_md
|
||||||
|
.replace(&operation.old_string, &operation.new_string)
|
||||||
|
} else {
|
||||||
|
document
|
||||||
|
.body_md
|
||||||
|
.replacen(&operation.old_string, &operation.new_string, 1)
|
||||||
|
};
|
||||||
|
if updated_body.trim().is_empty() {
|
||||||
|
return Err(Error::Store(
|
||||||
|
"memory document body must not become empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let updated = authority.update_memory_document(&updated_body)?;
|
||||||
Ok(MemoryToolOutput {
|
Ok(MemoryToolOutput {
|
||||||
summary: format!(
|
summary: format!(
|
||||||
"Updated Workspace memory document ({} bytes)",
|
"Edited {} occurrence(s) in Workspace memory document",
|
||||||
document.body_md.len()
|
if operation.replace_all {
|
||||||
|
match_count
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
),
|
),
|
||||||
content: Some(format!("updated_at={}", document.updated_at)),
|
content: Some(format!(
|
||||||
|
"updated_at={} bytes={}",
|
||||||
|
updated.updated_at,
|
||||||
|
updated.body_md.len()
|
||||||
|
)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -409,7 +429,9 @@ mod tests {
|
||||||
execute_memory_backend_operation_with_authority(
|
execute_memory_backend_operation_with_authority(
|
||||||
&authority,
|
&authority,
|
||||||
MemoryBackendOperation::UpdateDocument(MemoryDocumentUpdateOperation {
|
MemoryBackendOperation::UpdateDocument(MemoryDocumentUpdateOperation {
|
||||||
body_md: "# Memory\n\nSQLite backed body".into(),
|
old_string: "# Memory\n\n".into(),
|
||||||
|
new_string: "# Memory\n\nSQLite backed body".into(),
|
||||||
|
replace_all: false,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -3113,7 +3113,7 @@ mod tests {
|
||||||
let staging = MemoryStagingRecord {
|
let staging = MemoryStagingRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
candidate_id: "candidate-a".to_string(),
|
candidate_id: "candidate-a".to_string(),
|
||||||
raw_json: r#"{"kind":"summary","body":"candidate"}"#.to_string(),
|
raw_json: r#"{"claim":"candidate"}"#.to_string(),
|
||||||
source_path: Some("memory/_staging/candidate-a.json".to_string()),
|
source_path: Some("memory/_staging/candidate-a.json".to_string()),
|
||||||
imported_at: "4".to_string(),
|
imported_at: "4".to_string(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,24 +9,41 @@ Use this loop:
|
||||||
1. Call `MemoryStagingList` to inspect pending candidates.
|
1. Call `MemoryStagingList` to inspect pending candidates.
|
||||||
2. Pick one candidate and call `MemoryStagingRead` for the full record.
|
2. Pick one candidate and call `MemoryStagingRead` for the full record.
|
||||||
3. Use `MemoryQuery` / `MemoryReadDocument` to compare against durable Memory.
|
3. Use `MemoryQuery` / `MemoryReadDocument` to compare against durable Memory.
|
||||||
4. If the candidate should change durable Memory, update the single durable Markdown document with `MemoryUpdateDocument` first.
|
4. If the candidate should change durable Memory, edit the durable Markdown document with `MemoryUpdateDocument` before closing the candidate.
|
||||||
5. Call `MemoryStagingClose` exactly once for every candidate you finish. Always include a concrete reason.
|
5. Call `MemoryStagingClose` exactly once for every candidate you finish. Always include a concrete reason.
|
||||||
|
|
||||||
|
Durable Memory is one Markdown document. Keep it concise and useful for future workers:
|
||||||
|
|
||||||
|
- Merge facts into the most relevant existing `##` section when possible.
|
||||||
|
- Add a new `##` section only when the candidate does not fit an existing section.
|
||||||
|
- Prefer short bullets or short paragraphs over long transcripts.
|
||||||
|
- Preserve exact identifiers, paths, commands, Ticket IDs, branch names, and quoted user text when they matter.
|
||||||
|
- Do not create separate categorized Memory records.
|
||||||
|
|
||||||
|
`MemoryUpdateDocument` edits the document by exact replacement, like a text edit tool:
|
||||||
|
|
||||||
|
- Read enough context first with `MemoryReadDocument`.
|
||||||
|
- Provide the exact `old_string` to replace and the desired `new_string`.
|
||||||
|
- Use `replace_all` only when every occurrence should change.
|
||||||
|
- Do not rewrite the whole document unless the whole document is the intended `old_string`.
|
||||||
|
|
||||||
`MemoryStagingClose` removes the staging candidate after recording your disposition. Use it for both applied candidates and candidates that should not become Memory.
|
`MemoryStagingClose` removes the staging candidate after recording your disposition. Use it for both applied candidates and candidates that should not become Memory.
|
||||||
|
|
||||||
Valid close actions:
|
Valid close actions:
|
||||||
|
|
||||||
- `applied`: you changed durable Memory for this candidate.
|
- `applied`: you edited durable Memory for this candidate. Include `affected_memory: [{"operation":"edit"}]`.
|
||||||
- `discarded`: the candidate is understandable but should not become durable Memory.
|
- `discarded`: the candidate is understandable but should not become durable Memory.
|
||||||
- `invalid`: the staging record is malformed or cannot be interpreted.
|
- `invalid`: the staging record is malformed or cannot be interpreted.
|
||||||
- `duplicate`: the candidate duplicates another candidate or record.
|
- `duplicate`: the candidate duplicates another candidate or durable Memory.
|
||||||
- `already_covered`: durable Memory already covers the candidate sufficiently.
|
- `already_covered`: durable Memory already covers the candidate sufficiently.
|
||||||
|
|
||||||
|
Close reasons must be specific. For example, say what was added or updated for `applied`, what existing section already covered it for `already_covered`, why it is not durable enough for `discarded`, or what is malformed for `invalid`.
|
||||||
|
|
||||||
## Language
|
## Language
|
||||||
|
|
||||||
- `language`: `{{language}}`
|
- `language`: `{{language}}`
|
||||||
- Write durable memory prose in this language, including frontmatter descriptions and record bodies.
|
- Write durable memory prose in this language.
|
||||||
- Preserve literal identifiers, paths, commands, branch names, issue IDs, tool names, model names, and quoted user/system text as-is.
|
- Preserve literal identifiers, paths, commands, branch names, issue IDs, tool names, model names, and quoted user/system text as-is.
|
||||||
- If the configured language is unclear, use English.
|
- If the configured language is unclear, use English.
|
||||||
|
|
||||||
Do not create Tickets, documents, child Workers, or filesystem files. Use only Memory and MemoryStaging tools.
|
Do not create Knowledge, Skill, Ticket, documentation, child Worker, or filesystem records. Use only Memory and MemoryStaging tools.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user