Promptを一元管理するファイルから参照する実装

This commit is contained in:
2026-04-22 17:43:05 +09:00
parent 0c1276b730
commit b8d368f5e5
21 changed files with 1107 additions and 253 deletions
+18 -1
View File
@@ -37,6 +37,11 @@ pub struct PodManifestConfig {
pub struct PodMetaConfig {
#[serde(default)]
pub name: Option<String>,
/// Optional `PromptCatalog` manifest pack override. See
/// [`crate::PodMeta::prompt_pack`] for semantics. Relative paths
/// are resolved through [`PodManifestConfig::resolve_paths`].
#[serde(default)]
pub prompt_pack: Option<PathBuf>,
}
/// Partial-form of [`ModelConfig`]. カスケード層で個別に与えられる。
@@ -159,6 +164,9 @@ impl PodManifestConfig {
base.display()
);
resolve_auth_file(&mut self.model.auth, base);
if let Some(ref mut pack) = self.pod.prompt_pack {
*pack = join_if_relative(base, pack);
}
for rule in &mut self.scope.allow {
rule.target = join_if_relative(base, &rule.target);
}
@@ -196,6 +204,7 @@ impl PodMetaConfig {
fn merge(self, upper: Self) -> Self {
Self {
name: upper.name.or(self.name),
prompt_pack: upper.prompt_pack.or(self.prompt_pack),
}
}
}
@@ -332,6 +341,10 @@ impl TryFrom<PodManifestConfig> for PodManifest {
.pod
.name
.ok_or(ResolveError::MissingField("pod.name"))?;
let prompt_pack = cfg.pod.prompt_pack;
if let Some(ref p) = prompt_pack {
ensure_absolute("pod.prompt_pack", p)?;
}
let model = resolve_model(
cfg.model,
@@ -406,7 +419,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
.transpose()?;
Ok(PodManifest {
pod: PodMeta { name },
pod: PodMeta { name, prompt_pack },
model,
worker,
scope: cfg.scope,
@@ -435,6 +448,7 @@ mod tests {
PodManifestConfig {
pod: PodMetaConfig {
name: Some("test".into()),
prompt_pack: None,
},
model: ModelConfigPartial {
scheme: Some(SchemeKind::Anthropic),
@@ -554,6 +568,7 @@ mod tests {
let lower = PodManifestConfig {
pod: PodMetaConfig {
name: Some("lower".into()),
prompt_pack: None,
},
model: ModelConfigPartial {
model_id: Some("lower-model".into()),
@@ -564,6 +579,7 @@ mod tests {
let upper = PodManifestConfig {
pod: PodMetaConfig {
name: Some("upper".into()),
prompt_pack: None,
},
..Default::default()
};
@@ -725,6 +741,7 @@ permission = "write"
let overlay = PodManifestConfig {
pod: PodMetaConfig {
name: Some("x".into()),
prompt_pack: None,
},
model: ModelConfigPartial {
scheme: Some(SchemeKind::Anthropic),
+14
View File
@@ -13,6 +13,7 @@ pub use scope::{Scope, ScopeError};
use std::collections::HashMap;
use std::num::NonZeroU32;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
@@ -36,6 +37,19 @@ pub struct PodManifest {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PodMeta {
pub name: String,
/// Optional path to a TOML override file read as the top layer of
/// `pod::PromptCatalog`. Subject to the same relative-path
/// resolution as other manifest paths (joined against the
/// manifest's base directory). `None` leaves the 4th overlay layer
/// empty; auto-discovered user and workspace packs still apply.
///
/// Note: unlike `worker.instruction`, this is a plain filesystem
/// path — not a `$prefix/` prompt reference. Pack files carry
/// structured TOML data, while `worker.instruction` points at a
/// minijinja `.md` template; the two use different addressing
/// conventions on purpose.
#[serde(default)]
pub prompt_pack: Option<PathBuf>,
}
/// Worker-level configuration embedded in the manifest.
+3
View File
@@ -33,3 +33,6 @@ futures = "0.3.32"
session-store = { path = "../session-store" }
tempfile = "3.27.0"
tokio = { version = "1.49", features = ["macros", "rt-multi-thread", "time"] }
[build-dependencies]
toml = "1.1.2"
+54
View File
@@ -0,0 +1,54 @@
//! Emits `$OUT_DIR/internal_keys.rs` containing the sorted list of keys
//! present in `resources/prompts/internal.toml`. The generated slice is
//! included into `src/prompts.rs` where a `const _` assertion compares
//! it bidirectionally against the `PodPrompt` enum's own key list, so
//! that a mismatch fails the build (see ticket: pod-prompt-catalog).
use std::env;
use std::fs;
use std::path::PathBuf;
fn main() {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
let toml_path = PathBuf::from(&manifest_dir)
.join("..")
.join("..")
.join("resources")
.join("prompts")
.join("internal.toml");
println!("cargo:rerun-if-changed={}", toml_path.display());
println!("cargo:rerun-if-changed=build.rs");
let toml_str = fs::read_to_string(&toml_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", toml_path.display()));
let parsed: toml::Value = toml::from_str(&toml_str)
.unwrap_or_else(|e| panic!("failed to parse {}: {e}", toml_path.display()));
let prompt_section = parsed
.get("prompt")
.and_then(|v| v.as_table())
.unwrap_or_else(|| {
panic!(
"{} must contain a `[prompt]` table",
toml_path.display()
)
});
let mut keys: Vec<String> = prompt_section.keys().cloned().collect();
keys.sort();
let out_dir = env::var("OUT_DIR").expect("OUT_DIR");
let out_path = PathBuf::from(out_dir).join("internal_keys.rs");
let mut code = String::from("pub(crate) const INTERNAL_KEYS: &[&str] = &[\n");
for k in &keys {
code.push_str(" ");
code.push_str(&format!("{k:?}"));
code.push_str(",\n");
}
code.push_str("];\n");
fs::write(&out_path, code)
.unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display()));
}
+18 -16
View File
@@ -16,16 +16,17 @@ use tracing::warn;
/// well within typical provider rate limits.
pub(crate) const AGENTS_MD_LIMIT: usize = 64 * 1024;
const TRUNCATION_NOTICE: &str = "\n\n[truncated: AGENTS.md exceeded 64KB limit]";
/// Outcome of an `AGENTS.md` ingestion attempt.
///
/// `body` carries the text that should be handed to the template
/// engine (if any); `warnings` are short human-readable messages that
/// Pod forwards to the user-facing notification channel. The caller
/// also gets `tracing::warn!` lines for the developer log.
/// engine (if any); `truncated` signals that the caller should append
/// the `PodPrompt::AgentsMdTruncationNotice` text. `warnings` are short
/// human-readable messages that Pod forwards to the user-facing
/// notification channel. The caller also gets `tracing::warn!` lines
/// for the developer log.
pub(crate) struct AgentsMdResult {
pub body: Option<String>,
pub truncated: bool,
pub warnings: Vec<String>,
}
@@ -45,6 +46,7 @@ pub(crate) fn read_agents_md(cwd: &Path) -> AgentsMdResult {
Err(e) if e.kind() == ErrorKind::NotFound => {
return AgentsMdResult {
body: None,
truncated: false,
warnings,
};
}
@@ -53,6 +55,7 @@ pub(crate) fn read_agents_md(cwd: &Path) -> AgentsMdResult {
warnings.push(format!("failed to open AGENTS.md ({}): {}", path.display(), e));
return AgentsMdResult {
body: None,
truncated: false,
warnings,
};
}
@@ -67,6 +70,7 @@ pub(crate) fn read_agents_md(cwd: &Path) -> AgentsMdResult {
warnings.push(format!("failed to read AGENTS.md ({}): {}", path.display(), e));
return AgentsMdResult {
body: None,
truncated: false,
warnings,
};
}
@@ -102,12 +106,12 @@ pub(crate) fn read_agents_md(cwd: &Path) -> AgentsMdResult {
));
return AgentsMdResult {
body: None,
truncated: false,
warnings,
};
}
};
let mut text = text;
if truncated {
warn!(
path = %path.display(),
@@ -119,11 +123,11 @@ pub(crate) fn read_agents_md(cwd: &Path) -> AgentsMdResult {
path.display(),
AGENTS_MD_LIMIT
));
text.push_str(TRUNCATION_NOTICE);
}
AgentsMdResult {
body: Some(text),
truncated,
warnings,
}
}
@@ -156,11 +160,10 @@ mod tests {
fs::write(dir.path().join("AGENTS.md"), &body).unwrap();
let result = read_agents_md(dir.path());
assert!(result.truncated);
let got = result.body.expect("some");
assert!(got.ends_with(TRUNCATION_NOTICE));
let prefix = got.strip_suffix(TRUNCATION_NOTICE).unwrap();
assert_eq!(prefix.len(), AGENTS_MD_LIMIT);
assert!(prefix.chars().all(|c| c == 'a'));
assert_eq!(got.len(), AGENTS_MD_LIMIT);
assert!(got.chars().all(|c| c == 'a'));
assert_eq!(result.warnings.len(), 1);
}
@@ -171,9 +174,9 @@ mod tests {
fs::write(dir.path().join("AGENTS.md"), &body).unwrap();
let result = read_agents_md(dir.path());
assert!(!result.truncated);
let got = result.body.expect("some");
assert_eq!(got.len(), AGENTS_MD_LIMIT);
assert!(!got.contains("truncated"));
assert!(result.warnings.is_empty());
}
@@ -188,12 +191,11 @@ mod tests {
fs::write(dir.path().join("AGENTS.md"), &body).unwrap();
let result = read_agents_md(dir.path());
assert!(result.truncated);
let got = result.body.expect("some");
assert!(got.ends_with(TRUNCATION_NOTICE));
let prefix = got.strip_suffix(TRUNCATION_NOTICE).unwrap();
// The partial 'あ' must have been dropped, leaving only the ASCII prefix.
assert_eq!(prefix.len(), AGENTS_MD_LIMIT - 1);
assert!(prefix.chars().all(|c| c == 'a'));
assert_eq!(got.len(), AGENTS_MD_LIMIT - 1);
assert!(got.chars().all(|c| c == 'a'));
assert_eq!(result.warnings.len(), 1);
}
+27 -1
View File
@@ -78,6 +78,12 @@ pub struct PodFactory {
/// `<project_root>/.insomnia/prompts/` — co-located with the
/// project manifest when loaded.
project_prompts_dir: Option<PathBuf>,
/// `<user_manifest_dir>/prompts.toml`, sibling of the user
/// prompts library. Consumed by the prompt catalog's user layer.
user_pack_file: Option<PathBuf>,
/// `<project_root>/.insomnia/prompts.toml`, sibling of the project
/// prompts library. Consumed by the prompt catalog's workspace layer.
project_pack_file: Option<PathBuf>,
}
impl PodFactory {
@@ -97,6 +103,7 @@ impl PodFactory {
let base = manifest_base(&path)?;
self.user = Some((read_config_file(&path)?, base.clone()));
self.user_prompts_dir = Some(base.join("prompts"));
self.user_pack_file = Some(base.join("prompts.toml"));
}
Ok(self)
}
@@ -108,6 +115,7 @@ impl PodFactory {
let base = manifest_base(path)?;
self.user = Some((read_config_file(path)?, base.clone()));
self.user_prompts_dir = Some(base.join("prompts"));
self.user_pack_file = Some(base.join("prompts.toml"));
Ok(self)
}
@@ -150,6 +158,7 @@ impl PodFactory {
.unwrap_or_else(|| insomnia_dir.clone());
self.project = Some((read_config_file(path)?, project_root));
self.project_prompts_dir = Some(insomnia_dir.join("prompts"));
self.project_pack_file = Some(insomnia_dir.join("prompts.toml"));
Ok(())
}
@@ -185,7 +194,22 @@ impl PodFactory {
.as_ref()
.filter(|p| p.is_dir())
.cloned();
PromptLoader::new(user, project)
// Pack file filters: `.is_file()` keeps the loader's view
// consistent with the catalog loader, which skips missing packs
// silently. An existing but non-file path (e.g. a directory
// named `prompts.toml`) is also elided here and will surface
// only when a manifest pack explicitly references it.
let user_pack = self
.user_pack_file
.as_ref()
.filter(|p| p.is_file())
.cloned();
let project_pack = self
.project_pack_file
.as_ref()
.filter(|p| p.is_file())
.cloned();
PromptLoader::new(user, project).with_pack_files(user_pack, project_pack)
}
/// Merge all installed layers, convert the result to a validated
@@ -640,12 +664,14 @@ permission = "write"
deny: Vec::new(),
};
let scope = Scope::from_config(&scope_cfg).unwrap();
let catalog = crate::prompts::PromptCatalog::builtins_only().unwrap();
let ctx = SystemPromptContext {
now: chrono::Utc::now(),
cwd: &root,
scope: &scope,
tool_names: Vec::new(),
agents_md: None,
prompts: &catalog,
};
let rendered = tmpl.render(&ctx).unwrap();
assert!(
+39 -18
View File
@@ -14,10 +14,8 @@ use llm_worker::llm_client::client::LlmClient;
use session_store::Store;
use crate::pod::{Pod, PodError, PodRunResult};
const INTERRUPT_TOOL_RESULT_SUMMARY: &str = "[Interrupted by user]";
const INTERRUPT_SYSTEM_NOTE: &str =
"[The previous turn was interrupted by the user. The user's next request follows.]";
#[cfg(test)]
use crate::prompts::PromptCatalog;
impl<C: LlmClient, St: Store> Pod<C, St> {
/// Close out the current (paused) turn and start a new one with `input`.
@@ -29,19 +27,28 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
&mut self,
input: impl Into<String>,
) -> Result<PodRunResult, PodError> {
let closures: Vec<Item> = orphan_tool_result_closures(self.worker().history());
let tool_result_summary = self
.prompts()
.interrupt_tool_result_summary()
.map_err(PodError::from)?;
let system_note = self
.prompts()
.interrupt_system_note()
.map_err(PodError::from)?;
let closures: Vec<Item> =
orphan_tool_result_closures(self.worker().history(), &tool_result_summary);
if !closures.is_empty() {
self.worker_mut().extend_history(closures);
}
self.worker_mut()
.push_item(Item::system_message(INTERRUPT_SYSTEM_NOTE));
self.worker_mut().push_item(Item::system_message(system_note));
self.run(input).await
}
}
/// Build synthetic `Item::ToolResult` items for every unanswered
/// `Item::ToolCall` in `history`, preserving order.
fn orphan_tool_result_closures(history: &[Item]) -> Vec<Item> {
fn orphan_tool_result_closures(history: &[Item], summary: &str) -> Vec<Item> {
let mut answered: std::collections::HashSet<&str> = std::collections::HashSet::new();
for item in history {
if let Item::ToolResult { call_id, .. } = item {
@@ -52,16 +59,24 @@ fn orphan_tool_result_closures(history: &[Item]) -> Vec<Item> {
for item in history {
if let Item::ToolCall { call_id, .. } = item {
if !answered.contains(call_id.as_str()) {
out.push(Item::tool_result(
call_id.clone(),
INTERRUPT_TOOL_RESULT_SUMMARY,
));
out.push(Item::tool_result(call_id.clone(), summary));
}
}
}
out
}
/// Test-only helper to surface the canonical interrupt tool-result
/// summary without round-tripping through a Pod — used by tests in
/// this module that validate the closure logic.
#[cfg(test)]
fn interrupt_tool_result_summary() -> String {
PromptCatalog::builtins_only()
.unwrap()
.interrupt_tool_result_summary()
.unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -72,7 +87,8 @@ mod tests {
Item::user_message("hi"),
Item::assistant_message("hello"),
];
assert!(orphan_tool_result_closures(&history).is_empty());
let summary = interrupt_tool_result_summary();
assert!(orphan_tool_result_closures(&history, &summary).is_empty());
}
#[test]
@@ -81,20 +97,24 @@ mod tests {
Item::tool_call("c1", "Read", "{}"),
Item::tool_result("c1", "ok"),
];
assert!(orphan_tool_result_closures(&history).is_empty());
let summary = interrupt_tool_result_summary();
assert!(orphan_tool_result_closures(&history, &summary).is_empty());
}
#[test]
fn unanswered_call_becomes_closure() {
let history = vec![Item::tool_call("c1", "Read", "{}")];
let out = orphan_tool_result_closures(&history);
let summary = interrupt_tool_result_summary();
let out = orphan_tool_result_closures(&history, &summary);
assert_eq!(out.len(), 1);
match &out[0] {
Item::ToolResult {
call_id, summary, ..
call_id,
summary: got,
..
} => {
assert_eq!(call_id, "c1");
assert_eq!(summary, INTERRUPT_TOOL_RESULT_SUMMARY);
assert_eq!(got, &summary);
}
other => panic!("expected ToolResult, got {other:?}"),
}
@@ -108,7 +128,8 @@ mod tests {
Item::tool_result("c1", "ok"),
Item::tool_call("c3", "Grep", "{}"),
];
let out = orphan_tool_result_closures(&history);
let summary = interrupt_tool_result_summary();
let out = orphan_tool_result_closures(&history, &summary);
let ids: Vec<&str> = out
.iter()
.map(|i| match i {
+2
View File
@@ -19,6 +19,7 @@ mod notification_buffer;
mod pod;
mod pod_interceptor;
mod prompt_loader;
mod prompts;
mod prune;
mod system_prompt;
mod token_counter;
@@ -35,6 +36,7 @@ pub use manifest::{
};
pub use pod::{Pod, PodError, PodRunResult, apply_worker_manifest};
pub use prompt_loader::PromptLoader;
pub use prompts::{CatalogError, PodPrompt, PromptCatalog};
pub use protocol::{ErrorCode, Event, Method, TurnResult};
pub use provider::{ProviderError, build_client};
pub use runtime_dir::RuntimeDir;
+13 -11
View File
@@ -11,6 +11,8 @@ use std::sync::{Arc, Mutex};
use llm_worker::Item;
use tracing::warn;
use crate::prompts::{CatalogError, PromptCatalog};
/// Maximum queued notifications. Oldest entries are dropped beyond this.
const CAPACITY: usize = 128;
@@ -69,16 +71,15 @@ impl NotificationBuffer {
}
/// Format a single pending notification into the `Item::system_message`
/// that gets injected into the per-request context.
pub(crate) fn format_notification(n: &PendingNotification) -> Item {
let text = format!(
"[Notification]\n{message}\n\n\
This is a notification, not a blocking request. \
If you are in the middle of a task, continue your current work \
and address this at a natural stopping point.",
message = n.message,
);
Item::system_message(text)
/// that gets injected into the per-request context. The wrapper body
/// comes from `PodPrompt::NotifyWrapper` so the surrounding phrasing
/// can be customised via a prompt pack (translation, tone, ...).
pub(crate) fn format_notification(
n: &PendingNotification,
prompts: &PromptCatalog,
) -> Result<Item, CatalogError> {
let text = prompts.notify_wrapper(&n.message)?;
Ok(Item::system_message(text))
}
#[cfg(test)]
@@ -115,7 +116,8 @@ mod tests {
let n = PendingNotification {
message: "hello".into(),
};
let item = format_notification(&n);
let catalog = PromptCatalog::builtins_only().unwrap();
let item = format_notification(&n, &catalog).unwrap();
let text = item.as_text().unwrap_or_default().to_string();
assert!(text.contains("[Notification]"));
assert!(text.contains("hello"));
+46 -35
View File
@@ -23,6 +23,7 @@ use crate::notification_buffer::NotificationBuffer;
use crate::notifier::Notifier;
use crate::pod_interceptor::PodInterceptor;
use crate::prompt_loader::PromptLoader;
use crate::prompts::{CatalogError, PromptCatalog};
use crate::runtime_dir;
use crate::scope_lock::{self, ScopeAllocationGuard, ScopeLockError};
use crate::system_prompt::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
@@ -47,37 +48,6 @@ impl Hook<PreLlmRequest> for UsageTrackingHook {
}
}
const SUMMARY_SYSTEM_PROMPT: &str = "\
You are a context compaction assistant. Your job is to hand the next session a \
structured summary plus pointers to the files it actually needs.\n\n\
Tools you can call:\n\
- `read_file(file_path, offset?, limit?)` — inspect referenced files before deciding.\n\
- `mark_read_required(file_path, offset?, limit?)` — inject a file's contents into the \
next session as an auto-read system message. Counts against `auto_read_budget`.\n\
- `add_reference(file_path)` — record a file path the next session should know about \
without embedding its contents.\n\
- `write_summary(text)` — deliver the final structured summary. May be called multiple \
times; only the last call is kept.\n\n\
Always finish by calling `write_summary`. Produce the summary in this exact format:\n\n\
## Completed Tasks\n\
### (task name)\n\
- what was done (use concrete type / file / function names)\n\
- gotchas or facts that came up\n\n\
## Active Task\n\
### (task name)\n\
- goal\n\
- current state (what is done / not done)\n\
- next step\n\n\
## Key Decisions\n\
- (decision) — (reason)\n\n\
## User Directives\n\
- \"verbatim user line\" — only include directives whose wording the next session \
should not lose.\n\n\
## Current Work\n\
(23 lines on what was happening just before compaction).\n\n\
Keep code snippets and raw tool output OUT of the summary — that is what auto-read \
and references are for. Target 10002000 tokens.";
/// An independent agent execution unit.
///
/// Holds a [`Worker`] directly and persists session state via
@@ -142,6 +112,12 @@ pub struct Pod<C: LlmClient, St: Store> {
/// `Method::PodEvent` reports upward (turn end, error, shutdown,
/// scope sub-delegation).
callback_socket: Option<PathBuf>,
/// Central catalog of Pod-level prompt strings (compaction system
/// prompt, notification wrapper, interrupt notes, trailing system
/// sections, ...). Built from the 4-layer overlay in
/// [`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>,
}
impl<C: LlmClient, St: Store> Pod<C, St> {
@@ -166,6 +142,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
// run so a later-installed system-prompt template (see
// `set_system_prompt_template`) can be captured by `SessionStart`.
let session_id = session_store::new_session_id();
let prompts = PromptCatalog::builtins_only()?;
let mut pod = Self {
manifest,
worker: Some(worker),
@@ -186,6 +163,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
pending_notifications: NotificationBuffer::new(),
scope_allocation: None,
callback_socket: None,
prompts,
};
pod.apply_prune_from_manifest();
Ok(pod)
@@ -200,6 +178,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
/// Restore a Pod from a persisted session.
/// Shared handle to the prompt catalog. Cheap to clone (`Arc`).
pub fn prompts(&self) -> &Arc<PromptCatalog> {
&self.prompts
}
pub async fn restore(
session_id: SessionId,
manifest: PodManifest,
@@ -232,6 +215,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
worker.set_cache_anchor(Some(0));
}
let prompts = PromptCatalog::builtins_only()?;
let mut pod = Self {
manifest,
worker: Some(worker),
@@ -252,6 +236,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
pending_notifications: NotificationBuffer::new(),
scope_allocation: None,
callback_socket: None,
prompts,
};
pod.apply_prune_from_manifest();
Ok(pod)
@@ -513,6 +498,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
compact_state,
usage_history_handle,
self.pending_notifications.clone(),
self.prompts.clone(),
);
self.worker_mut().set_interceptor(interceptor);
self.interceptor_installed = true;
@@ -552,12 +538,24 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
);
}
}
let agents_md_body = match agents_md_read.body {
Some(mut body) if agents_md_read.truncated => {
let notice = self
.prompts
.agents_md_truncation_notice()
.map_err(PodError::PromptCatalog)?;
body.push_str(&notice);
Some(body)
}
other => other,
};
let ctx = SystemPromptContext {
now: chrono::Utc::now(),
cwd: &self.pwd,
scope: &self.scope,
tool_names,
agents_md: agents_md_read.body,
agents_md: agents_md_body,
prompts: &self.prompts,
};
let rendered = template
.render(&ctx)
@@ -914,8 +912,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let scoped_fs = tools::ScopedFs::new(self.scope.clone(), self.pwd.clone());
let summary_tracker = tools::Tracker::new();
let summary_client: Box<dyn LlmClient> = self.build_compactor_client()?;
let summary_system_prompt = self
.prompts
.compact_system()
.map_err(PodError::PromptCatalog)?;
let mut summary_worker = Worker::new(summary_client)
.system_prompt(SUMMARY_SYSTEM_PROMPT)
.system_prompt(summary_system_prompt)
.temperature(0.0);
summary_worker.set_max_tokens(4096);
@@ -1155,10 +1157,12 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
// runtime values (date, tools, scope summary, ...) can be
// injected.
let system_prompt_template = Some(
SystemPromptTemplate::parse(&manifest.worker.instruction, loader)
SystemPromptTemplate::parse(&manifest.worker.instruction, loader.clone())
.map_err(|source| PodError::InvalidSystemPromptTemplate { source })?,
);
let prompts = PromptCatalog::load(&loader, manifest.pod.prompt_pack.as_deref())?;
// Session creation is deferred to the first run (see
// `ensure_session_head`) so the SessionStart entry can capture
// the rendered system prompt, not the raw template source.
@@ -1183,6 +1187,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
pending_notifications: NotificationBuffer::new(),
scope_allocation: Some(scope_allocation),
callback_socket: None,
prompts,
};
pod.apply_prune_from_manifest();
Ok(pod)
@@ -1218,10 +1223,12 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
apply_worker_manifest(&mut worker, &manifest.worker);
let system_prompt_template = Some(
SystemPromptTemplate::parse(&manifest.worker.instruction, loader)
SystemPromptTemplate::parse(&manifest.worker.instruction, loader.clone())
.map_err(|source| PodError::InvalidSystemPromptTemplate { source })?,
);
let prompts = PromptCatalog::load(&loader, manifest.pod.prompt_pack.as_deref())?;
let session_id = session_store::new_session_id();
let mut pod = Self {
manifest,
@@ -1243,6 +1250,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
pending_notifications: NotificationBuffer::new(),
scope_allocation: Some(scope_allocation),
callback_socket: Some(callback_socket),
prompts,
};
pod.apply_prune_from_manifest();
Ok(pod)
@@ -1425,6 +1433,9 @@ pub enum PodError {
#[error(transparent)]
ScopeLock(#[from] ScopeLockError),
#[error(transparent)]
PromptCatalog(#[from] CatalogError),
}
/// Snapshot the process's current working directory as the Pod's pwd,
+48 -6
View File
@@ -26,7 +26,9 @@ use crate::hook::{
TurnEndInfo,
};
use crate::notification_buffer::{NotificationBuffer, format_notification};
use crate::prompts::PromptCatalog;
use crate::token_counter::total_tokens_impl;
use tracing::warn;
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
const FINAL_TEXT_PREVIEW_LIMIT: usize = 512;
@@ -41,6 +43,8 @@ pub(crate) struct PodInterceptor {
/// Pending-notification buffer drained into the per-request
/// context at the head of `pre_llm_request`.
pending_notifications: NotificationBuffer,
/// Prompt catalog used to render the injected notification wrapper.
prompts: Arc<PromptCatalog>,
/// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize,
/// Tool calls observed in the current turn (reset on each new prompt).
@@ -53,12 +57,14 @@ impl PodInterceptor {
compact_state: Option<Arc<CompactState>>,
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
pending_notifications: NotificationBuffer,
prompts: Arc<PromptCatalog>,
) -> Self {
Self {
registry,
compact_state,
usage_history,
pending_notifications,
prompts,
next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0),
}
@@ -122,7 +128,17 @@ impl Interceptor for PodInterceptor {
// These are not persisted to the Worker history; they exist only
// for this single LLM request.
for notification in self.pending_notifications.drain() {
context.push(format_notification(&notification));
match format_notification(&notification, &self.prompts) {
Ok(item) => context.push(item),
Err(e) => {
// A render failure here would starve the LLM of the
// notification text. Fall back to the raw message —
// it still carries the intent, just without the
// wrapper phrasing.
warn!(error = %e, "failed to render notify_wrapper; using raw message");
context.push(Item::system_message(notification.message.clone()));
}
}
}
let info = PreRequestInfo {
@@ -281,6 +297,7 @@ mod tests {
Some(state),
Some(history),
NotificationBuffer::new(),
PromptCatalog::builtins_only().unwrap(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -304,6 +321,7 @@ mod tests {
Some(state),
Some(history),
NotificationBuffer::new(),
PromptCatalog::builtins_only().unwrap(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -328,6 +346,7 @@ mod tests {
Some(state),
Some(history),
NotificationBuffer::new(),
PromptCatalog::builtins_only().unwrap(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -341,7 +360,13 @@ mod tests {
let count = Arc::new(AtomicUsize::new(0));
let registry = registry_with_pre_llm_hook(count.clone());
let interceptor = PodInterceptor::new(registry, None, None, NotificationBuffer::new());
let interceptor = PodInterceptor::new(
registry,
None,
None,
NotificationBuffer::new(),
PromptCatalog::builtins_only().unwrap(),
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -366,7 +391,13 @@ mod tests {
buffer.push("first".into());
buffer.push("second".into());
let interceptor = PodInterceptor::new(registry, None, None, buffer.clone());
let interceptor = PodInterceptor::new(
registry,
None,
None,
buffer.clone(),
PromptCatalog::builtins_only().unwrap(),
);
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -395,8 +426,13 @@ mod tests {
let ctx_items = vec![Item::user_message("hi")];
let history = usage_handle_with(ctx_items.len(), 200);
let interceptor =
PodInterceptor::new(registry, Some(state), Some(history), buffer.clone());
let interceptor = PodInterceptor::new(
registry,
Some(state),
Some(history),
buffer.clone(),
PromptCatalog::builtins_only().unwrap(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -415,7 +451,13 @@ mod tests {
builder.add_pre_llm_request(CountingHook(second_count.clone()));
let registry = Arc::new(builder.build());
let interceptor = PodInterceptor::new(registry, None, None, NotificationBuffer::new());
let interceptor = PodInterceptor::new(
registry,
None,
None,
NotificationBuffer::new(),
PromptCatalog::builtins_only().unwrap(),
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
+45
View File
@@ -102,10 +102,18 @@ pub enum LoaderError {
/// Loader that resolves [`PromptRef`]s against the configured prompt
/// libraries. Cheap to clone.
///
/// Also carries the auto-discovered `prompts.toml` pack file paths so
/// [`crate::prompts::PromptCatalog`] can read the same user/workspace
/// layers without a separate plumbing channel. These fields do not
/// affect `$prefix` asset resolution — they are purely metadata
/// consulted by the catalog loader.
#[derive(Debug, Clone)]
pub struct PromptLoader {
user_dir: Option<PathBuf>,
workspace_dir: Option<PathBuf>,
user_pack_file: Option<PathBuf>,
workspace_pack_file: Option<PathBuf>,
}
impl PromptLoader {
@@ -116,6 +124,8 @@ impl PromptLoader {
Self {
user_dir: None,
workspace_dir: None,
user_pack_file: None,
workspace_pack_file: None,
}
}
@@ -124,9 +134,44 @@ impl PromptLoader {
Self {
user_dir,
workspace_dir,
user_pack_file: None,
workspace_pack_file: None,
}
}
/// Override the auto-discovered pack file paths. Used by
/// [`crate::PodFactory`] to surface `<user_manifest_dir>/prompts.toml`
/// and `<project>/.insomnia/prompts.toml`.
pub fn with_pack_files(
mut self,
user_pack_file: Option<PathBuf>,
workspace_pack_file: Option<PathBuf>,
) -> Self {
self.user_pack_file = user_pack_file;
self.workspace_pack_file = workspace_pack_file;
self
}
/// Root of the `$user` prompt library, if configured.
pub fn user_dir(&self) -> Option<&Path> {
self.user_dir.as_deref()
}
/// Root of the `$workspace` prompt library, if configured.
pub fn workspace_dir(&self) -> Option<&Path> {
self.workspace_dir.as_deref()
}
/// Auto-discovered path to the user-layer `prompts.toml` pack, if any.
pub fn user_pack_file(&self) -> Option<&Path> {
self.user_pack_file.as_deref()
}
/// Auto-discovered path to the workspace-layer `prompts.toml` pack, if any.
pub fn workspace_pack_file(&self) -> Option<&Path> {
self.workspace_pack_file.as_deref()
}
/// Parse a string reference into a [`PromptRef`]. Unqualified
/// references (no leading `$prefix/`) are resolved against
/// `current`: the prefix is inherited, and the path is joined to
+616
View File
@@ -0,0 +1,616 @@
//! Central catalog of Pod-level prompt strings.
//!
//! Prompts that Pod injects into a Worker (compaction system prompt,
//! notification wrapper, interrupt notes, system-prompt trailing
//! sections, AGENTS.md truncation notice, ...) are enumerated by
//! [`PodPrompt`] and rendered through a single [`PromptCatalog`]. Direct
//! `const &str` / `format!` authoring of these strings elsewhere in
//! `crates/pod` is deliberately avoided — new injection points add a
//! variant here, which forces a matching entry in
//! `resources/prompts/internal.toml` (checked at build time) and keeps
//! the "Pod tone" editable in one place.
//!
//! # Layering
//!
//! Values are merged key-wise from low priority to high:
//!
//! 1. **builtin** — `resources/prompts/internal.toml`, baked into the
//! binary. Must cover every [`PodPrompt`] variant (build-time check).
//! 2. **user** — `<user_manifest_dir>/prompts.toml`, auto-discovered by
//! [`PodFactory`]. Optional.
//! 3. **workspace** — `<project>/.insomnia/prompts.toml`, auto-discovered.
//! Optional.
//! 4. **manifest pack** — `manifest.pod.prompt_pack`, an explicit path
//! per-Pod. Optional.
//!
//! Unknown keys in layers 24 are logged via `tracing::warn!` and
//! ignored (forward compatibility). Layer 1 is enforced at build time.
//!
//! # Template language
//!
//! All values are minijinja templates. `{% include "$prefix/..." %}`
//! resolves through the same [`PromptLoader`] used by the system-prompt
//! template, so long prompt bodies can be factored into `.md` files
//! under `resources/prompts/...`, the user prompts library, or the
//! workspace prompts library.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use minijinja::value::Value;
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
use serde::Deserialize;
use thiserror::Error;
use tracing::warn;
use crate::prompt_loader::PromptLoader;
// Generated by build.rs from `resources/prompts/internal.toml`.
include!(concat!(env!("OUT_DIR"), "/internal_keys.rs"));
/// Source of the builtin pack. Baked in at compile time.
const INTERNAL_TOML: &str = include_str!("../../../resources/prompts/internal.toml");
/// Pod-level prompt injection point.
///
/// Adding a new variant also requires adding a matching key to
/// `resources/prompts/internal.toml`; the build fails otherwise.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PodPrompt {
/// System prompt of the compaction (summary) Worker.
CompactSystem,
/// Wrapper around an incoming `Method::Notify` message injected into
/// the next LLM request context as a transient system message.
NotifyWrapper,
/// Synthetic `Item::ToolResult` summary used to close out orphaned
/// tool calls when a paused turn is interrupted by the user.
InterruptToolResultSummary,
/// System note prepended to the new turn after an interrupt.
InterruptSystemNote,
/// Trailing `## Working boundaries` section appended to every
/// materialised system prompt.
WorkingBoundariesSection,
/// Trailing `## Project instructions (AGENTS.md)` section, appended
/// after the scope summary when an AGENTS.md is present.
AgentsMdSection,
/// Tail note used when AGENTS.md exceeds the byte cap and is
/// truncated before being embedded in the system prompt.
AgentsMdTruncationNotice,
}
impl PodPrompt {
pub fn key(self) -> &'static str {
match self {
Self::CompactSystem => "compact_system",
Self::NotifyWrapper => "notify_wrapper",
Self::InterruptToolResultSummary => "interrupt_tool_result_summary",
Self::InterruptSystemNote => "interrupt_system_note",
Self::WorkingBoundariesSection => "working_boundaries_section",
Self::AgentsMdSection => "agents_md_section",
Self::AgentsMdTruncationNotice => "agents_md_truncation_notice",
}
}
/// All variants in declaration order. The associated `KEYS` slice
/// mirrors this for const-eval coverage checks against
/// `INTERNAL_KEYS` (generated by `build.rs`).
pub const ALL: &'static [PodPrompt] = &[
PodPrompt::CompactSystem,
PodPrompt::NotifyWrapper,
PodPrompt::InterruptToolResultSummary,
PodPrompt::InterruptSystemNote,
PodPrompt::WorkingBoundariesSection,
PodPrompt::AgentsMdSection,
PodPrompt::AgentsMdTruncationNotice,
];
pub const KEYS: &'static [&'static str] = &[
"compact_system",
"notify_wrapper",
"interrupt_tool_result_summary",
"interrupt_system_note",
"working_boundaries_section",
"agents_md_section",
"agents_md_truncation_notice",
];
}
// --- build-time bidirectional coverage check --------------------------------
const _: () = {
// Every enum key must appear in the builtin TOML.
let mut i = 0;
while i < PodPrompt::KEYS.len() {
if !const_slice_contains(INTERNAL_KEYS, PodPrompt::KEYS[i]) {
panic!(
"resources/prompts/internal.toml is missing a key declared by \
PodPrompt — regenerate the TOML or remove the variant"
);
}
i += 1;
}
// Every TOML key must correspond to an enum variant.
let mut i = 0;
while i < INTERNAL_KEYS.len() {
if !const_slice_contains(PodPrompt::KEYS, INTERNAL_KEYS[i]) {
panic!(
"resources/prompts/internal.toml has a key not declared by \
PodPrompt — add the variant or drop the key"
);
}
i += 1;
}
};
const fn const_str_eq(a: &str, b: &str) -> bool {
let a = a.as_bytes();
let b = b.as_bytes();
if a.len() != b.len() {
return false;
}
let mut i = 0;
while i < a.len() {
if a[i] != b[i] {
return false;
}
i += 1;
}
true
}
const fn const_slice_contains(haystack: &[&str], needle: &str) -> bool {
let mut i = 0;
while i < haystack.len() {
if const_str_eq(haystack[i], needle) {
return true;
}
i += 1;
}
false
}
// --- errors ----------------------------------------------------------------
#[derive(Debug, Error)]
pub enum CatalogError {
#[error("failed to read prompt pack {}: {source}", .path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse prompt pack {}: {source}", .path.display())]
ParseToml {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("failed to parse builtin prompt pack: {0}")]
ParseBuiltin(#[source] toml::de::Error),
#[error("failed to compile prompt template '{key}': {source}")]
TemplateCompile {
key: String,
#[source]
source: minijinja::Error,
},
#[error("failed to render prompt '{key}': {source}")]
Render {
key: String,
#[source]
source: minijinja::Error,
},
#[error("prompt key '{key}' is not registered in the catalog")]
UnknownKey { key: String },
}
// --- pack file shape -------------------------------------------------------
#[derive(Debug, Deserialize)]
struct PackFile {
#[serde(default)]
prompt: HashMap<String, String>,
}
// --- catalog ---------------------------------------------------------------
/// Merged, compiled pod-prompt catalog.
///
/// Owns a `minijinja::Environment` with one template registered per
/// [`PodPrompt`] key (after the 4-layer merge). Includes inside templates
/// are resolved via a provided [`PromptLoader`], so values can pull from
/// `$insomnia` / `$user` / `$workspace`.
pub struct PromptCatalog {
env: Environment<'static>,
}
impl std::fmt::Debug for PromptCatalog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PromptCatalog").finish_non_exhaustive()
}
}
impl PromptCatalog {
/// Builtin-only catalog. All `{% include %}` references must resolve
/// through `$insomnia` (user/workspace prefixes are unavailable).
pub fn builtins_only() -> Result<Arc<Self>, CatalogError> {
Self::load(&PromptLoader::builtins_only(), None)
}
/// Load the catalog honouring the 4-layer overlay.
///
/// - Layer 1 (builtin): `INTERNAL_TOML` baked into the binary.
/// - Layer 2 (user): `loader.user_pack_file()` if present.
/// - Layer 3 (workspace): `loader.workspace_pack_file()` if present.
/// - Layer 4 (manifest): `manifest_pack` as an absolute filesystem
/// path (pre-resolved by the manifest cascade).
pub fn load(
loader: &PromptLoader,
manifest_pack: Option<&Path>,
) -> Result<Arc<Self>, CatalogError> {
let mut merged = parse_builtin_pack()?;
if let Some(path) = loader.user_pack_file() {
if path.is_file() {
let pack = parse_pack_file(path)?;
merge_into(&mut merged, pack, "user");
}
}
if let Some(path) = loader.workspace_pack_file() {
if path.is_file() {
let pack = parse_pack_file(path)?;
merge_into(&mut merged, pack, "workspace");
}
}
if let Some(path) = manifest_pack {
let pack = parse_pack_file(path)?;
merge_into(&mut merged, pack, "manifest");
}
build_catalog(merged, loader.clone()).map(Arc::new)
}
/// Render a prompt by variant. `ctx` provides template variables; use
/// [`Value::UNDEFINED`] (or a helper below) when the template takes
/// no inputs.
pub fn render(&self, prompt: PodPrompt, ctx: Value) -> Result<String, CatalogError> {
let key = prompt.key();
let tmpl = self
.env
.get_template(key)
.map_err(|_| CatalogError::UnknownKey {
key: key.to_string(),
})?;
tmpl.render(ctx).map_err(|source| CatalogError::Render {
key: key.to_string(),
source,
})
}
/// Render `PodPrompt::CompactSystem` (no inputs).
pub fn compact_system(&self) -> Result<String, CatalogError> {
self.render(PodPrompt::CompactSystem, Value::UNDEFINED)
}
/// Render `PodPrompt::NotifyWrapper` with `{{ message }}`.
pub fn notify_wrapper(&self, message: &str) -> Result<String, CatalogError> {
self.render(PodPrompt::NotifyWrapper, single("message", message))
}
/// Render `PodPrompt::InterruptToolResultSummary` (no inputs).
pub fn interrupt_tool_result_summary(&self) -> Result<String, CatalogError> {
self.render(PodPrompt::InterruptToolResultSummary, Value::UNDEFINED)
}
/// Render `PodPrompt::InterruptSystemNote` (no inputs).
pub fn interrupt_system_note(&self) -> Result<String, CatalogError> {
self.render(PodPrompt::InterruptSystemNote, Value::UNDEFINED)
}
/// Render `PodPrompt::WorkingBoundariesSection` with `{{ scope_summary }}`.
pub fn working_boundaries_section(
&self,
scope_summary: &str,
) -> Result<String, CatalogError> {
self.render(
PodPrompt::WorkingBoundariesSection,
single("scope_summary", scope_summary),
)
}
/// Render `PodPrompt::AgentsMdSection` with `{{ agents_md }}`.
pub fn agents_md_section(&self, agents_md: &str) -> Result<String, CatalogError> {
self.render(PodPrompt::AgentsMdSection, single("agents_md", agents_md))
}
/// Render `PodPrompt::AgentsMdTruncationNotice` (no inputs).
pub fn agents_md_truncation_notice(&self) -> Result<String, CatalogError> {
self.render(PodPrompt::AgentsMdTruncationNotice, Value::UNDEFINED)
}
}
fn single(key: &'static str, value: &str) -> Value {
use std::collections::BTreeMap;
let mut m: BTreeMap<&'static str, Value> = BTreeMap::new();
m.insert(key, Value::from(value));
Value::from(m)
}
fn parse_builtin_pack() -> Result<HashMap<String, String>, CatalogError> {
let parsed: PackFile =
toml::from_str(INTERNAL_TOML).map_err(CatalogError::ParseBuiltin)?;
Ok(parsed.prompt)
}
fn parse_pack_file(path: &Path) -> Result<HashMap<String, String>, CatalogError> {
let src = fs::read_to_string(path).map_err(|source| CatalogError::Io {
path: path.to_path_buf(),
source,
})?;
let parsed: PackFile = toml::from_str(&src).map_err(|source| CatalogError::ParseToml {
path: path.to_path_buf(),
source,
})?;
Ok(parsed.prompt)
}
fn merge_into(
base: &mut HashMap<String, String>,
upper: HashMap<String, String>,
origin: &'static str,
) {
for (k, v) in upper {
if !PodPrompt::KEYS.iter().any(|declared| *declared == k) {
warn!(
origin = origin,
key = %k,
"unknown prompt pack key; ignoring"
);
continue;
}
base.insert(k, v);
}
}
fn build_catalog(
templates: HashMap<String, String>,
loader: PromptLoader,
) -> Result<PromptCatalog, CatalogError> {
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
// Reuse the system-prompt-template resolver so `{% include
// "$prefix/..." %}` inside a catalog value pulls from the same asset
// namespaces.
let loader_for_join = loader.clone();
env.set_path_join_callback(move |name, parent| {
let parent_ref = loader_for_join.parse_ref(parent, None).ok();
match loader_for_join.parse_ref(name, parent_ref.as_ref()) {
Ok(r) => r.to_qualified_string().into(),
Err(_) => name.to_string().into(),
}
});
let loader_for_src = loader.clone();
env.set_loader(move |name| {
let reference = loader_for_src
.parse_ref(name, None)
.map_err(|e| minijinja::Error::new(ErrorKind::TemplateNotFound, e.to_string()))?;
match loader_for_src.load(&reference) {
Ok(src) => Ok(Some(src)),
Err(e) => Err(minijinja::Error::new(
ErrorKind::TemplateNotFound,
e.to_string(),
)),
}
});
for (k, v) in templates {
env.add_template_owned(k.clone(), v)
.map_err(|source| CatalogError::TemplateCompile {
key: k.clone(),
source,
})?;
}
Ok(PromptCatalog { env })
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn loader_with_packs(
user_dir: Option<PathBuf>,
workspace_dir: Option<PathBuf>,
user_pack: Option<PathBuf>,
workspace_pack: Option<PathBuf>,
) -> PromptLoader {
PromptLoader::new(user_dir, workspace_dir).with_pack_files(user_pack, workspace_pack)
}
#[test]
fn builtin_covers_every_variant() {
let cat = PromptCatalog::builtins_only().unwrap();
for p in PodPrompt::ALL {
assert!(
cat.env.get_template(p.key()).is_ok(),
"builtin missing key: {}",
p.key()
);
}
}
#[test]
fn builtin_render_compact_system_includes_worker_instructions() {
let cat = PromptCatalog::builtins_only().unwrap();
let rendered = cat.compact_system().unwrap();
assert!(rendered.contains("write_summary"));
assert!(rendered.contains("mark_read_required"));
}
#[test]
fn notify_wrapper_interpolates_message() {
let cat = PromptCatalog::builtins_only().unwrap();
let out = cat.notify_wrapper("file changed").unwrap();
assert!(out.contains("[Notification]"));
assert!(out.contains("file changed"));
assert!(out.contains("not a blocking request"));
}
#[test]
fn working_boundaries_section_wraps_summary() {
let cat = PromptCatalog::builtins_only().unwrap();
let out = cat.working_boundaries_section("Readable: /a").unwrap();
assert!(out.contains("## Working boundaries"));
assert!(out.contains("Readable: /a"));
}
#[test]
fn agents_md_section_contains_marker() {
let cat = PromptCatalog::builtins_only().unwrap();
let out = cat.agents_md_section("PROJECT DOCS").unwrap();
assert!(out.contains("## Project instructions (AGENTS.md)"));
assert!(out.contains("PROJECT DOCS"));
}
#[test]
fn agents_md_truncation_notice_matches_expected_form() {
let cat = PromptCatalog::builtins_only().unwrap();
let out = cat.agents_md_truncation_notice().unwrap();
assert!(out.contains("[truncated: AGENTS.md exceeded 64KB limit]"));
assert!(out.starts_with("\n\n"));
}
#[test]
fn user_pack_overrides_builtin() {
let tmp = TempDir::new().unwrap();
let pack = tmp.path().join("prompts.toml");
fs::write(
&pack,
r#"
[prompt]
interrupt_system_note = "[OVERRIDDEN]"
"#,
)
.unwrap();
let loader = loader_with_packs(None, None, Some(pack), None);
let cat = PromptCatalog::load(&loader, None).unwrap();
assert_eq!(cat.interrupt_system_note().unwrap(), "[OVERRIDDEN]");
// Other keys still come from the builtin.
assert!(cat.notify_wrapper("x").unwrap().contains("[Notification]"));
}
#[test]
fn workspace_pack_wins_over_user_pack() {
let tmp = TempDir::new().unwrap();
let user = tmp.path().join("user.toml");
let ws = tmp.path().join("ws.toml");
fs::write(
&user,
r#"
[prompt]
interrupt_system_note = "[USER]"
"#,
)
.unwrap();
fs::write(
&ws,
r#"
[prompt]
interrupt_system_note = "[WS]"
"#,
)
.unwrap();
let loader = loader_with_packs(None, None, Some(user), Some(ws));
let cat = PromptCatalog::load(&loader, None).unwrap();
assert_eq!(cat.interrupt_system_note().unwrap(), "[WS]");
}
#[test]
fn manifest_pack_wins_over_workspace_pack() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws.toml");
let mf = tmp.path().join("mf.toml");
fs::write(
&ws,
r#"
[prompt]
interrupt_system_note = "[WS]"
"#,
)
.unwrap();
fs::write(
&mf,
r#"
[prompt]
interrupt_system_note = "[MF]"
"#,
)
.unwrap();
let loader = loader_with_packs(None, None, None, Some(ws));
let cat = PromptCatalog::load(&loader, Some(mf.as_path())).unwrap();
assert_eq!(cat.interrupt_system_note().unwrap(), "[MF]");
}
#[test]
fn unknown_key_in_runtime_pack_is_ignored_with_warning() {
let tmp = TempDir::new().unwrap();
let pack = tmp.path().join("p.toml");
fs::write(
&pack,
r#"
[prompt]
interrupt_system_note = "[OK]"
future_injection_point = "tolerated"
"#,
)
.unwrap();
let loader = loader_with_packs(None, None, Some(pack), None);
// Loads without error; the unknown key is dropped silently at
// runtime (log warning is emitted via tracing).
let cat = PromptCatalog::load(&loader, None).unwrap();
assert_eq!(cat.interrupt_system_note().unwrap(), "[OK]");
}
#[test]
fn manifest_pack_reads_from_absolute_path() {
let tmp = TempDir::new().unwrap();
let pack = tmp.path().join("mine.toml");
fs::write(
&pack,
r#"
[prompt]
interrupt_system_note = "[FROM-MANIFEST-PACK]"
"#,
)
.unwrap();
let loader = PromptLoader::builtins_only();
let cat = PromptCatalog::load(&loader, Some(pack.as_path())).unwrap();
assert_eq!(cat.interrupt_system_note().unwrap(), "[FROM-MANIFEST-PACK]");
}
#[test]
fn value_can_pull_long_text_via_include() {
// A runtime pack that overrides `compact_system` with an
// `{% include %}` into the same `$insomnia` namespace — exercises
// the template resolver path through all four layers.
let tmp = TempDir::new().unwrap();
let pack = tmp.path().join("p.toml");
fs::write(
&pack,
r#"
[prompt]
compact_system = "PREFIX\n{% include \"$insomnia/internal/compact_system\" %}"
"#,
)
.unwrap();
let loader = loader_with_packs(None, None, Some(pack), None);
let cat = PromptCatalog::load(&loader, None).unwrap();
let rendered = cat.compact_system().unwrap();
assert!(rendered.starts_with("PREFIX\n"));
assert!(rendered.contains("write_summary"));
}
}
+1
View File
@@ -355,6 +355,7 @@ fn build_overlay_toml(
let overlay = PodManifestConfig {
pod: PodMetaConfig {
name: Some(name.to_string()),
prompt_pack: None,
},
model: ModelConfigPartial {
scheme: Some(model.scheme),
+39 -15
View File
@@ -23,6 +23,7 @@ use minijinja::{Environment, ErrorKind, UndefinedBehavior};
use thiserror::Error;
use crate::prompt_loader::{LoaderError, PromptLoader, PromptRef};
use crate::prompts::{CatalogError, PromptCatalog};
#[derive(Debug, Error)]
pub enum SystemPromptError {
@@ -32,6 +33,8 @@ pub enum SystemPromptError {
Parse(String),
#[error("system prompt template render error: {0}")]
Render(String),
#[error("failed to render trailing section template: {0}")]
Catalog(#[from] CatalogError),
}
/// Parsed instruction template bound to a prompt loader.
@@ -114,7 +117,7 @@ impl SystemPromptTemplate {
let body = tmpl
.render(ctx.to_minijinja_value())
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
Ok(append_trailing_section(&body, ctx.scope, ctx.agents_md.as_deref()))
append_trailing_section(&body, ctx.prompts, ctx.scope, ctx.agents_md.as_deref())
}
}
@@ -140,6 +143,10 @@ pub struct SystemPromptContext<'a> {
/// Not visible from the template; consumed by the trailing-section
/// formatter in [`SystemPromptTemplate::render`].
pub agents_md: Option<String>,
/// 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.
pub prompts: &'a PromptCatalog,
}
impl<'a> SystemPromptContext<'a> {
@@ -173,33 +180,39 @@ impl<'a> SystemPromptContext<'a> {
}
/// Build the final system prompt by appending the fixed trailing
/// section to `body`. Exposed at the module level so callers that skip
/// the template path (e.g. pre-rendered content in tests) can reuse the
/// exact same formatter.
pub fn append_trailing_section(body: &str, scope: &Scope, agents_md: Option<&str>) -> String {
/// section to `body`. The Rust side owns the layout (blank-line
/// separators, trailing-whitespace trim); each section's header + body
/// comes from the prompt catalog (`PodPrompt::WorkingBoundariesSection`
/// / `PodPrompt::AgentsMdSection`) so that wording can be overridden
/// per-pack without touching this function.
pub fn append_trailing_section(
body: &str,
prompts: &PromptCatalog,
scope: &Scope,
agents_md: Option<&str>,
) -> Result<String, SystemPromptError> {
let mut out = String::with_capacity(body.len() + 256);
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
out.push('\n');
out.push_str("---\n## Working boundaries\n\n");
out.push_str(&scope.summary());
let boundaries = prompts.working_boundaries_section(&scope.summary())?;
out.push_str(boundaries.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
if let Some(agents) = agents_md {
out.push('\n');
out.push_str("---\n## Project instructions (AGENTS.md)\n\n");
out.push_str(agents);
if !agents.ends_with('\n') {
out.push('\n');
}
let section = prompts.agents_md_section(agents)?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
// Trim trailing whitespace on the final line so the emitted prompt
// has a single canonical form regardless of input quirks.
// 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(' ') {
out.pop();
}
out
Ok(out)
}
/// Bridge used by [`Pod::ensure_system_prompt_materialized`] so tests
@@ -244,9 +257,20 @@ mod tests {
scope,
tool_names: tools,
agents_md,
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.
fn test_prompts() -> &'static PromptCatalog {
use std::sync::OnceLock;
static CELL: OnceLock<Arc<PromptCatalog>> = OnceLock::new();
CELL.get_or_init(|| PromptCatalog::builtins_only().unwrap())
.as_ref()
}
fn user_loader_with(file_name: &str, body: &str) -> (TempDir, PromptLoader) {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(file_name), body).unwrap();