メモリー内容のシステムプロンプトへの埋め込みの実装

This commit is contained in:
2026-04-27 18:25:47 +09:00
parent 12c1d55127
commit e8559d4bee
9 changed files with 410 additions and 1 deletions
+2
View File
@@ -8,6 +8,7 @@
pub mod error;
pub mod linter;
pub mod resident;
pub mod schema;
pub mod scope;
pub mod slug;
@@ -16,6 +17,7 @@ pub mod workspace;
pub use error::{LintError, LintWarning, MemoryError};
pub use linter::{LintReport, Linter};
pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge};
pub use scope::deny_write_rules;
pub use slug::Slug;
pub use workspace::WorkspaceLayout;
+163
View File
@@ -0,0 +1,163 @@
//! Collect resident-injection candidates from the workspace.
//!
//! Walks `<workspace>/knowledge/*.md`, returns the records whose
//! frontmatter has `model_invokation: true` as `(slug, description)`
//! pairs sorted by slug. The Pod system-prompt assembler appends them
//! into the trailing section so descriptions sit next to the scope
//! summary and AGENTS.md.
//!
//! Files that fail to read or parse are skipped silently — the Linter
//! 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::workspace::WorkspaceLayout;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResidentKnowledgeEntry {
pub slug: String,
pub description: String,
}
/// Walk `<workspace>/knowledge/*.md` and return entries whose
/// frontmatter has `model_invokation: true`, sorted by slug. A missing
/// `knowledge/` directory yields an empty vec.
pub fn collect_resident_knowledge(layout: &WorkspaceLayout) -> Vec<ResidentKnowledgeEntry> {
let dir = layout.knowledge_dir();
let entries = match std::fs::read_dir(&dir) {
Ok(it) => it,
Err(_) => return Vec::new(),
};
let mut out: Vec<ResidentKnowledgeEntry> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
let slug = match name.strip_suffix(".md") {
Some(s) => s.to_string(),
None => continue,
};
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
let (yaml, _body) = match split_frontmatter(&raw) {
Ok(v) => v,
Err(_) => continue,
};
let fm: KnowledgeFrontmatter = match serde_yaml::from_str(yaml) {
Ok(f) => f,
Err(_) => continue,
};
if fm.model_invokation {
out.push(ResidentKnowledgeEntry {
slug,
description: fm.description,
});
}
}
out.sort_by(|a, b| a.slug.cmp(&b.slug));
out
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use std::path::Path;
use tempfile::TempDir;
fn now() -> String {
Utc::now().to_rfc3339()
}
fn write_knowledge(
dir: &Path,
slug: &str,
description: &str,
model_invokation: bool,
body: &str,
) {
let path = dir.join("knowledge").join(format!("{slug}.md"));
let content = format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nkind: policy\ndescription: \"{description}\"\nmodel_invokation: {flag}\nuser_invocable: true\nlast_sources: []\n---\n{body}",
n = now(),
flag = model_invokation,
);
std::fs::write(path, content).unwrap();
}
fn setup() -> (TempDir, WorkspaceLayout) {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("knowledge")).unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
(dir, layout)
}
#[test]
fn missing_knowledge_dir_returns_empty() {
let dir = TempDir::new().unwrap();
// No knowledge/ directory at all.
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
assert!(collect_resident_knowledge(&layout).is_empty());
}
#[test]
fn picks_only_model_invokation_true() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "alpha", "alpha desc", true, "body\n");
write_knowledge(dir.path(), "beta", "beta desc", false, "body\n");
write_knowledge(dir.path(), "gamma", "gamma desc", true, "body\n");
let got = collect_resident_knowledge(&layout);
assert_eq!(got.len(), 2);
assert_eq!(got[0].slug, "alpha");
assert_eq!(got[0].description, "alpha desc");
assert_eq!(got[1].slug, "gamma");
assert_eq!(got[1].description, "gamma desc");
}
#[test]
fn entries_are_sorted_by_slug() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "zeta", "z", true, "");
write_knowledge(dir.path(), "alpha", "a", true, "");
write_knowledge(dir.path(), "mu", "m", true, "");
let got = collect_resident_knowledge(&layout);
let slugs: Vec<&str> = got.iter().map(|e| e.slug.as_str()).collect();
assert_eq!(slugs, vec!["alpha", "mu", "zeta"]);
}
#[test]
fn malformed_frontmatter_is_skipped() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "good", "ok", true, "");
// Garbage in frontmatter — must be skipped, not panic.
std::fs::write(
dir.path().join("knowledge/bad.md"),
"---\nthis is not yaml: : :\n---\nbody\n",
)
.unwrap();
let got = collect_resident_knowledge(&layout);
assert_eq!(got.len(), 1);
assert_eq!(got[0].slug, "good");
}
#[test]
fn non_md_files_ignored() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "good", "ok", true, "");
std::fs::write(dir.path().join("knowledge/note.txt"), "not markdown\n").unwrap();
let got = collect_resident_knowledge(&layout);
assert_eq!(got.len(), 1);
}
}
+1
View File
@@ -646,6 +646,7 @@ permission = "write"
scope: &scope,
tool_names: Vec::new(),
agents_md: None,
resident_knowledge: None,
prompts: &catalog,
};
let rendered = tmpl.render(&ctx).unwrap();
+51
View File
@@ -118,6 +118,12 @@ pub struct Pod<C: LlmClient, St: Store> {
/// [`Self::from_manifest`], or defaults to the builtin pack when a
/// Pod is constructed through lower-level paths that have no loader.
prompts: Arc<PromptCatalog>,
/// When true (default), the system-prompt assembler walks
/// `<workspace>/knowledge/*` and appends a `## Resident knowledge`
/// section listing records with `model_invokation: true`.
/// Phase 2 (consolidation) workers set this to false so the
/// agentic worker pulls knowledge through the search tools instead.
inject_resident_knowledge: bool,
}
impl<C: LlmClient, St: Store> Pod<C, St> {
@@ -164,6 +170,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
scope_allocation: None,
callback_socket: None,
prompts,
inject_resident_knowledge: true,
};
pod.apply_prune_from_manifest();
Ok(pod)
@@ -177,6 +184,20 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.system_prompt_template = Some(template);
}
/// Toggle the resident-knowledge section of the system prompt.
///
/// Default `true`: when memory is enabled in the manifest, the
/// assembler walks `<workspace>/knowledge/*` and lists records with
/// `model_invokation: true`. Phase 2 (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.
pub fn set_resident_knowledge_injection(&mut self, enabled: bool) {
self.inject_resident_knowledge = enabled;
}
/// Restore a Pod from a persisted session.
/// Shared handle to the prompt catalog. Cheap to clone (`Arc`).
pub fn prompts(&self) -> &Arc<PromptCatalog> {
@@ -237,6 +258,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
scope_allocation: None,
callback_socket: None,
prompts,
inject_resident_knowledge: true,
};
pod.apply_prune_from_manifest();
Ok(pod)
@@ -538,12 +560,39 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
);
}
}
// Resident-injection collection: only when memory is enabled in
// the manifest AND this Pod opts in (Phase 2 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.manifest
.memory
.as_ref()
.map(|mem| {
let workspace_root = mem
.workspace_root
.clone()
.unwrap_or_else(|| self.pwd.clone());
let layout = memory::WorkspaceLayout::new(workspace_root);
memory::collect_resident_knowledge(&layout)
})
.unwrap_or_default()
} else {
Vec::new()
};
let resident_slice: Option<&[memory::ResidentKnowledgeEntry]> =
if self.inject_resident_knowledge && self.manifest.memory.is_some() {
Some(&resident)
} else {
None
};
let ctx = SystemPromptContext {
now: chrono::Utc::now(),
cwd: &self.pwd,
scope: &self.scope,
tool_names,
agents_md: agents_md_read.body,
resident_knowledge: resident_slice,
prompts: &self.prompts,
};
let rendered = template
@@ -1257,6 +1306,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
scope_allocation: Some(scope_allocation),
callback_socket: None,
prompts,
inject_resident_knowledge: true,
};
pod.apply_prune_from_manifest();
Ok(pod)
@@ -1320,6 +1370,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
scope_allocation: Some(scope_allocation),
callback_socket: Some(callback_socket),
prompts,
inject_resident_knowledge: true,
};
pod.apply_prune_from_manifest();
Ok(pod)
+16
View File
@@ -75,6 +75,10 @@ pub enum PodPrompt {
/// Trailing `## Project instructions (AGENTS.md)` section, appended
/// after the scope summary when an AGENTS.md is present.
AgentsMdSection,
/// Trailing `## Resident knowledge` section, appended after the
/// AGENTS.md section when memory is enabled and at least one
/// `knowledge/*` record advertises `model_invokation: true`.
ResidentKnowledgeSection,
}
impl PodPrompt {
@@ -86,6 +90,7 @@ impl PodPrompt {
Self::InterruptSystemNote => "interrupt_system_note",
Self::WorkingBoundariesSection => "working_boundaries_section",
Self::AgentsMdSection => "agents_md_section",
Self::ResidentKnowledgeSection => "resident_knowledge_section",
}
}
@@ -99,6 +104,7 @@ impl PodPrompt {
PodPrompt::InterruptSystemNote,
PodPrompt::WorkingBoundariesSection,
PodPrompt::AgentsMdSection,
PodPrompt::ResidentKnowledgeSection,
];
pub const KEYS: &'static [&'static str] = &[
@@ -108,6 +114,7 @@ impl PodPrompt {
"interrupt_system_note",
"working_boundaries_section",
"agents_md_section",
"resident_knowledge_section",
];
}
@@ -317,6 +324,15 @@ impl PromptCatalog {
pub fn agents_md_section(&self, agents_md: &str) -> Result<String, CatalogError> {
self.render(PodPrompt::AgentsMdSection, single("agents_md", agents_md))
}
/// 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> {
self.render(
PodPrompt::ResidentKnowledgeSection,
single("entries", entries),
)
}
}
fn single(key: &'static str, value: &str) -> Value {
+113 -1
View File
@@ -18,6 +18,7 @@ use std::sync::Arc;
use chrono::{DateTime, SecondsFormat, Utc};
use manifest::Scope;
use memory::ResidentKnowledgeEntry;
use minijinja::value::Value;
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
use thiserror::Error;
@@ -117,7 +118,13 @@ impl SystemPromptTemplate {
let body = tmpl
.render(ctx.to_minijinja_value())
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
append_trailing_section(&body, ctx.prompts, ctx.scope, ctx.agents_md.as_deref())
append_trailing_section(
&body,
ctx.prompts,
ctx.scope,
ctx.agents_md.as_deref(),
ctx.resident_knowledge,
)
}
}
@@ -143,6 +150,11 @@ pub struct SystemPromptContext<'a> {
/// Not visible from the template; consumed by the trailing-section
/// formatter in [`SystemPromptTemplate::render`].
pub agents_md: Option<String>,
/// Resident-injection candidates from `<workspace>/knowledge/*` whose
/// frontmatter has `model_invokation: true`. `None` disables the
/// section entirely (memory disabled, or a Phase 2 worker that opts
/// out); `Some(&[])` also yields no section.
pub resident_knowledge: Option<&'a [ResidentKnowledgeEntry]>,
/// Catalog used to render the fixed trailing section headers.
/// Passed by reference so callers do not give up ownership across
/// the short-lived render borrow.
@@ -190,6 +202,7 @@ pub fn append_trailing_section(
prompts: &PromptCatalog,
scope: &Scope,
agents_md: Option<&str>,
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
) -> Result<String, SystemPromptError> {
let mut out = String::with_capacity(body.len() + 256);
out.push_str(body);
@@ -207,6 +220,15 @@ pub fn append_trailing_section(
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
if let Some(entries) = resident_knowledge {
if !entries.is_empty() {
out.push('\n');
let formatted = format_resident_knowledge_entries(entries);
let section = prompts.resident_knowledge_section(&formatted)?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
}
// Canonicalise the tail so the emitted prompt has a single form
// regardless of how individual templates chose to end.
while out.ends_with('\n') || out.ends_with(' ') {
@@ -215,6 +237,28 @@ pub fn append_trailing_section(
Ok(out)
}
/// `- <slug>: <description>` per line. Description newlines are folded
/// to spaces so a single entry stays on one row in the rendered prompt.
fn format_resident_knowledge_entries(entries: &[ResidentKnowledgeEntry]) -> String {
let mut out = String::new();
for (i, e) in entries.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str("- ");
out.push_str(&e.slug);
out.push_str(": ");
for ch in e.description.chars() {
if ch == '\n' || ch == '\r' {
out.push(' ');
} else {
out.push(ch);
}
}
}
out
}
/// Bridge used by [`Pod::ensure_system_prompt_materialized`] so tests
/// can construct a synthetic context without going through a full Pod.
#[doc(hidden)]
@@ -257,6 +301,23 @@ mod tests {
scope,
tool_names: tools,
agents_md,
resident_knowledge: None,
prompts: test_prompts(),
}
}
fn ctx_with_resident<'a>(
cwd: &'a Path,
scope: &'a Scope,
resident: &'a [ResidentKnowledgeEntry],
) -> SystemPromptContext<'a> {
SystemPromptContext {
now: fixed_now(),
cwd,
scope,
tool_names: Vec::new(),
agents_md: None,
resident_knowledge: Some(resident),
prompts: test_prompts(),
}
}
@@ -464,4 +525,55 @@ mod tests {
assert!(!rendered.contains("AGENTS.md"));
assert!(!rendered.contains("Project instructions"));
}
#[test]
fn trailing_section_omits_resident_knowledge_when_none() {
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(dir.path(), &scope, vec![], None)).unwrap();
assert!(!rendered.contains("Resident knowledge"));
}
#[test]
fn trailing_section_omits_resident_knowledge_when_empty_slice() {
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_resident(dir.path(), &scope, &[]))
.unwrap();
assert!(!rendered.contains("Resident knowledge"));
}
#[test]
fn trailing_section_renders_resident_knowledge_entries() {
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 = vec![
ResidentKnowledgeEntry {
slug: "alpha".into(),
description: "first record".into(),
},
ResidentKnowledgeEntry {
slug: "beta".into(),
description: "second record\nwith newline".into(),
},
];
let rendered = tmpl
.render(&ctx_with_resident(dir.path(), &scope, &entries))
.unwrap();
assert!(rendered.contains("## Resident knowledge"));
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"));
// 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);
}
}