From b25f4c74686dbb8ac3fbd6f0446b685353cc8207 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 26 May 2026 09:21:10 +0900 Subject: [PATCH 1/2] feat: inject memory summary into resident prompt --- crates/manifest/src/config.rs | 1 + crates/manifest/src/lib.rs | 13 +++ crates/memory/src/lib.rs | 5 +- crates/memory/src/resident.rs | 71 +++++++++++- crates/pod/src/factory.rs | 1 + crates/pod/src/pod.rs | 178 ++++++++++++++++++++++++++----- crates/pod/src/prompt/catalog.rs | 17 ++- crates/pod/src/prompt/system.rs | 76 ++++++++++++- resources/prompts/internal.toml | 9 ++ 9 files changed, 336 insertions(+), 35 deletions(-) diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 8200fb60..67f9130a 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -289,6 +289,7 @@ impl MemoryConfig { workspace_root: upper.workspace_root.or(self.workspace_root), query_result_limit: upper.query_result_limit.or(self.query_result_limit), query_excerpt_lines: upper.query_excerpt_lines.or(self.query_excerpt_lines), + inject_summary: upper.inject_summary.or(self.inject_summary), language: upper.language.or(self.language), extract_model: upper.extract_model.or(self.extract_model), extract_threshold: upper.extract_threshold.or(self.extract_threshold), diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 36435cb6..6f152b69 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -96,6 +96,10 @@ pub struct MemoryConfig { /// Ignored when the request omits `query`. `None` ⇒ tool default (3). #[serde(default)] pub query_excerpt_lines: Option, + /// Whether the body of `memory/summary.md` is exposed in the resident + /// system-prompt section. `None` ⇒ enabled. + #[serde(default)] + pub inject_summary: Option, /// Language used by memory extraction / consolidation workers for durable /// memory and knowledge text. Free-form so workspaces can use names like /// `English`, `Japanese`, or locale tags. `None` ⇒ @@ -669,6 +673,15 @@ model_id = "claude-sonnet-4-20250514" let manifest = PodManifest::from_toml(&toml).unwrap(); let mem = manifest.memory.expect("memory section parsed"); assert!(mem.workspace_root.is_none()); + assert_eq!(mem.inject_summary, None); + } + + #[test] + fn memory_section_with_inject_summary_false() { + let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n"); + let manifest = PodManifest::from_toml(&toml).unwrap(); + let mem = manifest.memory.unwrap(); + assert_eq!(mem.inject_summary, Some(false)); } #[test] diff --git a/crates/memory/src/lib.rs b/crates/memory/src/lib.rs index fa7719b2..915c5dc6 100644 --- a/crates/memory/src/lib.rs +++ b/crates/memory/src/lib.rs @@ -22,7 +22,10 @@ pub use error::{LintError, LintWarning, MemoryError}; pub use extract::ExtractPointerPayload; pub use lint_common::{RecordLintError, Slug, is_valid_slug}; pub use linter::{LintReport, Linter}; -pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge, list_knowledge_slugs}; +pub use resident::{ + ResidentKnowledgeEntry, collect_resident_knowledge, collect_resident_summary, + list_knowledge_slugs, +}; pub use scope::deny_write_rules; pub use usage::{ UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource, diff --git a/crates/memory/src/resident.rs b/crates/memory/src/resident.rs index 6905f4df..b343c751 100644 --- a/crates/memory/src/resident.rs +++ b/crates/memory/src/resident.rs @@ -1,10 +1,12 @@ -//! Workspace knowledge enumeration helpers. +//! Workspace memory resident-enumeration helpers. //! -//! Two surfaces, both walking `/.insomnia/knowledge/*.md`: +//! Surfaces used by the Pod system-prompt assembler: //! //! - [`collect_resident_knowledge`] — resident-injection candidates -//! (`model_invokation: true`) returned as `(slug, description)` pairs -//! for the Pod system-prompt assembler. +//! (`model_invokation: true`) returned as `(slug, description)` pairs. +//! - [`collect_resident_summary`] — the body of +//! `/.insomnia/memory/summary.md` when it parses as a summary +//! record and has non-empty body. //! - [`list_knowledge_slugs`] — every slug whose file parses, regardless //! of `model_invokation`. Used by the Pod IPC layer to answer TUI `#` //! completion (`model_invokation` is a resident-injection flag, not a @@ -14,7 +16,7 @@ //! enforces shape on write, so a malformed file here means external //! tampering and we'd rather degrade than panic. -use crate::schema::{KnowledgeFrontmatter, split_frontmatter}; +use crate::schema::{KnowledgeFrontmatter, SummaryFrontmatter, split_frontmatter}; use crate::workspace::WorkspaceLayout; #[derive(Debug, Clone, PartialEq, Eq)] @@ -40,6 +42,21 @@ pub fn collect_resident_knowledge(layout: &WorkspaceLayout) -> Vec/.insomnia/memory/summary.md` for resident prompt +/// injection. Returns only the markdown body (frontmatter stripped), and +/// degrades to `None` for missing, unreadable, malformed, or empty records. +pub fn collect_resident_summary(layout: &WorkspaceLayout) -> Option { + let raw = std::fs::read_to_string(layout.summary_path()).ok()?; + let (yaml, body) = split_frontmatter(&raw).ok()?; + let _fm: SummaryFrontmatter = serde_yaml::from_str(yaml).ok()?; + let body = body.trim_matches(&['\n', '\r'][..]); + if body.trim().is_empty() { + None + } else { + Some(body.to_string()) + } +} + /// Walk `/knowledge/*.md` and return every slug whose /// frontmatter parses, sorted ascending. Does not filter on /// `model_invokation`. A missing `knowledge/` directory yields an empty @@ -97,6 +114,12 @@ mod tests { Utc::now().to_rfc3339() } + fn write_summary(dir: &Path, body: &str) { + let path = dir.join(".insomnia/memory/summary.md"); + let content = format!("---\nupdated_at: {n}\n---\n{body}", n = now()); + std::fs::write(path, content).unwrap(); + } + fn write_knowledge( dir: &Path, slug: &str, @@ -116,10 +139,48 @@ mod tests { fn setup() -> (TempDir, WorkspaceLayout) { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".insomnia/knowledge")).unwrap(); + std::fs::create_dir_all(dir.path().join(".insomnia/memory")).unwrap(); let layout = WorkspaceLayout::new(dir.path().to_path_buf()); (dir, layout) } + #[test] + fn missing_summary_returns_none() { + let dir = TempDir::new().unwrap(); + let layout = WorkspaceLayout::new(dir.path().to_path_buf()); + assert!(collect_resident_summary(&layout).is_none()); + } + + #[test] + fn summary_returns_body_without_frontmatter() { + let (dir, layout) = setup(); + write_summary(dir.path(), "remember this\n"); + + let got = collect_resident_summary(&layout).unwrap(); + assert_eq!(got, "remember this"); + assert!(!got.contains("updated_at")); + assert!(!got.contains("---")); + } + + #[test] + fn malformed_summary_returns_none() { + let (dir, layout) = setup(); + std::fs::write( + dir.path().join(".insomnia/memory/summary.md"), + "---\nthis is not yaml: : :\n---\nbody\n", + ) + .unwrap(); + + assert!(collect_resident_summary(&layout).is_none()); + } + + #[test] + fn empty_summary_body_returns_none() { + let (dir, layout) = setup(); + write_summary(dir.path(), " \n"); + assert!(collect_resident_summary(&layout).is_none()); + } + #[test] fn missing_knowledge_dir_returns_empty() { let dir = TempDir::new().unwrap(); diff --git a/crates/pod/src/factory.rs b/crates/pod/src/factory.rs index 59a79e8f..4b69b908 100644 --- a/crates/pod/src/factory.rs +++ b/crates/pod/src/factory.rs @@ -647,6 +647,7 @@ permission = "write" scope: &scope, tool_names: Vec::new(), agents_md: None, + resident_summary: None, resident_knowledge: None, resident_workflows: None, prompts: &catalog, diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 3358a80b..ec25124f 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -314,11 +314,12 @@ pub struct Pod { /// Memory workspace layout used by the workflow resolver to load required /// Knowledge records by exact slug. memory_layout: Option, - /// When true (default), the system-prompt assembler walks - /// `/knowledge/*` and appends a `## Resident knowledge` - /// section listing records with `model_invokation: true`. - /// consolidation workers set this to false so the - /// agentic worker pulls knowledge through the search tools instead. + /// When true (default), the system-prompt assembler may append resident + /// memory sections from the workspace: `memory/summary.md`, resident + /// knowledge records, and resident workflows. Consolidation workers set + /// this to false so the agentic worker pulls knowledge through the + /// search tools instead, and so disposable internal workers avoid + /// resident memory exposure entirely. inject_resident_knowledge: bool, /// Latest runtime scope snapshot queued by dynamic scope changes. /// Drained into the session log before the next turn result is @@ -593,16 +594,16 @@ impl Pod { self.system_prompt_template = Some(template); } - /// Toggle the resident-knowledge section of the system prompt. + /// Toggle resident memory sections in the system prompt. /// /// Default `true`: when memory is enabled in the manifest, the - /// assembler walks `/knowledge/*` and lists records with - /// `model_invokation: true`. consolidation workers and - /// other agentic memory paths set this to `false` so the worker - /// pulls knowledge through the search tools instead of riding on - /// the resident system-prompt budget. Idempotent if called multiple - /// times before the first turn; ineffective once the system prompt - /// has been materialised. + /// assembler can expose the summary body, resident knowledge records, + /// and resident workflows. Consolidation workers and other internal + /// disposable workers set this to `false` so the worker pulls knowledge + /// through the search tools instead of riding on the resident + /// system-prompt budget. Idempotent if called multiple times before + /// the first turn; ineffective once the system prompt has been + /// materialised. pub fn set_resident_knowledge_injection(&mut self, enabled: bool) { self.inject_resident_knowledge = enabled; } @@ -1160,10 +1161,25 @@ impl Pod { } } // Resident-injection collection: only when memory is enabled in - // the manifest AND this Pod opts in (consolidation workers opt out). - // Owned `Vec` lives for the duration of `render` below; the - // context borrows a slice into it. - let resident: Vec = if self.inject_resident_knowledge { + // the manifest AND this Pod opts in (internal workers opt out). + // Owned values live for the duration of `render` below; the + // context borrows from them. + let inject_memory_resident = self.inject_resident_knowledge && self.memory_layout.is_some(); + let inject_summary = inject_memory_resident + && self + .manifest + .memory + .as_ref() + .and_then(|m| m.inject_summary) + .unwrap_or(true); + let resident_summary: Option = if inject_summary { + self.memory_layout + .as_ref() + .and_then(memory::collect_resident_summary) + } else { + None + }; + let resident: Vec = if inject_memory_resident { self.memory_layout .as_ref() .map(memory::collect_resident_knowledge) @@ -1171,20 +1187,19 @@ impl Pod { } else { Vec::new() }; - let resident_slice: Option<&[memory::ResidentKnowledgeEntry]> = - if self.inject_resident_knowledge && self.memory_layout.is_some() { - Some(&resident) - } else { - None - }; + let resident_slice: Option<&[memory::ResidentKnowledgeEntry]> = if inject_memory_resident { + Some(&resident) + } else { + None + }; let resident_workflows: Vec = - if self.inject_resident_knowledge && self.memory_layout.is_some() { + if inject_memory_resident { self.workflow_registry.resident_entries() } else { Vec::new() }; let resident_workflow_slice: Option<&[workflow_crate::ResidentWorkflowEntry]> = - if self.inject_resident_knowledge && self.memory_layout.is_some() { + if inject_memory_resident { Some(&resident_workflows) } else { None @@ -1200,6 +1215,7 @@ impl Pod { scope: &scope_snapshot, tool_names, agents_md: agents_md_read.body, + resident_summary: resident_summary.as_deref(), resident_knowledge: resident_slice, resident_workflows: resident_workflow_slice, prompts: &self.prompts, @@ -4491,6 +4507,118 @@ mod build_summary_prompt_tests { assert_eq!(interrupt_system_count, 1); } + async fn render_system_prompt_with_summary( + summary_doc: Option<&str>, + memory_config: Option, + resident_injection: bool, + ) -> String { + let dir = tempfile::tempdir().unwrap(); + let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap(); + let pwd = dir.path().join("workspace"); + std::fs::create_dir_all(&pwd).unwrap(); + if let Some(doc) = summary_doc { + std::fs::create_dir_all(pwd.join(".insomnia/memory")).unwrap(); + std::fs::write(pwd.join(".insomnia/memory/summary.md"), doc).unwrap(); + } + + let mut manifest = minimal_manifest_with_skills(vec![]); + manifest.memory = memory_config; + let scope = Scope::writable(&pwd).unwrap(); + let mut pod = Pod::new(manifest, Worker::new(NoopClient), store, pwd.clone(), scope) + .await + .unwrap(); + pod.memory_layout = pod + .manifest + .memory + .as_ref() + .map(|mem| memory::WorkspaceLayout::resolve(mem, &pwd)); + pod.set_resident_knowledge_injection(resident_injection); + let template = SystemPromptTemplate::parse( + "$insomnia/default", + crate::prompt::loader::PromptLoader::builtins_only(), + ) + .unwrap(); + pod.set_system_prompt_template(template); + pod.ensure_system_prompt_materialized().unwrap(); + pod.worker().get_system_prompt().unwrap().to_string() + } + + fn summary_doc(body: &str) -> String { + format!("---\nupdated_at: 2026-01-01T00:00:00Z\n---\n{body}") + } + + #[tokio::test] + async fn resident_summary_body_is_injected_without_frontmatter() { + let rendered = render_system_prompt_with_summary( + Some(&summary_doc("summary body for resident prompt\n")), + Some(manifest::MemoryConfig::default()), + true, + ) + .await; + + assert!(rendered.contains("## Resident memory summary")); + assert!(rendered.contains("summary body for resident prompt")); + assert!(!rendered.contains("updated_at: 2026-01-01T00:00:00Z")); + assert!(!rendered.contains("---\nupdated_at")); + } + + #[tokio::test] + async fn resident_summary_injection_can_be_disabled_by_manifest() { + let memory = manifest::MemoryConfig { + inject_summary: Some(false), + ..manifest::MemoryConfig::default() + }; + let rendered = render_system_prompt_with_summary( + Some(&summary_doc("disabled summary body\n")), + Some(memory), + true, + ) + .await; + + assert!(!rendered.contains("Resident memory summary")); + assert!(!rendered.contains("disabled summary body")); + } + + #[tokio::test] + async fn resident_summary_is_absent_without_memory_config() { + let rendered = render_system_prompt_with_summary( + Some(&summary_doc("memory-disabled summary body\n")), + None, + true, + ) + .await; + + assert!(!rendered.contains("Resident memory summary")); + assert!(!rendered.contains("memory-disabled summary body")); + } + + #[tokio::test] + async fn malformed_resident_summary_does_not_fail_render() { + let rendered = render_system_prompt_with_summary( + Some("---\nthis is not yaml: : :\n---\nbad summary body\n"), + Some(manifest::MemoryConfig::default()), + true, + ) + .await; + + assert!(rendered.contains("## Working boundaries")); + assert!(!rendered.contains("Resident memory summary")); + assert!(!rendered.contains("bad summary body")); + } + + #[tokio::test] + async fn resident_summary_respects_internal_worker_opt_out() { + let rendered = render_system_prompt_with_summary( + Some(&summary_doc("internal opt-out summary body\n")), + Some(manifest::MemoryConfig::default()), + false, + ) + .await; + + assert!(!rendered.contains("Resident memory summary")); + assert!(!rendered.contains("internal opt-out summary body")); + } + fn minimal_manifest_with_skills(dirs: Vec) -> PodManifest { // Construct the smallest possible PodManifest that resolves; only // the `skills` field matters for `skill_dir_read_rules`. diff --git a/crates/pod/src/prompt/catalog.rs b/crates/pod/src/prompt/catalog.rs index 42f225ae..0ca271fc 100644 --- a/crates/pod/src/prompt/catalog.rs +++ b/crates/pod/src/prompt/catalog.rs @@ -79,8 +79,12 @@ pub enum PodPrompt { /// Trailing `## Project instructions (AGENTS.md)` section, appended /// after the scope summary when an AGENTS.md is present. AgentsMdSection, + /// Trailing `## Resident memory summary` section, appended after the + /// AGENTS.md section when memory is enabled, summary injection is enabled, + /// and `memory/summary.md` has a valid non-empty body. + ResidentMemorySummarySection, /// Trailing `## Resident knowledge` section, appended after the - /// AGENTS.md section when memory is enabled and at least one + /// resident memory summary when memory is enabled and at least one /// `knowledge/*` record advertises `model_invokation: true`. ResidentKnowledgeSection, /// Trailing `## Resident workflows` section, appended after resident @@ -100,6 +104,7 @@ impl PodPrompt { Self::InterruptSystemNote => "interrupt_system_note", Self::WorkingBoundariesSection => "working_boundaries_section", Self::AgentsMdSection => "agents_md_section", + Self::ResidentMemorySummarySection => "resident_memory_summary_section", Self::ResidentKnowledgeSection => "resident_knowledge_section", Self::ResidentWorkflowsSection => "resident_workflows_section", } @@ -117,6 +122,7 @@ impl PodPrompt { PodPrompt::InterruptSystemNote, PodPrompt::WorkingBoundariesSection, PodPrompt::AgentsMdSection, + PodPrompt::ResidentMemorySummarySection, PodPrompt::ResidentKnowledgeSection, PodPrompt::ResidentWorkflowsSection, ]; @@ -130,6 +136,7 @@ impl PodPrompt { "interrupt_system_note", "working_boundaries_section", "agents_md_section", + "resident_memory_summary_section", "resident_knowledge_section", "resident_workflows_section", ]; @@ -352,6 +359,14 @@ impl PromptCatalog { self.render(PodPrompt::AgentsMdSection, single("agents_md", agents_md)) } + /// Render `PodPrompt::ResidentMemorySummarySection` with `{{ summary }}`. + pub fn resident_memory_summary_section(&self, summary: &str) -> Result { + self.render( + PodPrompt::ResidentMemorySummarySection, + single("summary", summary), + ) + } + /// Render `PodPrompt::ResidentKnowledgeSection` with `{{ entries }}` /// (a pre-formatted list block authored by the caller). pub fn resident_knowledge_section(&self, entries: &str) -> Result { diff --git a/crates/pod/src/prompt/system.rs b/crates/pod/src/prompt/system.rs index e11fcd1e..4a5b9ce0 100644 --- a/crates/pod/src/prompt/system.rs +++ b/crates/pod/src/prompt/system.rs @@ -8,9 +8,9 @@ //! prompt is materialised exactly once just before the first LLM turn: //! the rendered body is appended with a fixed trailing section carrying //! the Pod's `Scope` summary and (if present) the project's `AGENTS.md` -//! contents, and the whole string is handed to the Worker via -//! `set_system_prompt`. Subsequent turns and compactions reuse that -//! materialised string verbatim. +//! contents plus resident memory sections, and the whole string is handed +//! to the Worker via `set_system_prompt`. Subsequent turns and compactions +//! reuse that materialised string verbatim. use std::collections::BTreeMap; use std::path::Path; @@ -122,6 +122,7 @@ impl SystemPromptTemplate { ctx.prompts, ctx.scope, ctx.agents_md.as_deref(), + ctx.resident_summary, ctx.resident_knowledge, ctx.resident_workflows, ) @@ -152,6 +153,10 @@ pub struct SystemPromptContext<'a> { /// Not visible from the template; consumed by the trailing-section /// formatter in [`SystemPromptTemplate::render`]. pub agents_md: Option, + /// The body of `/.insomnia/memory/summary.md`, with + /// frontmatter stripped. `None` disables the resident summary section; + /// empty strings are ignored by the trailing-section formatter. + pub resident_summary: Option<&'a str>, /// Resident-injection candidates from `/knowledge/*` whose /// frontmatter has `model_invokation: true`. `None` disables the /// section entirely (memory disabled, or a consolidation worker that opts @@ -209,6 +214,7 @@ pub fn append_trailing_section( prompts: &PromptCatalog, scope: &Scope, agents_md: Option<&str>, + resident_summary: Option<&str>, resident_knowledge: Option<&[ResidentKnowledgeEntry]>, resident_workflows: Option<&[ResidentWorkflowEntry]>, ) -> Result { @@ -228,6 +234,15 @@ pub fn append_trailing_section( out.push_str(section.trim_end_matches(&['\n', ' '][..])); out.push('\n'); } + if let Some(summary) = resident_summary { + let summary = summary.trim_matches(&['\n', '\r'][..]); + if !summary.trim().is_empty() { + out.push('\n'); + let section = prompts.resident_memory_summary_section(summary)?; + out.push_str(section.trim_end_matches(&['\n', ' '][..])); + out.push('\n'); + } + } if let Some(entries) = resident_knowledge { if !entries.is_empty() { out.push('\n'); @@ -335,6 +350,26 @@ mod tests { scope, tool_names: tools, agents_md, + resident_summary: None, + resident_knowledge: None, + resident_workflows: None, + prompts: test_prompts(), + } + } + + fn ctx_with_summary<'a>( + cwd: &'a Path, + scope: &'a Scope, + summary: Option<&'a str>, + ) -> SystemPromptContext<'a> { + SystemPromptContext { + now: fixed_now(), + cwd, + language: manifest::defaults::WORKER_LANGUAGE, + scope, + tool_names: Vec::new(), + agents_md: None, + resident_summary: summary, resident_knowledge: None, resident_workflows: None, prompts: test_prompts(), @@ -353,6 +388,7 @@ mod tests { scope, tool_names: Vec::new(), agents_md: None, + resident_summary: None, resident_knowledge: Some(resident), resident_workflows: None, prompts: test_prompts(), @@ -568,6 +604,40 @@ mod tests { assert!(!rendered.contains("Project instructions")); } + #[test] + fn trailing_section_renders_resident_summary_body() { + 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 rendered = tmpl + .render(&ctx_with_summary( + dir.path(), + &scope, + Some("Persistent summary body"), + )) + .unwrap(); + assert!(rendered.contains("## Resident memory summary")); + assert!(rendered.contains("Persistent summary body")); + } + + #[test] + fn trailing_section_omits_resident_summary_when_none_or_empty() { + 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 rendered = tmpl + .render(&ctx_with_summary(dir.path(), &scope, None)) + .unwrap(); + assert!(!rendered.contains("Resident memory summary")); + + let rendered = tmpl + .render(&ctx_with_summary(dir.path(), &scope, Some(" \n"))) + .unwrap(); + assert!(!rendered.contains("Resident memory summary")); + } + #[test] fn trailing_section_omits_resident_knowledge_when_none() { let (_tmp, loader) = user_loader_with("body.md", "BODY"); diff --git a/resources/prompts/internal.toml b/resources/prompts/internal.toml index e4dff27c..325bd305 100644 --- a/resources/prompts/internal.toml +++ b/resources/prompts/internal.toml @@ -39,6 +39,15 @@ agents_md_section = """\ {{ agents_md }}\ """ +resident_memory_summary_section = """\ +--- +## Resident memory summary + +The following is the current durable session/workspace summary. Treat it as background context; it is not a user request. + +{{ summary }}\ +""" + resident_knowledge_section = """\ --- ## Resident knowledge From d0849238788263265397ec58b7b64171ff7775cc Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 26 May 2026 09:44:24 +0900 Subject: [PATCH 2/2] fix: split resident injection gates --- crates/pod/src/pod.rs | 230 +++++++++++++++++++++++++------ crates/pod/src/prompt/catalog.rs | 9 +- crates/pod/src/prompt/system.rs | 54 ++++++++ 3 files changed, 249 insertions(+), 44 deletions(-) diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index ec25124f..e9434aa8 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -314,13 +314,18 @@ pub struct Pod { /// Memory workspace layout used by the workflow resolver to load required /// Knowledge records by exact slug. memory_layout: Option, + /// When true (default), the system-prompt assembler may append the + /// workspace memory summary (`memory/summary.md`). Internal disposable + /// workers disable this so resident memory exposure is opt-in per Pod. + inject_resident_summary: bool, /// When true (default), the system-prompt assembler may append resident - /// memory sections from the workspace: `memory/summary.md`, resident - /// knowledge records, and resident workflows. Consolidation workers set - /// this to false so the agentic worker pulls knowledge through the - /// search tools instead, and so disposable internal workers avoid - /// resident memory exposure entirely. + /// Knowledge descriptions. This is intentionally independent from + /// summary and workflow residency: each section has its own gate. inject_resident_knowledge: bool, + /// When true (default), the system-prompt assembler may append resident + /// Workflow descriptions. This is intentionally independent from + /// summary and Knowledge residency: each section has its own gate. + inject_resident_workflows: bool, /// Latest runtime scope snapshot queued by dynamic scope changes. /// Drained into the session log before the next turn result is /// persisted, so resume never silently reclaims delegated writes. @@ -426,7 +431,9 @@ impl Pod { prompts: self.prompts.clone(), workflow_registry: self.workflow_registry.clone(), memory_layout: self.memory_layout.clone(), + inject_resident_summary: self.inject_resident_summary, inject_resident_knowledge: self.inject_resident_knowledge, + inject_resident_workflows: self.inject_resident_workflows, pending_scope_snapshot: self.pending_scope_snapshot.clone(), extract_in_flight: self.extract_in_flight.clone(), consolidation_in_flight: self.consolidation_in_flight.clone(), @@ -570,7 +577,9 @@ impl Pod { prompts, workflow_registry: workflow_crate::WorkflowRegistry::empty(), memory_layout: None, + inject_resident_summary: true, inject_resident_knowledge: true, + inject_resident_workflows: true, pending_scope_snapshot: Arc::new(Mutex::new(None)), extract_in_flight: Arc::new(AtomicBool::new(false)), consolidation_in_flight: Arc::new(AtomicBool::new(false)), @@ -594,20 +603,33 @@ impl Pod { self.system_prompt_template = Some(template); } - /// Toggle resident memory sections in the system prompt. + /// Toggle all resident sections in the system prompt. /// - /// Default `true`: when memory is enabled in the manifest, the - /// assembler can expose the summary body, resident knowledge records, - /// and resident workflows. Consolidation workers and other internal - /// disposable workers set this to `false` so the worker pulls knowledge - /// through the search tools instead of riding on the resident - /// system-prompt budget. Idempotent if called multiple times before - /// the first turn; ineffective once the system prompt has been - /// materialised. + /// Default `true`: normal Pods may expose each resident section according + /// to its own gate and manifest settings. Internal disposable workers set + /// this to `false` so summary, Knowledge, and Workflow residency are all + /// suppressed while explicit tools remain available. + pub fn set_resident_injection(&mut self, enabled: bool) { + self.inject_resident_summary = enabled; + self.inject_resident_knowledge = enabled; + self.inject_resident_workflows = enabled; + } + + /// Toggle `memory/summary.md` resident injection in the system prompt. + pub fn set_resident_summary_injection(&mut self, enabled: bool) { + self.inject_resident_summary = enabled; + } + + /// Toggle resident Knowledge injection in the system prompt. pub fn set_resident_knowledge_injection(&mut self, enabled: bool) { self.inject_resident_knowledge = enabled; } + /// Toggle resident Workflow injection in the system prompt. + pub fn set_resident_workflow_injection(&mut self, enabled: bool) { + self.inject_resident_workflows = enabled; + } + /// Shared handle to the prompt catalog. Cheap to clone (`Arc`). pub fn prompts(&self) -> &Arc { &self.prompts @@ -1160,12 +1182,15 @@ impl Pod { n.alert(AlertLevel::Warn, AlertSource::AgentsMd, warning); } } - // Resident-injection collection: only when memory is enabled in - // the manifest AND this Pod opts in (internal workers opt out). + // Resident-injection collection. Each resident section has its own + // gate so summary, Knowledge, and Workflow residency remain + // conceptually independent. Internal workers can still opt out of all + // resident sections by flipping all three gates. // Owned values live for the duration of `render` below; the // context borrows from them. - let inject_memory_resident = self.inject_resident_knowledge && self.memory_layout.is_some(); - let inject_summary = inject_memory_resident + let memory_layout = self.memory_layout.as_ref(); + let inject_summary = self.inject_resident_summary + && memory_layout.is_some() && self .manifest .memory @@ -1173,33 +1198,32 @@ impl Pod { .and_then(|m| m.inject_summary) .unwrap_or(true); let resident_summary: Option = if inject_summary { - self.memory_layout - .as_ref() - .and_then(memory::collect_resident_summary) + memory_layout.and_then(memory::collect_resident_summary) } else { None }; - let resident: Vec = if inject_memory_resident { - self.memory_layout - .as_ref() + let inject_resident_knowledge = self.inject_resident_knowledge && memory_layout.is_some(); + let resident: Vec = if inject_resident_knowledge { + memory_layout .map(memory::collect_resident_knowledge) .unwrap_or_default() } else { Vec::new() }; - let resident_slice: Option<&[memory::ResidentKnowledgeEntry]> = if inject_memory_resident { + let resident_slice: Option<&[memory::ResidentKnowledgeEntry]> = if inject_resident_knowledge + { Some(&resident) } else { None }; let resident_workflows: Vec = - if inject_memory_resident { + if self.inject_resident_workflows { self.workflow_registry.resident_entries() } else { Vec::new() }; let resident_workflow_slice: Option<&[workflow_crate::ResidentWorkflowEntry]> = - if inject_memory_resident { + if self.inject_resident_workflows { Some(&resident_workflows) } else { None @@ -3257,12 +3281,11 @@ impl Pod { }); // Memory tools are self-contained — they bypass ScopedFs and write - // directly under the workspace via WorkspaceLayout. Resident - // knowledge injection (`Pod::set_resident_knowledge_injection`) is - // a Pod-level concern; this disposable Worker is built without it - // by construction, in keeping with `docs/plan/memory.md` §Consolidation - // のKnowledgeアクセス (agent pulls knowledge through the search - // tool instead of via system-prompt residency). + // directly under the workspace via WorkspaceLayout. Resident section + // injection is a Pod-level concern; this disposable Worker is built + // without it by construction, in keeping with `docs/plan/memory.md` + // §Consolidation のKnowledgeアクセス (agent pulls knowledge through + // the search tool instead of via system-prompt residency). let query_cfg = memory::tool::QueryConfig::from(memory_cfg); worker.register_tool(memory::tool::read_tool_with_usage( layout.clone(), @@ -3579,7 +3602,9 @@ where prompts: common.prompts, workflow_registry: common.workflow_registry, memory_layout: common.memory_layout, + inject_resident_summary: true, inject_resident_knowledge: true, + inject_resident_workflows: true, pending_scope_snapshot: Arc::new(Mutex::new(None)), extract_in_flight: Arc::new(AtomicBool::new(false)), consolidation_in_flight: Arc::new(AtomicBool::new(false)), @@ -3656,7 +3681,9 @@ where prompts: common.prompts, workflow_registry: common.workflow_registry, memory_layout: common.memory_layout, + inject_resident_summary: true, inject_resident_knowledge: true, + inject_resident_workflows: true, pending_scope_snapshot: Arc::new(Mutex::new(None)), extract_in_flight: Arc::new(AtomicBool::new(false)), consolidation_in_flight: Arc::new(AtomicBool::new(false)), @@ -3832,7 +3859,9 @@ where prompts: common.prompts, workflow_registry: common.workflow_registry, memory_layout: common.memory_layout, + inject_resident_summary: true, inject_resident_knowledge: true, + inject_resident_workflows: true, pending_scope_snapshot: Arc::new(Mutex::new(None)), extract_in_flight: Arc::new(AtomicBool::new(false)), consolidation_in_flight: Arc::new(AtomicBool::new(false)), @@ -4507,10 +4536,44 @@ mod build_summary_prompt_tests { assert_eq!(interrupt_system_count, 1); } + #[derive(Clone, Copy)] + struct ResidentInjectionGates { + summary: bool, + knowledge: bool, + workflows: bool, + } + + impl ResidentInjectionGates { + fn all(enabled: bool) -> Self { + Self { + summary: enabled, + knowledge: enabled, + workflows: enabled, + } + } + } + async fn render_system_prompt_with_summary( summary_doc: Option<&str>, memory_config: Option, resident_injection: bool, + ) -> String { + render_system_prompt_with_resident_sections( + summary_doc, + memory_config, + ResidentInjectionGates::all(resident_injection), + false, + false, + ) + .await + } + + async fn render_system_prompt_with_resident_sections( + summary_doc: Option<&str>, + memory_config: Option, + gates: ResidentInjectionGates, + include_knowledge: bool, + include_workflow: bool, ) -> String { let dir = tempfile::tempdir().unwrap(); let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap(); @@ -4520,6 +4583,22 @@ mod build_summary_prompt_tests { std::fs::create_dir_all(pwd.join(".insomnia/memory")).unwrap(); std::fs::write(pwd.join(".insomnia/memory/summary.md"), doc).unwrap(); } + if include_knowledge { + std::fs::create_dir_all(pwd.join(".insomnia/knowledge")).unwrap(); + std::fs::write( + pwd.join(".insomnia/knowledge/resident-policy.md"), + knowledge_doc("knowledge resident desc"), + ) + .unwrap(); + } + if include_workflow { + std::fs::create_dir_all(pwd.join(".insomnia/workflow")).unwrap(); + std::fs::write( + pwd.join(".insomnia/workflow/resident-flow.md"), + workflow_doc("workflow resident desc"), + ) + .unwrap(); + } let mut manifest = minimal_manifest_with_skills(vec![]); manifest.memory = memory_config; @@ -4532,7 +4611,16 @@ mod build_summary_prompt_tests { .memory .as_ref() .map(|mem| memory::WorkspaceLayout::resolve(mem, &pwd)); - pod.set_resident_knowledge_injection(resident_injection); + if let Some(layout) = pod.memory_layout.as_ref() { + pod.workflow_registry = workflow_crate::load_workflows(layout).unwrap(); + } + if gates.summary == gates.knowledge && gates.summary == gates.workflows { + pod.set_resident_injection(gates.summary); + } else { + pod.set_resident_summary_injection(gates.summary); + pod.set_resident_knowledge_injection(gates.knowledge); + pod.set_resident_workflow_injection(gates.workflows); + } let template = SystemPromptTemplate::parse( "$insomnia/default", crate::prompt::loader::PromptLoader::builtins_only(), @@ -4547,6 +4635,16 @@ mod build_summary_prompt_tests { format!("---\nupdated_at: 2026-01-01T00:00:00Z\n---\n{body}") } + fn knowledge_doc(description: &str) -> String { + format!( + "---\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\nkind: policy\ndescription: \"{description}\"\nmodel_invokation: true\nuser_invocable: true\nlast_sources: []\n---\nbody\n", + ) + } + + fn workflow_doc(description: &str) -> String { + format!("---\ndescription: {description}\nmodel_invokation: true\n---\nbody\n") + } + #[tokio::test] async fn resident_summary_body_is_injected_without_frontmatter() { let rendered = render_system_prompt_with_summary( @@ -4607,16 +4705,68 @@ mod build_summary_prompt_tests { } #[tokio::test] - async fn resident_summary_respects_internal_worker_opt_out() { - let rendered = render_system_prompt_with_summary( - Some(&summary_doc("internal opt-out summary body\n")), + async fn resident_summary_gate_false_omits_only_summary() { + let prompt = render_system_prompt_with_resident_sections( + Some(&summary_doc("resident summary marker")), Some(manifest::MemoryConfig::default()), - false, + ResidentInjectionGates { + summary: false, + knowledge: true, + workflows: true, + }, + true, + true, ) .await; - assert!(!rendered.contains("Resident memory summary")); - assert!(!rendered.contains("internal opt-out summary body")); + assert!(!prompt.contains("Resident memory summary")); + assert!(!prompt.contains("resident summary marker")); + assert!(prompt.contains("Resident knowledge")); + assert!(prompt.contains("knowledge resident desc")); + assert!(prompt.contains("Resident workflows")); + assert!(prompt.contains("workflow resident desc")); + } + + #[tokio::test] + async fn knowledge_and_workflow_gates_false_keep_resident_summary() { + let prompt = render_system_prompt_with_resident_sections( + Some(&summary_doc("resident summary marker")), + Some(manifest::MemoryConfig::default()), + ResidentInjectionGates { + summary: true, + knowledge: false, + workflows: false, + }, + true, + true, + ) + .await; + + assert!(prompt.contains("Resident memory summary")); + assert!(prompt.contains("resident summary marker")); + assert!(!prompt.contains("Resident knowledge")); + assert!(!prompt.contains("knowledge resident desc")); + assert!(!prompt.contains("Resident workflows")); + assert!(!prompt.contains("workflow resident desc")); + } + + #[tokio::test] + async fn resident_injection_opt_out_omits_all_resident_sections() { + let prompt = render_system_prompt_with_resident_sections( + Some(&summary_doc("resident summary marker")), + Some(manifest::MemoryConfig::default()), + ResidentInjectionGates::all(false), + true, + true, + ) + .await; + + assert!(!prompt.contains("Resident memory summary")); + assert!(!prompt.contains("resident summary marker")); + assert!(!prompt.contains("Resident knowledge")); + assert!(!prompt.contains("knowledge resident desc")); + assert!(!prompt.contains("Resident workflows")); + assert!(!prompt.contains("workflow resident desc")); } fn minimal_manifest_with_skills(dirs: Vec) -> PodManifest { diff --git a/crates/pod/src/prompt/catalog.rs b/crates/pod/src/prompt/catalog.rs index 0ca271fc..d61b1fd9 100644 --- a/crates/pod/src/prompt/catalog.rs +++ b/crates/pod/src/prompt/catalog.rs @@ -84,12 +84,13 @@ pub enum PodPrompt { /// and `memory/summary.md` has a valid non-empty body. ResidentMemorySummarySection, /// Trailing `## Resident knowledge` section, appended after the - /// resident memory summary when memory is enabled and at least one - /// `knowledge/*` record advertises `model_invokation: true`. + /// resident memory summary when memory is enabled, Knowledge resident + /// injection is enabled, and at least one `knowledge/*` record advertises + /// `model_invokation: true`. ResidentKnowledgeSection, /// Trailing `## Resident workflows` section, appended after resident - /// knowledge when memory is enabled and at least one workflow advertises - /// `model_invokation: true`. + /// knowledge when Workflow resident injection is enabled and at least one + /// workflow advertises `model_invokation: true`. ResidentWorkflowsSection, } diff --git a/crates/pod/src/prompt/system.rs b/crates/pod/src/prompt/system.rs index 4a5b9ce0..700dd06c 100644 --- a/crates/pod/src/prompt/system.rs +++ b/crates/pod/src/prompt/system.rs @@ -395,6 +395,25 @@ mod tests { } } + fn ctx_with_resident_workflows<'a>( + cwd: &'a Path, + scope: &'a Scope, + resident: &'a [ResidentWorkflowEntry], + ) -> SystemPromptContext<'a> { + SystemPromptContext { + now: fixed_now(), + cwd, + language: manifest::defaults::WORKER_LANGUAGE, + scope, + tool_names: Vec::new(), + agents_md: None, + resident_summary: None, + resident_knowledge: None, + resident_workflows: Some(resident), + prompts: test_prompts(), + } + } + /// 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. @@ -688,4 +707,39 @@ mod tests { let pos_resident = rendered.find("## Resident knowledge").unwrap(); assert!(pos_resident > pos_boundaries); } + + #[test] + fn trailing_section_renders_resident_workflows() { + 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 workflows = [ResidentWorkflowEntry { + slug: "resident-flow".to_string(), + description: "workflow resident desc\nwith newline".to_string(), + }]; + let rendered = tmpl + .render(&ctx_with_resident_workflows(dir.path(), &scope, &workflows)) + .unwrap(); + + assert!(rendered.contains("## Resident workflows")); + assert!(rendered.contains("- resident-flow: workflow resident desc with newline")); + let pos_boundaries = rendered.find("## Working boundaries").unwrap(); + let pos_resident = rendered.find("## Resident workflows").unwrap(); + assert!(pos_resident > pos_boundaries); + } + + #[test] + fn trailing_section_omits_empty_resident_workflows() { + 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 workflows: [ResidentWorkflowEntry; 0] = []; + let rendered = tmpl + .render(&ctx_with_resident_workflows(dir.path(), &scope, &workflows)) + .unwrap(); + + assert!(!rendered.contains("Resident workflows")); + } }