merge: sqlite memory tools

This commit is contained in:
Keisuke Hirata 2026-07-26 15:23:24 +09:00
commit faaf258a96
No known key found for this signature in database
9 changed files with 628 additions and 131 deletions

View File

@ -28,6 +28,8 @@ use crate::workspace::WorkspaceLayout;
#[serde(tag = "operation", rename_all = "snake_case")] #[serde(tag = "operation", rename_all = "snake_case")]
pub enum MemoryBackendOperation { pub enum MemoryBackendOperation {
Query(MemoryQueryOperation), Query(MemoryQueryOperation),
ReadDocument(MemoryDocumentReadOperation),
UpdateDocument(MemoryDocumentUpdateOperation),
Read(MemoryReadOperation), Read(MemoryReadOperation),
Write(MemoryWriteOperation), Write(MemoryWriteOperation),
Edit(MemoryEditOperation), Edit(MemoryEditOperation),
@ -73,6 +75,19 @@ pub struct MemoryQueryOperation {
pub query: Option<String>, pub query: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MemoryDocumentReadOperation {
#[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, JsonSchema)]
pub struct MemoryDocumentUpdateOperation {
pub body_md: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryReadOperation { pub struct MemoryReadOperation {
pub kind: MemoryToolKind, pub kind: MemoryToolKind,
@ -232,6 +247,14 @@ pub fn execute_memory_backend_operation(
MemoryBackendOperation::Query(operation) => { MemoryBackendOperation::Query(operation) => {
execute_query(layout, operation).map(MemoryBackendOperationResult::ToolOutput) execute_query(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
} }
MemoryBackendOperation::ReadDocument(_operation) => Err(io::Error::new(
io::ErrorKind::Unsupported,
"Memory document operations require WorkspaceAuthority-backed executor",
)),
MemoryBackendOperation::UpdateDocument(_operation) => Err(io::Error::new(
io::ErrorKind::Unsupported,
"Memory document operations require WorkspaceAuthority-backed executor",
)),
MemoryBackendOperation::Read(operation) => { MemoryBackendOperation::Read(operation) => {
execute_read(layout, operation).map(MemoryBackendOperationResult::ToolOutput) execute_read(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
} }

View File

@ -12,9 +12,9 @@ use llm_engine::tool::{
}; };
use memory::backend::{ use memory::backend::{
MemoryBackendHttpResponse, MemoryBackendOperation, MemoryBackendOperationResult, MemoryBackendHttpResponse, MemoryBackendOperation, MemoryBackendOperationResult,
MemoryConsolidateStagingOperation, MemoryConsolidationOutput, MemoryDeleteOperation, MemoryConsolidateStagingOperation, MemoryConsolidationOutput, MemoryDocumentReadOperation,
MemoryEditOperation, MemoryQueryOperation, MemoryReadOperation, MemoryStagingCloseOperation, MemoryDocumentUpdateOperation, MemoryQueryOperation, MemoryStagingCloseOperation,
MemoryStagingListOperation, MemoryStagingReadOperation, MemoryToolOutput, MemoryWriteOperation, MemoryStagingListOperation, MemoryStagingReadOperation, MemoryToolOutput,
}; };
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
@ -175,47 +175,29 @@ pub fn workspace_http_memory_tools(
let backend = WorkspaceHttpMemoryBackend::new(workspace_id, base_url); let backend = WorkspaceHttpMemoryBackend::new(workspace_id, base_url);
vec![ vec![
memory_tool( memory_tool(
"MemoryRead", "MemoryReadDocument",
READ_DESCRIPTION, READ_DOCUMENT_DESCRIPTION,
read_schema(), document_read_schema(),
backend.clone(), backend.clone(),
|input| { |input| {
Ok(MemoryBackendOperation::Read(parse_input::< Ok(MemoryBackendOperation::ReadDocument(parse_input::<
MemoryReadOperation, MemoryDocumentReadOperation,
>(input)?)) >(
input
)?))
}, },
), ),
memory_tool( memory_tool(
"MemoryWrite", "MemoryUpdateDocument",
WRITE_DESCRIPTION, UPDATE_DOCUMENT_DESCRIPTION,
write_schema(), document_update_schema(),
backend.clone(), backend.clone(),
|input| { |input| {
Ok(MemoryBackendOperation::Write(parse_input::< Ok(MemoryBackendOperation::UpdateDocument(parse_input::<
MemoryWriteOperation, MemoryDocumentUpdateOperation,
>(input)?)) >(
}, input
), )?))
memory_tool(
"MemoryEdit",
EDIT_DESCRIPTION,
edit_schema(),
backend.clone(),
|input| {
Ok(MemoryBackendOperation::Edit(parse_input::<
MemoryEditOperation,
>(input)?))
},
),
memory_tool(
"MemoryDelete",
DELETE_DESCRIPTION,
delete_schema(),
backend.clone(),
|input| {
Ok(MemoryBackendOperation::Delete(parse_input::<
MemoryDeleteOperation,
>(input)?))
}, },
), ),
memory_tool( memory_tool(
@ -339,72 +321,34 @@ fn tool_output(output: MemoryToolOutput) -> ToolOutput {
} }
} }
const READ_DESCRIPTION: &str = "Read a durable memory record through Workspace authority."; const READ_DOCUMENT_DESCRIPTION: &str =
const WRITE_DESCRIPTION: &str = "Read the Workspace memory Markdown document through Workspace authority.";
"Create or overwrite a durable memory record through Workspace authority."; const UPDATE_DOCUMENT_DESCRIPTION: &str =
const EDIT_DESCRIPTION: &str = "Replace the Workspace memory Markdown document through Workspace authority.";
"Replace text in a durable memory record through Workspace authority."; const QUERY_DESCRIPTION: &str = "Query the Workspace memory document through Workspace authority.";
const DELETE_DESCRIPTION: &str = "Delete a durable memory record through Workspace authority.";
const QUERY_DESCRIPTION: &str = "Query durable memory records 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.";
const STAGING_READ_DESCRIPTION: &str = "Read one pending Memory staging candidate by candidate_id."; const STAGING_READ_DESCRIPTION: &str = "Read one pending Memory staging candidate by candidate_id.";
const STAGING_CLOSE_DESCRIPTION: &str = "Close one staging candidate with a required reason; records disposition and deletes the staging record."; const STAGING_CLOSE_DESCRIPTION: &str = "Close one staging candidate with a required reason; records disposition and deletes the staging record.";
fn kind_schema() -> serde_json::Value { fn document_read_schema() -> serde_json::Value {
json!({"type":"string","enum":["summary","decision","request"]})
}
fn read_schema() -> serde_json::Value {
json!({ json!({
"type":"object", "type":"object",
"additionalProperties": false, "additionalProperties": false,
"required":["kind"],
"properties":{ "properties":{
"kind": kind_schema(),
"slug":{"type":["string","null"]},
"offset":{"type":["integer","null"],"minimum":0}, "offset":{"type":["integer","null"],"minimum":0},
"limit":{"type":["integer","null"],"minimum":0} "limit":{"type":["integer","null"],"minimum":0}
} }
}) })
} }
fn write_schema() -> serde_json::Value { fn document_update_schema() -> serde_json::Value {
json!({ json!({
"type":"object", "type":"object",
"additionalProperties": false, "additionalProperties": false,
"required":["kind","content"], "required":["body_md"],
"properties":{ "properties":{
"kind": kind_schema(), "body_md":{"type":"string"}
"slug":{"type":["string","null"]},
"content":{"type":"string"}
}
})
}
fn edit_schema() -> serde_json::Value {
json!({
"type":"object",
"additionalProperties": false,
"required":["kind","old_string","new_string"],
"properties":{
"kind": kind_schema(),
"slug":{"type":["string","null"]},
"old_string":{"type":"string"},
"new_string":{"type":"string"},
"replace_all":{"type":"boolean","default":false}
}
})
}
fn delete_schema() -> serde_json::Value {
json!({
"type":"object",
"additionalProperties": false,
"required":["kind"],
"properties":{
"kind": kind_schema(),
"slug":{"type":["string","null"]}
} }
}) })
} }
@ -441,6 +385,12 @@ mod tests {
)); ));
assert!(names.contains(&"MemoryQuery".to_string())); assert!(names.contains(&"MemoryQuery".to_string()));
assert!(names.contains(&"MemoryReadDocument".to_string()));
assert!(names.contains(&"MemoryUpdateDocument".to_string()));
assert!(!names.contains(&"MemoryRead".to_string()));
assert!(!names.contains(&"MemoryWrite".to_string()));
assert!(!names.contains(&"MemoryEdit".to_string()));
assert!(!names.contains(&"MemoryDelete".to_string()));
assert!(!names.contains(&"MemoryStagingList".to_string())); assert!(!names.contains(&"MemoryStagingList".to_string()));
assert!(!names.contains(&"MemoryStagingRead".to_string())); assert!(!names.contains(&"MemoryStagingRead".to_string()));
assert!(!names.contains(&"MemoryStagingClose".to_string())); assert!(!names.contains(&"MemoryStagingClose".to_string()));
@ -454,6 +404,8 @@ mod tests {
)); ));
assert!(names.contains(&"MemoryQuery".to_string())); assert!(names.contains(&"MemoryQuery".to_string()));
assert!(names.contains(&"MemoryReadDocument".to_string()));
assert!(names.contains(&"MemoryUpdateDocument".to_string()));
assert!(names.contains(&"MemoryStagingList".to_string())); assert!(names.contains(&"MemoryStagingList".to_string()));
assert!(names.contains(&"MemoryStagingRead".to_string())); assert!(names.contains(&"MemoryStagingRead".to_string()));
assert!(names.contains(&"MemoryStagingClose".to_string())); assert!(names.contains(&"MemoryStagingClose".to_string()));

View File

@ -201,10 +201,8 @@ impl<'a> SystemPromptContext<'a> {
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct ToolCapabilities { struct ToolCapabilities {
memory_query: bool, memory_query: bool,
memory_read: bool, memory_read_document: bool,
memory_write: bool, memory_update_document: bool,
memory_edit: bool,
memory_delete: bool,
worker_spawn: bool, worker_spawn: bool,
worker_send: bool, worker_send: bool,
worker_read_output: bool, worker_read_output: bool,
@ -219,10 +217,8 @@ impl ToolCapabilities {
for name in names { for name in names {
match name.as_str() { match name.as_str() {
"MemoryQuery" => capabilities.memory_query = true, "MemoryQuery" => capabilities.memory_query = true,
"MemoryRead" => capabilities.memory_read = true, "MemoryReadDocument" => capabilities.memory_read_document = true,
"MemoryWrite" => capabilities.memory_write = true, "MemoryUpdateDocument" => capabilities.memory_update_document = true,
"MemoryEdit" => capabilities.memory_edit = true,
"MemoryDelete" => capabilities.memory_delete = true,
"SpawnWorker" => capabilities.worker_spawn = true, "SpawnWorker" => capabilities.worker_spawn = true,
"SendToWorker" => capabilities.worker_send = true, "SendToWorker" => capabilities.worker_send = true,
"ReadWorkerOutput" => capabilities.worker_read_output = true, "ReadWorkerOutput" => capabilities.worker_read_output = true,
@ -236,11 +232,7 @@ impl ToolCapabilities {
} }
fn memory_records(self) -> bool { fn memory_records(self) -> bool {
self.memory_query self.memory_query || self.memory_read_document || self.memory_update_document
|| self.memory_read
|| self.memory_write
|| self.memory_edit
|| self.memory_delete
} }
fn memory_any(self) -> bool { fn memory_any(self) -> bool {
@ -248,7 +240,7 @@ impl ToolCapabilities {
} }
fn memory_mutation(self) -> bool { fn memory_mutation(self) -> bool {
self.memory_write || self.memory_edit || self.memory_delete self.memory_update_document
} }
fn worker_management(self) -> bool { fn worker_management(self) -> bool {
@ -265,10 +257,14 @@ impl ToolCapabilities {
map.insert("memory_any", Value::from(self.memory_any())); map.insert("memory_any", Value::from(self.memory_any()));
map.insert("memory_records", Value::from(self.memory_records())); map.insert("memory_records", Value::from(self.memory_records()));
map.insert("memory_query", Value::from(self.memory_query)); map.insert("memory_query", Value::from(self.memory_query));
map.insert("memory_read", Value::from(self.memory_read)); map.insert(
map.insert("memory_write", Value::from(self.memory_write)); "memory_read_document",
map.insert("memory_edit", Value::from(self.memory_edit)); Value::from(self.memory_read_document),
map.insert("memory_delete", Value::from(self.memory_delete)); );
map.insert(
"memory_update_document",
Value::from(self.memory_update_document),
);
map.insert("memory_mutation", Value::from(self.memory_mutation())); map.insert("memory_mutation", Value::from(self.memory_mutation()));
map.insert("worker_management", Value::from(self.worker_management())); map.insert("worker_management", Value::from(self.worker_management()));
Value::from(map) Value::from(map)
@ -407,16 +403,10 @@ mod tests {
} }
fn memory_tool_names() -> Vec<String> { fn memory_tool_names() -> Vec<String> {
[ ["MemoryQuery", "MemoryReadDocument", "MemoryUpdateDocument"]
"MemoryQuery", .into_iter()
"MemoryRead", .map(String::from)
"MemoryWrite", .collect()
"MemoryEdit",
"MemoryDelete",
]
.into_iter()
.map(String::from)
.collect()
} }
fn worker_management_tool_names() -> Vec<String> { fn worker_management_tool_names() -> Vec<String> {
@ -463,9 +453,9 @@ mod tests {
assert!(rendered.contains("### Memory")); assert!(rendered.contains("### Memory"));
assert!(rendered.contains("small targeted `MemoryQuery`")); assert!(rendered.contains("small targeted `MemoryQuery`"));
assert!(rendered.contains("Strong lookup triggers include")); assert!(rendered.contains("Strong lookup triggers include"));
assert!(rendered.contains("MemoryRead(kind=summary)")); assert!(rendered.contains("MemoryReadDocument"));
assert!(rendered.contains("Do not query memory every turn")); assert!(rendered.contains("Do not query memory every turn"));
assert!(rendered.contains("MemoryWrite")); assert!(rendered.contains("MemoryUpdateDocument"));
assert!(rendered.contains("## Language")); assert!(rendered.contains("## Language"));
assert!(rendered.contains("`language`: `match the user's language")); assert!(rendered.contains("`language`: `match the user's language"));
// Trailing section must be present. // Trailing section must be present.
@ -508,15 +498,15 @@ mod tests {
.render(&ctx( .render(&ctx(
dir.path(), dir.path(),
&scope, &scope,
vec!["MemoryQuery".into(), "MemoryRead".into()], vec!["MemoryQuery".into(), "MemoryReadDocument".into()],
None, None,
)) ))
.unwrap(); .unwrap();
assert!(rendered.contains("### Memory")); assert!(rendered.contains("### Memory"));
assert!(rendered.contains("small targeted `MemoryQuery`")); assert!(rendered.contains("small targeted `MemoryQuery`"));
assert!(rendered.contains("MemoryRead(kind=summary)")); assert!(rendered.contains("MemoryReadDocument"));
assert!(!rendered.contains("MemoryWrite")); assert!(!rendered.contains("MemoryUpdateDocument"));
assert!(!rendered.contains("MemoryEdit")); assert!(!rendered.contains("MemoryEdit"));
assert!(!rendered.contains("MemoryDelete")); assert!(!rendered.contains("MemoryDelete"));
} }
@ -555,7 +545,7 @@ mod tests {
.render(&ctx( .render(&ctx(
dir.path(), dir.path(),
&scope, &scope,
vec!["Read".into(), "Edit".into(), "MemoryRead".into()], vec!["Read".into(), "Edit".into(), "MemoryReadDocument".into()],
None, None,
)) ))
.unwrap(); .unwrap();

View File

@ -10,6 +10,7 @@ pub mod companion;
pub mod config; pub mod config;
pub mod hosts; pub mod hosts;
pub mod identity; pub mod identity;
pub mod memory_backend;
pub mod memory_staging; pub mod memory_staging;
pub mod observation; pub mod observation;
pub mod profile_settings; pub mod profile_settings;

View File

@ -0,0 +1,485 @@
use memory::backend::{
MemoryBackendAckOutput, MemoryBackendOperation, MemoryBackendOperationResult,
MemoryDocumentReadOperation, MemoryDocumentUpdateOperation, MemoryQueryOperation,
MemoryReadOperation, MemoryStagingCloseAction, MemoryStagingCloseOperation,
MemoryStagingListOperation, MemoryStagingReadOperation, MemoryStagingWriteOutput,
MemoryToolOutput,
};
use memory::extract::{ExtractedCandidate, StagingEvidence, StagingRecord};
use memory::schema::{SourceEvidenceRef, SourceRef};
use serde_json::json;
use uuid::Uuid;
use crate::authority::MemoryAuthority;
use crate::{Error, Result};
const DEFAULT_READ_LIMIT: usize = 2000;
const MAX_STAGING_LIST_LIMIT: usize = 100;
pub fn execute_memory_backend_operation_with_authority<A: MemoryAuthority>(
authority: &A,
operation: MemoryBackendOperation,
) -> Result<MemoryBackendOperationResult> {
match operation {
MemoryBackendOperation::Query(operation) => {
execute_query(authority, operation).map(MemoryBackendOperationResult::ToolOutput)
}
MemoryBackendOperation::ReadDocument(operation) => execute_read_document(authority, operation)
.map(MemoryBackendOperationResult::ToolOutput),
MemoryBackendOperation::UpdateDocument(operation) => {
execute_update_document(authority, operation).map(MemoryBackendOperationResult::ToolOutput)
}
MemoryBackendOperation::ResidentSummary(_operation) => Ok(
MemoryBackendOperationResult::ToolOutput(execute_resident_summary(authority)?),
),
MemoryBackendOperation::AppendAudit(_operation) => Ok(
MemoryBackendOperationResult::Acknowledged(MemoryBackendAckOutput {
summary: "memory audit event accepted by Workspace authority".to_string(),
}),
),
MemoryBackendOperation::StageCandidate(operation) => {
let id = stage_candidate(
authority,
operation.source,
operation.extract_run_id,
operation.candidate,
operation.evidence,
operation.source_refs,
)?;
Ok(MemoryBackendOperationResult::StagingWritten(
MemoryStagingWriteOutput {
staging_count: 1,
staging_ids: vec![id],
},
))
}
MemoryBackendOperation::StageExtracted(operation) => {
let extract_run_id = Uuid::now_v7().to_string();
let mut ids = Vec::with_capacity(operation.payload.candidates.len());
for candidate in operation.payload.candidates {
ids.push(stage_candidate(
authority,
operation.source.clone(),
extract_run_id.clone(),
candidate,
Vec::new(),
Vec::new(),
)?);
}
Ok(MemoryBackendOperationResult::StagingWritten(
MemoryStagingWriteOutput {
staging_count: ids.len(),
staging_ids: ids,
},
))
}
MemoryBackendOperation::StagingList(operation) => execute_staging_list(authority, operation)
.map(MemoryBackendOperationResult::ToolOutput),
MemoryBackendOperation::StagingRead(operation) => execute_staging_read(authority, operation)
.map(MemoryBackendOperationResult::ToolOutput),
MemoryBackendOperation::StagingClose(operation) => execute_staging_close(authority, operation)
.map(MemoryBackendOperationResult::ToolOutput),
MemoryBackendOperation::Read(operation) => execute_legacy_read(authority, operation)
.map(MemoryBackendOperationResult::ToolOutput),
MemoryBackendOperation::Write(_)
| MemoryBackendOperation::Edit(_)
| MemoryBackendOperation::Delete(_) => Err(Error::Store(
"legacy summary/decision/request Memory mutation operations are not supported by Workspace authority; use MemoryUpdateDocument".to_string(),
)),
}
}
fn execute_resident_summary<A: MemoryAuthority>(authority: &A) -> Result<MemoryToolOutput> {
let document = authority.ensure_memory_document()?;
if document.body_md.trim().is_empty() {
Ok(MemoryToolOutput {
summary: "resident memory summary unavailable".to_string(),
content: None,
})
} else {
Ok(MemoryToolOutput {
summary: "resident memory document collected".to_string(),
content: Some(document.body_md),
})
}
}
fn execute_query<A: MemoryAuthority>(
authority: &A,
operation: MemoryQueryOperation,
) -> Result<MemoryToolOutput> {
let document = authority.ensure_memory_document()?;
let query = operation
.query
.as_deref()
.map(str::trim)
.filter(|query| !query.is_empty());
match query {
Some(query) => {
if let Some(body) = excerpt_matching(&document.body_md, query) {
Ok(MemoryToolOutput {
summary: format!("Memory document matched {query:?}"),
content: Some(body),
})
} else {
Ok(MemoryToolOutput {
summary: format!("No memory document content matched {query:?}"),
content: None,
})
}
}
None => Ok(MemoryToolOutput {
summary: "Memory document overview".to_string(),
content: Some(excerpt_head(&document.body_md, 120)),
}),
}
}
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>(
authority: &A,
operation: MemoryDocumentReadOperation,
) -> Result<MemoryToolOutput> {
let document = authority.ensure_memory_document()?;
let offset = operation.offset.unwrap_or(0);
let limit = operation.limit.unwrap_or(DEFAULT_READ_LIMIT);
let lines: Vec<&str> = document.body_md.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 Workspace memory document",
selected.lines().count()
),
content: Some(format!(
"total_lines={total} offset={offset} limit={limit}\n{selected}"
)),
})
}
fn execute_update_document<A: MemoryAuthority>(
authority: &A,
operation: MemoryDocumentUpdateOperation,
) -> Result<MemoryToolOutput> {
if operation.body_md.trim().is_empty() {
return Err(Error::Store(
"memory document body_md must not be empty".to_string(),
));
}
let document = authority.update_memory_document(&operation.body_md)?;
Ok(MemoryToolOutput {
summary: format!(
"Updated Workspace memory document ({} bytes)",
document.body_md.len()
),
content: Some(format!("updated_at={}", document.updated_at)),
})
}
fn stage_candidate<A: MemoryAuthority>(
authority: &A,
source: SourceRef,
extract_run_id: String,
candidate: ExtractedCandidate,
evidence: Vec<StagingEvidence>,
source_refs: Vec<SourceEvidenceRef>,
) -> Result<String> {
let candidate_id = Uuid::now_v7().to_string();
let record = StagingRecord::from_candidate(
candidate_id.clone(),
extract_run_id,
source,
candidate,
evidence,
source_refs,
);
let raw_json = serde_json::to_string_pretty(&record)
.map_err(|err| Error::Store(format!("serialize memory staging record: {err}")))?;
authority.upsert_memory_staging_record(&candidate_id, &raw_json, None)?;
Ok(candidate_id)
}
fn execute_staging_list<A: MemoryAuthority>(
authority: &A,
operation: MemoryStagingListOperation,
) -> Result<MemoryToolOutput> {
let limit = operation.limit.unwrap_or(20).min(MAX_STAGING_LIST_LIMIT);
let entries = authority.list_memory_staging_records(limit)?;
let records = entries
.iter()
.map(|entry| {
staging_summary_json(
&entry.candidate_id,
&entry.raw_json,
entry.source_path.as_deref(),
)
})
.collect::<Result<Vec<_>>>()?;
Ok(MemoryToolOutput {
summary: format!(
"Listed {} memory staging candidate(s) from Workspace authority",
records.len()
),
content: Some(
serde_json::to_string_pretty(&records)
.map_err(|err| Error::Store(format!("serialize memory staging list: {err}")))?,
),
})
}
fn execute_staging_read<A: MemoryAuthority>(
authority: &A,
operation: MemoryStagingReadOperation,
) -> Result<MemoryToolOutput> {
let entry = authority.memory_staging_record(&operation.candidate_id)?;
let raw: serde_json::Value = serde_json::from_str(&entry.raw_json)
.map_err(|err| Error::Store(format!("decode memory staging record: {err}")))?;
Ok(MemoryToolOutput {
summary: format!("Read memory staging candidate {}", entry.candidate_id),
content: Some(
serde_json::to_string_pretty(&raw)
.map_err(|err| Error::Store(format!("serialize memory staging record: {err}")))?,
),
})
}
fn execute_staging_close<A: MemoryAuthority>(
authority: &A,
operation: MemoryStagingCloseOperation,
) -> Result<MemoryToolOutput> {
if operation.reason.trim().is_empty() {
return Err(Error::Store("reason is required".to_string()));
}
let affected_refs_json = serde_json::to_string(&operation.affected_memory)
.map_err(|err| Error::Store(format!("serialize affected memory refs: {err}")))?;
let action = staging_close_action_name(&operation.action);
let resolution = authority.close_memory_staging_record(
&operation.candidate_id,
action,
&operation.reason,
&affected_refs_json,
)?;
Ok(MemoryToolOutput {
summary: format!("Closed memory staging candidate {}", resolution.candidate_id),
content: Some(
serde_json::to_string_pretty(&json!({
"candidate_id": resolution.candidate_id,
"action": resolution.action,
"reason": resolution.reason,
"affected_refs": serde_json::from_str::<serde_json::Value>(&resolution.affected_refs_json).unwrap_or_else(|_| json!([])),
"resolved_at": resolution.resolved_at,
"record_authority": resolution.record_source,
}))
.map_err(|err| Error::Store(format!("serialize memory staging resolution: {err}")))?,
),
})
}
fn staging_close_action_name(action: &MemoryStagingCloseAction) -> &'static str {
match action {
MemoryStagingCloseAction::Applied => "applied",
MemoryStagingCloseAction::Discarded => "discarded",
MemoryStagingCloseAction::Invalid => "invalid",
MemoryStagingCloseAction::Duplicate => "duplicate",
MemoryStagingCloseAction::AlreadyCovered => "already_covered",
}
}
fn staging_summary_json(
candidate_id: &str,
raw_json: &str,
source_path: Option<&str>,
) -> Result<serde_json::Value> {
let raw: serde_json::Value = serde_json::from_str(raw_json).map_err(|err| {
Error::Store(format!(
"decode memory staging record {candidate_id}: {err}"
))
})?;
Ok(json!({
"candidate_id": candidate_id,
"bytes": raw_json.len(),
"source_path": source_path,
"source": raw.get("source").cloned().unwrap_or(serde_json::Value::Null),
"kind": raw.get("kind").cloned().unwrap_or(serde_json::Value::Null),
"claim": raw.get("claim").cloned().unwrap_or(serde_json::Value::Null),
"why_useful": raw.get("why_useful").cloned().unwrap_or(serde_json::Value::Null),
"staleness": raw.get("staleness").cloned().unwrap_or(serde_json::Value::Null),
"evidence_count": raw.get("evidence").and_then(|value| value.as_array()).map_or(0, Vec::len),
"source_ref_count": raw.get("source_refs").and_then(|value| value.as_array()).map_or(0, Vec::len),
}))
}
fn excerpt_head(body: &str, max_lines: usize) -> String {
let mut lines = body.lines().take(max_lines).collect::<Vec<_>>().join("\n");
if body.lines().count() > max_lines {
lines.push_str("\n... [truncated]");
}
lines
}
fn excerpt_matching(body: &str, query: &str) -> Option<String> {
let query_lower = query.to_lowercase();
let lines = body.lines().collect::<Vec<_>>();
let index = lines
.iter()
.position(|line| line.to_lowercase().contains(&query_lower))?;
let start = index.saturating_sub(3);
let end = (index + 4).min(lines.len());
Some(
lines[start..end]
.iter()
.enumerate()
.map(|(offset, line)| format!("{:>6}\t{}", start + offset + 1, line))
.collect::<Vec<_>>()
.join("\n"),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authority::SqliteWorkspaceAuthority;
use memory::backend::{
MemoryDocumentReadOperation, MemoryDocumentUpdateOperation, MemoryStagingCloseAction,
MemoryStagingCloseOperation, MemoryStagingListOperation,
};
use memory::extract::{CandidateKind, ExtractedCandidate};
use memory::schema::SourceRef;
use tempfile::TempDir;
fn authority(temp: &TempDir) -> SqliteWorkspaceAuthority {
let db_path = temp.path().join("workspace.sqlite3");
let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace").unwrap();
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO workspaces (
workspace_id, owner_account_id, display_name, state, created_at, updated_at
) VALUES (?1, NULL, ?2, ?3, ?4, ?4)",
rusqlite::params!["workspace", "Workspace", "active", "2026-01-01T00:00:00Z"],
)
.unwrap();
authority
}
fn source() -> SourceRef {
SourceRef {
segment_id: "segment-1".into(),
range: [1, 2],
}
}
fn candidate() -> ExtractedCandidate {
ExtractedCandidate {
kind: CandidateKind::Decision,
claim: "Use SQLite memory authority".into(),
why_useful: "Keeps Worker tools away from .yoi/memory filesystem authority".into(),
staleness: None,
evidence_ids: Vec::new(),
}
}
#[test]
fn document_operations_use_workspace_authority() {
let temp = TempDir::new().unwrap();
let authority = authority(&temp);
execute_memory_backend_operation_with_authority(
&authority,
MemoryBackendOperation::UpdateDocument(MemoryDocumentUpdateOperation {
body_md: "# Memory\n\nSQLite backed body".into(),
}),
)
.unwrap();
let result = execute_memory_backend_operation_with_authority(
&authority,
MemoryBackendOperation::ReadDocument(MemoryDocumentReadOperation {
offset: None,
limit: None,
}),
)
.unwrap();
let MemoryBackendOperationResult::ToolOutput(output) = result else {
panic!("expected tool output")
};
assert!(output.content.unwrap().contains("SQLite backed body"));
}
#[test]
fn staging_close_moves_record_to_workspace_resolution() {
let temp = TempDir::new().unwrap();
let authority = authority(&temp);
let result = execute_memory_backend_operation_with_authority(
&authority,
MemoryBackendOperation::StageCandidate(
memory::backend::MemoryStageCandidateOperation {
source: source(),
extract_run_id: "run-1".into(),
candidate: candidate(),
evidence: Vec::new(),
source_refs: Vec::new(),
},
),
)
.unwrap();
let MemoryBackendOperationResult::StagingWritten(written) = result else {
panic!("expected staging write")
};
assert_eq!(written.staging_count, 1);
let list = execute_memory_backend_operation_with_authority(
&authority,
MemoryBackendOperation::StagingList(MemoryStagingListOperation { limit: None }),
)
.unwrap();
let MemoryBackendOperationResult::ToolOutput(list) = list else {
panic!("expected list")
};
assert!(
list.content
.unwrap()
.contains("Use SQLite memory authority")
);
execute_memory_backend_operation_with_authority(
&authority,
MemoryBackendOperation::StagingClose(MemoryStagingCloseOperation {
candidate_id: written.staging_ids[0].clone(),
action: MemoryStagingCloseAction::Applied,
reason: "merged into document".into(),
affected_memory: Vec::new(),
}),
)
.unwrap();
assert_eq!(authority.list_memory_staging_records(10).unwrap().len(), 0);
assert_eq!(
authority.list_memory_staging_resolutions(10).unwrap().len(),
1
);
}
}

View File

@ -4,6 +4,9 @@ use memory::extract::StagingRecord;
use memory::schema::{SourceEvidenceRef, SourceRef}; use memory::schema::{SourceEvidenceRef, SourceRef};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::Result;
use crate::authority::MemoryAuthority;
const DEFAULT_MEMORY_STAGING_LIMIT: usize = 100; const DEFAULT_MEMORY_STAGING_LIMIT: usize = 100;
const MAX_MEMORY_STAGING_LIMIT: usize = 500; const MAX_MEMORY_STAGING_LIMIT: usize = 500;
@ -88,6 +91,48 @@ pub fn list_memory_staging(
} }
} }
pub fn list_memory_staging_from_authority<A: MemoryAuthority>(
authority: &A,
requested_limit: Option<usize>,
) -> Result<MemoryStagingListResponse> {
let limit = requested_limit
.unwrap_or(DEFAULT_MEMORY_STAGING_LIMIT)
.min(MAX_MEMORY_STAGING_LIMIT);
let entries = authority.list_memory_staging_records(MAX_MEMORY_STAGING_LIMIT + 1)?;
let fetched_count = entries.len();
let mut invalid_count = 0usize;
let mut total_valid_count = 0usize;
let mut valid_items = Vec::with_capacity(fetched_count.min(limit));
for entry in entries {
let record = match serde_json::from_str::<StagingRecord>(&entry.raw_json) {
Ok(record) => record,
Err(_) => {
invalid_count += 1;
continue;
}
};
total_valid_count += 1;
if valid_items.len() < limit {
valid_items.push(MemoryStagingEntrySummary {
id: entry.candidate_id,
byte_len: entry.raw_json.len() as u64,
record: memory_staging_record_summary(record),
});
}
}
let returned_count = valid_items.len();
Ok(MemoryStagingListResponse {
limit,
returned_count,
total_valid_count,
invalid_count,
truncated: total_valid_count > returned_count || fetched_count > MAX_MEMORY_STAGING_LIMIT,
order: "uuidv7_ascending".to_string(),
record_authority: "sqlite_workspace_authority.memory_staging".to_string(),
items: valid_items,
})
}
fn memory_staging_entry_summary(entry: StagingEntry) -> MemoryStagingEntrySummary { fn memory_staging_entry_summary(entry: StagingEntry) -> MemoryStagingEntrySummary {
MemoryStagingEntrySummary { MemoryStagingEntrySummary {
id: entry.id.to_string(), id: entry.id.to_string(),

View File

@ -14,7 +14,7 @@ use chrono::{Duration, SecondsFormat, Utc};
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use memory::backend::{ use memory::backend::{
MemoryBackendHttpResponse, MemoryBackendOperation, MemoryConsolidateStagingOperation, MemoryBackendHttpResponse, MemoryBackendOperation, MemoryConsolidateStagingOperation,
MemoryConsolidationOutput, execute_memory_backend_operation, MemoryConsolidationOutput,
}; };
use protocol::stream::{decode_method, encode_event}; use protocol::stream::{decode_method, encode_event};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -58,7 +58,8 @@ use crate::hosts::{
WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerWorkspaceSummary, WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerWorkspaceSummary,
}; };
use crate::identity::WorkspaceIdentity; use crate::identity::WorkspaceIdentity;
use crate::memory_staging::{MemoryStagingListResponse, list_memory_staging}; use crate::memory_backend::execute_memory_backend_operation_with_authority;
use crate::memory_staging::{MemoryStagingListResponse, list_memory_staging_from_authority};
use crate::observation::{ use crate::observation::{
BackendObservationProxy, ObservationProxyError, RuntimeObservationClient, BackendObservationProxy, ObservationProxyError, RuntimeObservationClient,
RuntimeObservationSource, RuntimeObservationSourceConfig, RuntimeObservationSource, RuntimeObservationSourceConfig,
@ -1458,9 +1459,10 @@ async fn scoped_list_memory_staging(
Query(query): Query<MemoryStagingQuery>, Query(query): Query<MemoryStagingQuery>,
) -> ApiResult<Json<MemoryStagingListResponse>> { ) -> ApiResult<Json<MemoryStagingListResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
let memory_config = manifest::MemoryConfig::default(); Ok(Json(list_memory_staging_from_authority(
let layout = memory::WorkspaceLayout::resolve(&memory_config, &api.config.workspace_root); &api.authority,
Ok(Json(list_memory_staging(&layout, query.limit))) query.limit,
)?))
} }
async fn scoped_memory_backend_operation( async fn scoped_memory_backend_operation(
@ -1469,9 +1471,8 @@ async fn scoped_memory_backend_operation(
Json(operation): Json<MemoryBackendOperation>, Json(operation): Json<MemoryBackendOperation>,
) -> ApiResult<Json<MemoryBackendHttpResponse>> { ) -> ApiResult<Json<MemoryBackendHttpResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
let memory_config = manifest::MemoryConfig::default(); let response = match execute_memory_backend_operation_with_authority(&api.authority, operation)
let layout = memory::WorkspaceLayout::resolve(&memory_config, &api.config.workspace_root); {
let response = match execute_memory_backend_operation(&layout, operation) {
Ok(result) => MemoryBackendHttpResponse::Ok { result }, Ok(result) => MemoryBackendHttpResponse::Ok { result },
Err(error) => MemoryBackendHttpResponse::Error { Err(error) => MemoryBackendHttpResponse::Error {
message: sanitize_backend_error(&error.to_string()), message: sanitize_backend_error(&error.to_string()),

View File

@ -17,10 +17,10 @@ Use memory proactively when the request may depend on prior project decisions, h
{% if tool_capabilities.memory_query %}Prefer a small targeted `MemoryQuery` before relying on vague recollection. {% if tool_capabilities.memory_query %}Prefer a small targeted `MemoryQuery` before relying on vague recollection.
{% endif %} {% endif %}
Strong lookup triggers include: the user says "recently", "previously", "that decision", "the ticket", "why", "policy", or "workflow"; you are about to make a design recommendation; you are reviewing, merging, closing, or rescoping a work item; or you are about to assert project history from memory. Strong lookup triggers include: the user says "recently", "previously", "that decision", "the ticket", "why", "policy", or "workflow"; you are about to make a design recommendation; you are reviewing, merging, closing, or rescoping a work item; or you are about to assert project history from memory.
{% if tool_capabilities.memory_read %} {% if tool_capabilities.memory_read_document %}
Use `MemoryRead(kind=summary)` for the full memory summary, and `MemoryRead` on returned slugs when excerpts are insufficient. Use `MemoryReadDocument` when the full Workspace memory document is needed or query excerpts are insufficient.
{% endif %} {% endif %}
Resident memory is helpful context but may be stale; current user instructions, repository files, tickets, git history, and session logs are authoritative for exact current state. Resident memory is helpful context but may be stale; current user instructions, repository files, tickets, git history, and session logs are authoritative for exact current state.
Do not query memory every turn or mechanically. Skip memory lookup for purely local facts answered by current repository files, command output, or current user instructions. Do not query memory every turn or mechanically. Skip memory lookup for purely local facts answered by current repository files, command output, or current user instructions.
{% if tool_capabilities.memory_mutation %}Normally prefer read/query tools; use available mutation tools ({% if tool_capabilities.memory_write %}`MemoryWrite`{% endif %}{% if tool_capabilities.memory_edit %}{% if tool_capabilities.memory_write %}, {% endif %}`MemoryEdit`{% endif %}{% if tool_capabilities.memory_delete %}{% if tool_capabilities.memory_write or tool_capabilities.memory_edit %}, {% endif %}`MemoryDelete`{% endif %}) only when explicitly asked or in a memory maintenance worker. {% if tool_capabilities.memory_mutation %}Normally prefer read/query tools; use `MemoryUpdateDocument` only when explicitly asked or in a memory maintenance worker.
{% endif %}{% endif %} {% endif %}{% endif %}

View File

@ -8,8 +8,8 @@ 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` / `MemoryRead` to compare against durable Memory. 3. Use `MemoryQuery` / `MemoryReadDocument` to compare against durable Memory.
4. If the candidate should change durable Memory, use `MemoryWrite`, `MemoryEdit`, or `MemoryDelete` first. 4. If the candidate should change durable Memory, update the single durable Markdown document with `MemoryUpdateDocument` first.
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.
`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.