feat: Append the memory system prompts (readonly or r/w) to the system prompt when applicable
This commit is contained in:
+184
-1
@@ -2,10 +2,14 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{paths, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME};
|
||||
|
||||
pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000;
|
||||
pub const DEFAULT_MEMORY_CAP_WITHOUT_TOOLS: usize = 12_000;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WorkspaceMemory {
|
||||
Structured { workspace_root: PathBuf, dir: PathBuf },
|
||||
@@ -156,6 +160,73 @@ impl MemoryStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_memory_section(
|
||||
store: &MemoryStore,
|
||||
with_tools: bool,
|
||||
cap: usize,
|
||||
) -> Result<Option<String>> {
|
||||
let global_index = store.load_global_index()?;
|
||||
let workspace_index = store.load_workspace_index()?;
|
||||
|
||||
if global_index.is_none() && workspace_index.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut buf = String::from("<memory>\n");
|
||||
let mut consumed = 0usize;
|
||||
|
||||
if let Some(s) = &global_index {
|
||||
buf.push_str("<global_index>\n");
|
||||
buf.push_str(s);
|
||||
buf.push_str("\n</global_index>\n");
|
||||
consumed += s.chars().count();
|
||||
}
|
||||
|
||||
if let Some(s) = &workspace_index {
|
||||
buf.push_str("<workspace_index>\n");
|
||||
buf.push_str(s);
|
||||
buf.push_str("\n</workspace_index>\n");
|
||||
consumed += s.chars().count();
|
||||
}
|
||||
|
||||
if consumed > cap {
|
||||
warn!(
|
||||
"memory indexes ({} chars) exceed cap ({} chars); injecting fully - \
|
||||
consider raising memory_cap_* in config or shrinking MEMORY.md",
|
||||
consumed,
|
||||
cap
|
||||
);
|
||||
}
|
||||
|
||||
if !with_tools {
|
||||
let mut budget = cap.saturating_sub(consumed);
|
||||
let mut files = store.list_files()?;
|
||||
files.sort_by(|a, b| a.frontmatter.name.cmp(&b.frontmatter.name));
|
||||
let mut omitted = 0usize;
|
||||
for f in files {
|
||||
let needed = f.body.chars().count() + 50;
|
||||
if needed > budget {
|
||||
omitted += 1;
|
||||
continue;
|
||||
}
|
||||
buf.push_str(&format!("<file name=\"{}\">\n", f.frontmatter.name));
|
||||
buf.push_str(&f.body);
|
||||
buf.push_str("\n</file>\n");
|
||||
budget = budget.saturating_sub(needed);
|
||||
}
|
||||
|
||||
if omitted > 0 {
|
||||
buf.push_str(&format!(
|
||||
"<!-- {} memory file(s) omitted; enable function calling for full access -->\n",
|
||||
omitted
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
buf.push_str("</memory>");
|
||||
Ok(Some(buf))
|
||||
}
|
||||
|
||||
fn collect_md_files(dir: &Path, out: &mut Vec<MemoryFile>) -> Result<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
@@ -170,7 +241,7 @@ fn collect_md_files(dir: &Path, out: &mut Vec<MemoryFile>) -> Result<()> {
|
||||
|
||||
match MemoryFile::load(&path) {
|
||||
Ok(f) => out.push(f),
|
||||
Err(e) => log::warn!("skip malformed memory file {}: {}", path.display(), e),
|
||||
Err(e) => warn!("skip malformed memory file {}: {}", path.display(), e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,4 +315,116 @@ mod tests {
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_memory_section_returns_none_when_no_memory_exists() {
|
||||
let root = temp_root("none");
|
||||
let workspace = root.join("ws");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("global"),
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
|
||||
assert!(build_memory_section(&store, true, 6_000).unwrap().is_none());
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_memory_section_injects_only_indexes_with_tools_on() {
|
||||
let root = temp_root("indexes_only");
|
||||
let workspace = root.join("ws");
|
||||
let structured = workspace
|
||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
||||
.join(MEMORY_DIR_NAME);
|
||||
fs::create_dir_all(&structured).unwrap();
|
||||
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "workspace-index-content").unwrap();
|
||||
fs::write(
|
||||
structured.join("foo.md"),
|
||||
"---\nname: foo\n---\nfoo body that should not appear\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("global"),
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
|
||||
let section = build_memory_section(&store, true, 6_000)
|
||||
.unwrap()
|
||||
.expect("memory section should exist");
|
||||
assert!(section.contains("workspace-index-content"));
|
||||
assert!(!section.contains("foo body that should not appear"));
|
||||
assert!(section.starts_with("<memory>"));
|
||||
assert!(section.ends_with("</memory>"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_memory_section_injects_drill_bodies_alphabetically_without_tools() {
|
||||
let root = temp_root("drill_bodies");
|
||||
let workspace = root.join("ws");
|
||||
let structured = workspace
|
||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
||||
.join(MEMORY_DIR_NAME);
|
||||
fs::create_dir_all(&structured).unwrap();
|
||||
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
|
||||
fs::write(
|
||||
structured.join("zebra.md"),
|
||||
"---\nname: zebra\n---\nzebra body\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
structured.join("alpha.md"),
|
||||
"---\nname: alpha\n---\nalpha body\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("global"),
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
|
||||
let section = build_memory_section(&store, false, 6_000)
|
||||
.unwrap()
|
||||
.expect("memory section should exist");
|
||||
let alpha_pos = section.find("alpha body").expect("alpha body missing");
|
||||
let zebra_pos = section.find("zebra body").expect("zebra body missing");
|
||||
assert!(alpha_pos < zebra_pos, "drill bodies must be alphabetical");
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_memory_section_omits_drill_bodies_when_cap_exceeded() {
|
||||
let root = temp_root("cap");
|
||||
let workspace = root.join("ws");
|
||||
let structured = workspace
|
||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
||||
.join(MEMORY_DIR_NAME);
|
||||
fs::create_dir_all(&structured).unwrap();
|
||||
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
|
||||
let big_body = "x".repeat(200);
|
||||
fs::write(
|
||||
structured.join("big.md"),
|
||||
format!("---\nname: big\n---\n{}\n", big_body),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("global"),
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
|
||||
let section = build_memory_section(&store, false, 100)
|
||||
.unwrap()
|
||||
.expect("memory section should exist");
|
||||
assert!(!section.contains(&big_body));
|
||||
assert!(section.contains("memory file(s) omitted"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,37 @@ pub(crate) const DEFAULT_SKILL_INSTRUCTIONS: &str = indoc! {"
|
||||
complete to keep the context lean."
|
||||
};
|
||||
|
||||
pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS: &str = indoc! {"
|
||||
## Memory
|
||||
A persistent memory file system survives across sessions. The MEMORY.md content shown above is
|
||||
your always-on context; put universal facts (user identity, hard rules, binding feedback) directly
|
||||
in MEMORY.md so they appear on every turn. Drill files hold deeper, on-demand context.
|
||||
|
||||
Tools:
|
||||
- `memory__read(name)`: Read a specific drill file's full content.
|
||||
- `memory__write(name, content, scope)`: Create or replace a drill file (scope: 'global' | 'workspace').
|
||||
- `memory__list()`: See all known drill files and their metadata.
|
||||
- `memory__lint()`: Health-check memory for orphans, broken links, oversized files.
|
||||
|
||||
RULES:
|
||||
- Every interaction has two outputs: your answer AND any memory updates the conversation warrants.
|
||||
Don't let learnings evaporate into chat history.
|
||||
- When you create or modify a drill file, also update MEMORY.md so the index stays accurate.
|
||||
- Use [[wikilink]] notation in memory files to reference other memories by their `name:` slug.
|
||||
- NEVER write secrets, credentials, or API keys to memory — memory is plaintext on disk.
|
||||
Use coyote's Vault for secrets.
|
||||
- Keep individual drill files focused (under ~2K chars). Split large topics across linked files."
|
||||
};
|
||||
|
||||
pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS_READONLY: &str = indoc! {"
|
||||
## Memory (read-only)
|
||||
The memory content shown above persists across sessions. In this session it is READ-ONLY — the user
|
||||
maintains memory files manually outside the conversation.
|
||||
|
||||
Reference the memory content as authoritative context about the user and their workspace.
|
||||
Do not propose writing to memory or call any `memory__*` tools — they are unavailable."
|
||||
};
|
||||
|
||||
pub(in crate::config) const DEFAULT_TODO_INSTRUCTIONS: &str = indoc! {"
|
||||
## Task Tracking
|
||||
You have built-in task tracking tools. Use them to track your progress:
|
||||
|
||||
@@ -5,12 +5,7 @@ use super::skill_policy::SkillPolicy;
|
||||
use super::skill_registry::SkillRegistry;
|
||||
use super::todo::TodoList;
|
||||
use super::tool_scope::{McpRuntime, ToolScope};
|
||||
use super::{
|
||||
AGENTS_DIR_NAME, Agent, AgentVariables, AppConfig, AppState, AssetCategory, CREATE_TITLE_ROLE,
|
||||
Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, RIGHT_PROMPT, Role,
|
||||
RoleLike, SESSIONS_DIR_NAME, SUMMARIZATION_PROMPT, SUMMARY_CONTEXT_PROMPT, StateFlags,
|
||||
TEMP_ROLE_NAME, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, list_agents, paths,
|
||||
};
|
||||
use super::{AGENTS_DIR_NAME, Agent, AgentVariables, AppConfig, AppState, AssetCategory, CREATE_TITLE_ROLE, Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, RIGHT_PROMPT, Role, RoleLike, SESSIONS_DIR_NAME, SUMMARIZATION_PROMPT, SUMMARY_CONTEXT_PROMPT, StateFlags, TEMP_ROLE_NAME, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, list_agents, paths, memory};
|
||||
use super::{MessageContentToolCalls, prompts};
|
||||
use crate::client::{Model, ModelType, list_models};
|
||||
use crate::function::{
|
||||
@@ -46,7 +41,7 @@ use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::{env, fs};
|
||||
use super::memory::{WorkspaceMemory, MemoryStore};
|
||||
use super::memory::{WorkspaceMemory, MemoryStore, DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS};
|
||||
|
||||
pub struct AutoContinueConfig {
|
||||
pub enabled: bool,
|
||||
@@ -682,6 +677,35 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
if self.should_inject_memory()
|
||||
&& let Some(cwd) = env::current_dir().ok()
|
||||
{
|
||||
let store = MemoryStore::new(&cwd);
|
||||
let with_tools = self.should_register_memory_tools();
|
||||
let cap = if with_tools {
|
||||
app.memory_cap_with_tools
|
||||
.unwrap_or(DEFAULT_MEMORY_CAP_WITH_TOOLS)
|
||||
} else {
|
||||
app.memory_cap_without_tools
|
||||
.unwrap_or(DEFAULT_MEMORY_CAP_WITHOUT_TOOLS)
|
||||
};
|
||||
match memory::build_memory_section(&store, with_tools, cap) {
|
||||
Ok(Some(section)) => {
|
||||
let separator = if role.is_empty_prompt() { "" } else { "\n\n" };
|
||||
role.append_to_prompt(separator);
|
||||
role.append_to_prompt(§ion);
|
||||
role.append_to_prompt("\n\n");
|
||||
role.append_to_prompt(if with_tools {
|
||||
prompts::DEFAULT_MEMORY_INSTRUCTIONS
|
||||
} else {
|
||||
prompts::DEFAULT_MEMORY_INSTRUCTIONS_READONLY
|
||||
});
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => warn!("memory injection failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(self.skill_registry.effective_role(&role, &policy))
|
||||
}
|
||||
|
||||
@@ -3225,6 +3249,31 @@ mod tests {
|
||||
assert!(!Arc::ptr_eq(&ctx.app.config, &previous));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_config_app_some_false_disables_via_cascade() {
|
||||
let mut ctx = create_test_ctx();
|
||||
|
||||
ctx.update_app_config(|app| app.memory = Some(false));
|
||||
|
||||
assert!(
|
||||
!ctx.should_inject_memory(),
|
||||
"AppConfig.memory=Some(false) must disable memory regardless of on-disk content (this is the --no-memory CLI path)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_config_role_false_beats_app_true_in_cascade() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.update_app_config(|app| app.memory = Some(true));
|
||||
let role = Role::new("memory_off_role", "---\nmemory: false\n---\n");
|
||||
assert_eq!(role.memory(), Some(false), "metadata parser sanity check");
|
||||
ctx.role = Some(role);
|
||||
assert!(
|
||||
!ctx.should_inject_memory(),
|
||||
"Role::memory=Some(false) must win over AppConfig::memory=Some(true)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_role_obj_sets_role() {
|
||||
let mut ctx = create_test_ctx();
|
||||
|
||||
Reference in New Issue
Block a user