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 std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use log::warn;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::config::{paths, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME};
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum WorkspaceMemory {
|
pub enum WorkspaceMemory {
|
||||||
Structured { workspace_root: PathBuf, dir: PathBuf },
|
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<()> {
|
fn collect_md_files(dir: &Path, out: &mut Vec<MemoryFile>) -> Result<()> {
|
||||||
for entry in fs::read_dir(dir)? {
|
for entry in fs::read_dir(dir)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
@@ -170,7 +241,7 @@ fn collect_md_files(dir: &Path, out: &mut Vec<MemoryFile>) -> Result<()> {
|
|||||||
|
|
||||||
match MemoryFile::load(&path) {
|
match MemoryFile::load(&path) {
|
||||||
Ok(f) => out.push(f),
|
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);
|
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."
|
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! {"
|
pub(in crate::config) const DEFAULT_TODO_INSTRUCTIONS: &str = indoc! {"
|
||||||
## Task Tracking
|
## Task Tracking
|
||||||
You have built-in task tracking tools. Use them to track your progress:
|
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::skill_registry::SkillRegistry;
|
||||||
use super::todo::TodoList;
|
use super::todo::TodoList;
|
||||||
use super::tool_scope::{McpRuntime, ToolScope};
|
use super::tool_scope::{McpRuntime, ToolScope};
|
||||||
use super::{
|
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};
|
||||||
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::{MessageContentToolCalls, prompts};
|
use super::{MessageContentToolCalls, prompts};
|
||||||
use crate::client::{Model, ModelType, list_models};
|
use crate::client::{Model, ModelType, list_models};
|
||||||
use crate::function::{
|
use crate::function::{
|
||||||
@@ -46,7 +41,7 @@ use std::io::Write;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::{env, fs};
|
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 struct AutoContinueConfig {
|
||||||
pub enabled: bool,
|
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))
|
Ok(self.skill_registry.effective_role(&role, &policy))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3225,6 +3249,31 @@ mod tests {
|
|||||||
assert!(!Arc::ptr_eq(&ctx.app.config, &previous));
|
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]
|
#[test]
|
||||||
fn use_role_obj_sets_role() {
|
fn use_role_obj_sets_role() {
|
||||||
let mut ctx = create_test_ctx();
|
let mut ctx = create_test_ctx();
|
||||||
|
|||||||
Reference in New Issue
Block a user