merge: memory summary resident injection
This commit is contained in:
commit
9435f44d53
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@ pub struct MemoryConfig {
|
|||
/// Ignored when the request omits `query`. `None` ⇒ tool default (3).
|
||||
#[serde(default)]
|
||||
pub query_excerpt_lines: Option<usize>,
|
||||
/// Whether the body of `memory/summary.md` is exposed in the resident
|
||||
/// system-prompt section. `None` ⇒ enabled.
|
||||
#[serde(default)]
|
||||
pub inject_summary: Option<bool>,
|
||||
/// 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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
//! Workspace knowledge enumeration helpers.
|
||||
//! Workspace memory resident-enumeration helpers.
|
||||
//!
|
||||
//! Two surfaces, both walking `<workspace>/.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
|
||||
//! `<workspace>/.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<ResidentKnowl
|
|||
out
|
||||
}
|
||||
|
||||
/// Read `<workspace>/.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<String> {
|
||||
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 `<workspace>/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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -314,12 +314,18 @@ pub struct Pod<C: LlmClient, St: Store> {
|
|||
/// Memory workspace layout used by the workflow resolver to load required
|
||||
/// Knowledge records by exact slug.
|
||||
memory_layout: Option<memory::WorkspaceLayout>,
|
||||
/// When true (default), the system-prompt assembler walks
|
||||
/// `<workspace>/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 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
|
||||
/// 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.
|
||||
|
|
@ -425,7 +431,9 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Pod<C, St> {
|
|||
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(),
|
||||
|
|
@ -569,7 +577,9 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
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)),
|
||||
|
|
@ -593,20 +603,33 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
self.system_prompt_template = Some(template);
|
||||
}
|
||||
|
||||
/// Toggle the resident-knowledge section of the system prompt.
|
||||
/// Toggle all resident sections in the system prompt.
|
||||
///
|
||||
/// Default `true`: when memory is enabled in the manifest, the
|
||||
/// assembler walks `<workspace>/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.
|
||||
/// 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<PromptCatalog> {
|
||||
&self.prompts
|
||||
|
|
@ -1159,32 +1182,48 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
n.alert(AlertLevel::Warn, AlertSource::AgentsMd, warning);
|
||||
}
|
||||
}
|
||||
// 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<memory::ResidentKnowledgeEntry> = if self.inject_resident_knowledge {
|
||||
self.memory_layout
|
||||
// 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 memory_layout = self.memory_layout.as_ref();
|
||||
let inject_summary = self.inject_resident_summary
|
||||
&& memory_layout.is_some()
|
||||
&& self
|
||||
.manifest
|
||||
.memory
|
||||
.as_ref()
|
||||
.and_then(|m| m.inject_summary)
|
||||
.unwrap_or(true);
|
||||
let resident_summary: Option<String> = if inject_summary {
|
||||
memory_layout.and_then(memory::collect_resident_summary)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let inject_resident_knowledge = self.inject_resident_knowledge && memory_layout.is_some();
|
||||
let resident: Vec<memory::ResidentKnowledgeEntry> = if inject_resident_knowledge {
|
||||
memory_layout
|
||||
.map(memory::collect_resident_knowledge)
|
||||
.unwrap_or_default()
|
||||
} 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_resident_knowledge
|
||||
{
|
||||
Some(&resident)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let resident_workflows: Vec<workflow_crate::ResidentWorkflowEntry> =
|
||||
if self.inject_resident_knowledge && self.memory_layout.is_some() {
|
||||
if self.inject_resident_workflows {
|
||||
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 self.inject_resident_workflows {
|
||||
Some(&resident_workflows)
|
||||
} else {
|
||||
None
|
||||
|
|
@ -1200,6 +1239,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
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,
|
||||
|
|
@ -3241,12 +3281,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
});
|
||||
|
||||
// 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(),
|
||||
|
|
@ -3563,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)),
|
||||
|
|
@ -3640,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)),
|
||||
|
|
@ -3816,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)),
|
||||
|
|
@ -4491,6 +4536,239 @@ 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<manifest::MemoryConfig>,
|
||||
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<manifest::MemoryConfig>,
|
||||
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();
|
||||
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();
|
||||
}
|
||||
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;
|
||||
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));
|
||||
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(),
|
||||
)
|
||||
.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}")
|
||||
}
|
||||
|
||||
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(
|
||||
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_gate_false_omits_only_summary() {
|
||||
let prompt = render_system_prompt_with_resident_sections(
|
||||
Some(&summary_doc("resident summary marker")),
|
||||
Some(manifest::MemoryConfig::default()),
|
||||
ResidentInjectionGates {
|
||||
summary: false,
|
||||
knowledge: true,
|
||||
workflows: true,
|
||||
},
|
||||
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 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<PathBuf>) -> PodManifest {
|
||||
// Construct the smallest possible PodManifest that resolves; only
|
||||
// the `skills` field matters for `skill_dir_read_rules`.
|
||||
|
|
|
|||
|
|
@ -79,13 +79,18 @@ 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
|
||||
/// `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,
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +105,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 +123,7 @@ impl PodPrompt {
|
|||
PodPrompt::InterruptSystemNote,
|
||||
PodPrompt::WorkingBoundariesSection,
|
||||
PodPrompt::AgentsMdSection,
|
||||
PodPrompt::ResidentMemorySummarySection,
|
||||
PodPrompt::ResidentKnowledgeSection,
|
||||
PodPrompt::ResidentWorkflowsSection,
|
||||
];
|
||||
|
|
@ -130,6 +137,7 @@ impl PodPrompt {
|
|||
"interrupt_system_note",
|
||||
"working_boundaries_section",
|
||||
"agents_md_section",
|
||||
"resident_memory_summary_section",
|
||||
"resident_knowledge_section",
|
||||
"resident_workflows_section",
|
||||
];
|
||||
|
|
@ -352,6 +360,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<String, CatalogError> {
|
||||
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<String, CatalogError> {
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// The body of `<workspace>/.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 `<workspace>/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<String, SystemPromptError> {
|
||||
|
|
@ -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,12 +388,32 @@ mod tests {
|
|||
scope,
|
||||
tool_names: Vec::new(),
|
||||
agents_md: None,
|
||||
resident_summary: None,
|
||||
resident_knowledge: Some(resident),
|
||||
resident_workflows: None,
|
||||
prompts: test_prompts(),
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
@ -568,6 +623,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");
|
||||
|
|
@ -618,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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user