From 03256db9139e6c09a984fdfb7100bf77c3ff1fb7 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 1 Jun 2026 07:45:06 +0900 Subject: [PATCH] memory: gate prompt guidance --- crates/pod/src/prompt/catalog.rs | 21 ++- crates/pod/src/prompt/system.rs | 183 ++++++++++++++++++++++++- resources/prompts/common/tool-usage.md | 18 ++- resources/prompts/internal.toml | 2 +- 4 files changed, 210 insertions(+), 14 deletions(-) diff --git a/crates/pod/src/prompt/catalog.rs b/crates/pod/src/prompt/catalog.rs index 918c3dba..a12c8a84 100644 --- a/crates/pod/src/prompt/catalog.rs +++ b/crates/pod/src/prompt/catalog.rs @@ -376,11 +376,21 @@ impl PromptCatalog { /// Render `PodPrompt::ResidentKnowledgeSection` with `{{ entries }}` /// (a pre-formatted list block authored by the caller). - pub fn resident_knowledge_section(&self, entries: &str) -> Result { - self.render( - PodPrompt::ResidentKnowledgeSection, - single("entries", entries), - ) + pub fn resident_knowledge_section( + &self, + entries: &str, + knowledge_query_available: bool, + memory_read_available: bool, + ) -> Result { + use std::collections::BTreeMap; + let mut m: BTreeMap<&'static str, Value> = BTreeMap::new(); + m.insert("entries", Value::from(entries)); + m.insert( + "knowledge_query_available", + Value::from(knowledge_query_available), + ); + m.insert("memory_read_available", Value::from(memory_read_available)); + self.render(PodPrompt::ResidentKnowledgeSection, Value::from(m)) } /// Render `PodPrompt::ResidentWorkflowsSection` with `{{ entries }}` @@ -537,6 +547,7 @@ mod tests { for rendered in [compact, extract, consolidate] { assert!(!rendered.contains("### Memory and knowledge")); assert!(!rendered.contains("Do not query memory every turn")); + assert!(!rendered.contains("Strong lookup triggers include")); } } diff --git a/crates/pod/src/prompt/system.rs b/crates/pod/src/prompt/system.rs index 3b2f8465..a6f97ce0 100644 --- a/crates/pod/src/prompt/system.rs +++ b/crates/pod/src/prompt/system.rs @@ -117,7 +117,7 @@ impl SystemPromptTemplate { let body = tmpl .render(ctx.to_minijinja_value()) .map_err(|e| SystemPromptError::Render(e.to_string()))?; - append_trailing_section( + append_trailing_section_with_capabilities( &body, ctx.prompts, ctx.scope, @@ -125,6 +125,7 @@ impl SystemPromptTemplate { ctx.resident_summary, ctx.resident_knowledge, ctx.resident_workflows, + ToolCapabilities::from_tool_names(&ctx.tool_names), ) } } @@ -199,10 +200,72 @@ impl<'a> SystemPromptContext<'a> { .collect::>(), ), ); + root.insert( + "tool_capabilities".into(), + ToolCapabilities::from_tool_names(&self.tool_names).to_minijinja_value(), + ); Value::from(root) } } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct ToolCapabilities { + memory_query: bool, + knowledge_query: bool, + memory_read: bool, + memory_write: bool, + memory_edit: bool, + memory_delete: bool, +} + +impl ToolCapabilities { + fn from_tool_names(names: &[String]) -> Self { + let mut capabilities = Self::default(); + for name in names { + match name.as_str() { + "MemoryQuery" => capabilities.memory_query = true, + "KnowledgeQuery" => capabilities.knowledge_query = true, + "MemoryRead" => capabilities.memory_read = true, + "MemoryWrite" => capabilities.memory_write = true, + "MemoryEdit" => capabilities.memory_edit = true, + "MemoryDelete" => capabilities.memory_delete = true, + _ => {} + } + } + capabilities + } + + fn memory_records(self) -> bool { + self.memory_query + || self.memory_read + || self.memory_write + || self.memory_edit + || self.memory_delete + } + + fn memory_any(self) -> bool { + self.memory_records() || self.knowledge_query + } + + fn memory_mutation(self) -> bool { + self.memory_write || self.memory_edit || self.memory_delete + } + + fn to_minijinja_value(self) -> Value { + let mut map: BTreeMap<&'static str, Value> = BTreeMap::new(); + map.insert("memory_any", Value::from(self.memory_any())); + map.insert("memory_records", Value::from(self.memory_records())); + map.insert("memory_query", Value::from(self.memory_query)); + map.insert("knowledge_query", Value::from(self.knowledge_query)); + map.insert("memory_read", Value::from(self.memory_read)); + map.insert("memory_write", Value::from(self.memory_write)); + map.insert("memory_edit", Value::from(self.memory_edit)); + map.insert("memory_delete", Value::from(self.memory_delete)); + map.insert("memory_mutation", Value::from(self.memory_mutation())); + Value::from(map) + } +} + /// Build the final system prompt by appending the fixed trailing /// section to `body`. The Rust side owns the layout (blank-line /// separators, trailing-whitespace trim); each section's header + body @@ -217,6 +280,28 @@ pub fn append_trailing_section( resident_summary: Option<&str>, resident_knowledge: Option<&[ResidentKnowledgeEntry]>, resident_workflows: Option<&[ResidentWorkflowEntry]>, +) -> Result { + append_trailing_section_with_capabilities( + body, + prompts, + scope, + agents_md, + resident_summary, + resident_knowledge, + resident_workflows, + ToolCapabilities::default(), + ) +} + +fn append_trailing_section_with_capabilities( + body: &str, + prompts: &PromptCatalog, + scope: &Scope, + agents_md: Option<&str>, + resident_summary: Option<&str>, + resident_knowledge: Option<&[ResidentKnowledgeEntry]>, + resident_workflows: Option<&[ResidentWorkflowEntry]>, + tool_capabilities: ToolCapabilities, ) -> Result { let mut out = String::with_capacity(body.len() + 256); out.push_str(body); @@ -247,7 +332,11 @@ pub fn append_trailing_section( if !entries.is_empty() { out.push('\n'); let formatted = format_resident_knowledge_entries(entries); - let section = prompts.resident_knowledge_section(&formatted)?; + let section = prompts.resident_knowledge_section( + &formatted, + tool_capabilities.knowledge_query, + tool_capabilities.memory_read, + )?; out.push_str(section.trim_end_matches(&['\n', ' '][..])); out.push('\n'); } @@ -414,6 +503,20 @@ mod tests { } } + fn memory_tool_names() -> Vec { + [ + "MemoryQuery", + "KnowledgeQuery", + "MemoryRead", + "MemoryWrite", + "MemoryEdit", + "MemoryDelete", + ] + .into_iter() + .map(String::from) + .collect() + } + /// Lazily-initialised builtin catalog shared across system-prompt /// tests, so every `ctx()` can hand out a `&'static PromptCatalog` /// reference without forcing test bodies to create one per call. @@ -438,13 +541,15 @@ mod tests { let dir = TempDir::new().unwrap(); let scope = build_scope(dir.path()); let rendered = tmpl - .render(&ctx(dir.path(), &scope, vec!["Read".into()], None)) + .render(&ctx(dir.path(), &scope, memory_tool_names(), None)) .unwrap(); // Builtin default body must expose the tool and language policies. assert!(rendered.contains("### Memory and knowledge")); - assert!(rendered.contains("MemoryQuery")); + assert!(rendered.contains("small targeted `MemoryQuery` / `KnowledgeQuery`")); + assert!(rendered.contains("Strong lookup triggers include")); assert!(rendered.contains("MemoryRead(kind=summary)")); assert!(rendered.contains("Do not query memory every turn")); + assert!(rendered.contains("MemoryWrite")); assert!(rendered.contains("## Language")); assert!(rendered.contains("`language`: `match the user's language")); // Trailing section must be present. @@ -452,6 +557,56 @@ mod tests { assert!(rendered.contains("Readable:")); } + #[test] + fn instruction_default_omits_memory_guidance_without_memory_tools() { + let loader = PromptLoader::builtins_only(); + let tmpl = SystemPromptTemplate::parse("$insomnia/default", loader).unwrap(); + let dir = TempDir::new().unwrap(); + let scope = build_scope(dir.path()); + let rendered = tmpl + .render(&ctx( + dir.path(), + &scope, + vec!["Read".into(), "Edit".into()], + None, + )) + .unwrap(); + + assert!(!rendered.contains("### Memory and knowledge")); + assert!(!rendered.contains("MemoryQuery")); + assert!(!rendered.contains("KnowledgeQuery")); + assert!(!rendered.contains("MemoryRead")); + assert!(!rendered.contains("MemoryWrite")); + assert!(!rendered.contains("MemoryEdit")); + assert!(!rendered.contains("MemoryDelete")); + assert!(rendered.contains("## Language")); + assert!(rendered.contains("## Working boundaries")); + } + + #[test] + fn memory_guidance_names_only_available_memory_tools() { + let loader = PromptLoader::builtins_only(); + let tmpl = SystemPromptTemplate::parse("$insomnia/default", loader).unwrap(); + let dir = TempDir::new().unwrap(); + let scope = build_scope(dir.path()); + let rendered = tmpl + .render(&ctx( + dir.path(), + &scope, + vec!["MemoryQuery".into(), "MemoryRead".into()], + None, + )) + .unwrap(); + + assert!(rendered.contains("### Memory and knowledge")); + assert!(rendered.contains("small targeted `MemoryQuery`")); + assert!(rendered.contains("MemoryRead(kind=summary)")); + assert!(!rendered.contains("KnowledgeQuery")); + assert!(!rendered.contains("MemoryWrite")); + assert!(!rendered.contains("MemoryEdit")); + assert!(!rendered.contains("MemoryDelete")); + } + #[test] fn instruction_prefix_addressing_user() { let (_tmp, loader) = user_loader_with("greet.md", "HELLO from {{ cwd }}"); @@ -706,12 +861,32 @@ mod tests { assert!(rendered.contains("- alpha: first record")); // Newline in description is folded to a space (one entry per line). assert!(rendered.contains("- beta: second record with newline")); + assert!(!rendered.contains("KnowledgeQuery")); + assert!(!rendered.contains("MemoryRead")); // Resident section sits *after* the working-boundaries header. let pos_boundaries = rendered.find("## Working boundaries").unwrap(); let pos_resident = rendered.find("## Resident knowledge").unwrap(); assert!(pos_resident > pos_boundaries); } + #[test] + fn trailing_section_mentions_resident_knowledge_tools_when_available() { + let (_tmp, loader) = user_loader_with("body.md", "BODY"); + let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap(); + let dir = TempDir::new().unwrap(); + let scope = build_scope(dir.path()); + let entries = [ResidentKnowledgeEntry { + slug: "alpha".into(), + description: "first record".into(), + }]; + let mut context = ctx_with_resident(dir.path(), &scope, &entries); + context.tool_names = memory_tool_names(); + let rendered = tmpl.render(&context).unwrap(); + + assert!(rendered.contains("## Resident knowledge")); + assert!(rendered.contains("KnowledgeQuery / MemoryRead")); + } + #[test] fn trailing_section_renders_resident_workflows() { let (_tmp, loader) = user_loader_with("body.md", "BODY"); diff --git a/resources/prompts/common/tool-usage.md b/resources/prompts/common/tool-usage.md index d0524197..95a76002 100644 --- a/resources/prompts/common/tool-usage.md +++ b/resources/prompts/common/tool-usage.md @@ -5,10 +5,20 @@ When searching, use grep/glob primitives rather than shell pipelines. You can run multiple tools simultaneously by calling them within a single response. It is recommended to run tools that handle asynchronous processing, such as queries and readings, in batches. +{% if tool_capabilities.memory_any %} ### Memory and knowledge -For past decisions, prior requests, durable preferences, project history, or why something was done, use targeted lookup instead of guessing from vague recollection. -Use `MemoryQuery` for durable memory records (summary, decisions, requests), `KnowledgeQuery` for project knowledge, `MemoryRead(kind=summary)` for the full memory summary, and `MemoryRead` on returned slugs when excerpts are insufficient. -Resident memory and knowledge are 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, and normally prefer read/query tools; use `MemoryWrite`, `MemoryEdit`, or `MemoryDelete` only when explicitly asked or in a memory maintenance worker. +Use memory and knowledge proactively when the request may depend on prior project decisions, historical rationale, durable user preferences, recently completed tickets, or established workflow/policy conventions. +{% if tool_capabilities.memory_query and tool_capabilities.knowledge_query %}Prefer a small targeted `MemoryQuery` / `KnowledgeQuery` before relying on vague recollection. +{% elif tool_capabilities.memory_query %}Prefer a small targeted `MemoryQuery` before relying on vague recollection. +{% elif tool_capabilities.knowledge_query %}Prefer a small targeted `KnowledgeQuery` before relying on vague recollection. +{% 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. +{% if tool_capabilities.memory_read %} +Use `MemoryRead(kind=summary)` for the full memory summary, and `MemoryRead` on returned slugs when excerpts are insufficient. +{% endif %} +{% if tool_capabilities.memory_records and tool_capabilities.knowledge_query %}Resident memory and knowledge are{% elif tool_capabilities.knowledge_query %}Resident knowledge is{% else %}Resident memory is{% endif %} 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. +{% 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. +{% endif %}{% endif %} diff --git a/resources/prompts/internal.toml b/resources/prompts/internal.toml index 482e7085..caffc1c3 100644 --- a/resources/prompts/internal.toml +++ b/resources/prompts/internal.toml @@ -52,7 +52,7 @@ resident_knowledge_section = """\ --- ## Resident knowledge -The following knowledge records are advertised resident. Use the KnowledgeQuery / MemoryRead tools to fetch the full body when relevant. +The following knowledge records are advertised resident.{% if knowledge_query_available and memory_read_available %} Use the KnowledgeQuery / MemoryRead tools to fetch the full body when relevant.{% elif knowledge_query_available %} Use KnowledgeQuery to search related knowledge records when relevant.{% elif memory_read_available %} Use MemoryRead on a known knowledge slug when the full body is required.{% endif %} {{ entries }}\ """