Promptを一元管理するファイルから参照する実装
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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\
|
||||
(2–3 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 1000–2000 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(¬ice);
|
||||
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,
|
||||
|
||||
@@ -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(¬ification));
|
||||
match format_notification(¬ification, &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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 2–4 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"));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user