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

This commit is contained in:
Keisuke Hirata 2026-04-22 17:43:05 +09:00
parent 0c1276b730
commit b8d368f5e5
21 changed files with 1107 additions and 253 deletions

View File

@ -3,7 +3,6 @@ name: "ticket-reviewer"
description: "Use this agent when a ticket implementation is submitted for review in this project (insomnia). The agent reviews the ticket's premises/requirements and the actual implementation, creates `tickets/<ticket>.review.md` with findings, and updates the original `tickets/<ticket>.md` with review status. Do NOT use this agent for general code review unrelated to a ticket. Examples:\\n<example>\\nContext: User has just finished implementing a feature described in tickets/foo.md and wants it reviewed.\\nuser: \"tickets/foo.md の実装が終わったのでレビューして\"\\nassistant: \"I'll use the Agent tool to launch the ticket-reviewer agent to review the implementation against tickets/foo.md's requirements and produce tickets/foo.review.md.\"\\n<commentary>\\nThe user is explicitly requesting a ticket-scoped review with the project's .review.md workflow, which is this agent's purpose.\\n</commentary>\\n</example>\\n<example>\\nContext: User finishes a chunk of work and mentions the ticket name.\\nuser: \"scopedfs-scripting のチケット、一通り実装出来た\"\\nassistant: \"Let me use the Agent tool to launch the ticket-reviewer agent to review the implementation and produce the review artifacts.\"\\n<commentary>\\nCompletion of a ticket implementation is the trigger for the ticket-reviewer agent per project's lifecycle (c. レビュー).\\n</commentary>\\n</example>\\n<example>\\nContext: User requests re-review after addressing feedback.\\nuser: \"指摘を反映したので再レビューお願い\"\\nassistant: \"I'll use the Agent tool to launch the ticket-reviewer agent to re-review and update the .review.md accordingly.\"\\n<commentary>\\nRe-review updates the existing .review.md and ticket status; this agent handles that workflow.\\n</commentary>\\n</example>"
model: opus
color: purple
memory: project
---
You are a senior reviewer specialized in the `insomnia` project. You are an expert at evaluating ticket-scoped implementations against their stated premises and requirements, and at safeguarding the codebase from unnecessary complexity or architectural drift. You operate strictly within the project's ticket lifecycle conventions defined in `CLAUDE.md`.
@ -119,152 +118,3 @@ Do not modify the ticket's 背景・要件 sections unless the user explicitly a
5. Did I update both `<name>.review.md` and `<name>.md`?
6. Is my judgment line unambiguous?
## Agent Memory
**Update your agent memory** as you review tickets in this project. This builds up institutional knowledge across review sessions. Write concise notes about what you found and where.
Examples of what to record:
- Recurring architectural patterns and anti-patterns observed across tickets
- Layer boundary conventions (e.g., what belongs in llm-worker vs. upper layers) as they become clearer
- Common requirement-miss patterns (e.g., tests omitted, build-through invariant violated)
- Crate/module organization conventions confirmed during reviews
- Reviewer judgment precedents — when a similar issue was Approve-with-follow-up vs. Request-changes
- Ticket authoring patterns that correlate with smooth vs. troubled reviews
- Project-specific policies reinforced during review (provider policy, ScopedFs scripting direction, cargo add discipline, etc.)
Keep entries short and link-friendly so they can be referenced in future reviews.
# Persistent Agent Memory
You have a persistent, file-based memory system at `<repo>/.claude/agent-memory/ticket-reviewer/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence).
You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.
If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.
## Types of memory
There are several discrete types of memory that you can store in your memory system:
<types>
<type>
<name>user</name>
<description>Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together.</description>
<when_to_save>When you learn any details about the user's role, preferences, responsibilities, or knowledge</when_to_save>
<how_to_use>When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.</how_to_use>
<examples>
user: I'm a data scientist investigating what logging we have in place
assistant: [saves user memory: user is a data scientist, currently focused on observability/logging]
user: I've been writing Go for ten years but this is my first time touching the React side of this repo
assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues]
</examples>
</type>
<type>
<name>feedback</name>
<description>Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious.</description>
<when_to_save>Any time the user corrects your approach ("no not that", "don't", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later.</when_to_save>
<how_to_use>Let these memories guide your behavior so that the user does not need to offer the same guidance twice.</how_to_use>
<body_structure>Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule.</body_structure>
<examples>
user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed
assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration]
user: stop summarizing what you just did at the end of every response, I can read the diff
assistant: [saves feedback memory: this user wants terse responses with no trailing summaries]
user: yeah the single bundled PR was the right call here, splitting this one would've just been churn
assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction]
</examples>
</type>
<type>
<name>project</name>
<description>Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory.</description>
<when_to_save>When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes.</when_to_save>
<how_to_use>Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions.</how_to_use>
<body_structure>Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing.</body_structure>
<examples>
user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch
assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]
user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements
assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics]
</examples>
</type>
<type>
<name>reference</name>
<description>Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.</description>
<when_to_save>When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.</when_to_save>
<how_to_use>When the user references an external system or information that may be in an external system.</how_to_use>
<examples>
user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs
assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"]
user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone
assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code]
</examples>
</type>
</types>
## What NOT to save in memory
- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.
- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative.
- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.
- Anything already documented in CLAUDE.md files.
- Ephemeral task details: in-progress work, temporary state, current conversation context.
These exclusions apply even when the user explicitly asks you to save. If they ask you to save a PR list or activity summary, ask what was *surprising* or *non-obvious* about it — that is the part worth keeping.
## How to save memories
Saving a memory is a two-step process:
**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:
```markdown
---
name: {{memory name}}
description: {{one-line description — used to decide relevance in future conversations, so be specific}}
type: {{user, feedback, project, reference}}
---
{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}}
```
**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — each entry should be one line, under ~150 characters: `- [Title](file.md) — one-line hook`. It has no frontmatter. Never write memory content directly into `MEMORY.md`.
- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise
- Keep the name, description, and type fields in memory files up-to-date with the content
- Organize memory semantically by topic, not chronologically
- Update or remove memories that turn out to be wrong or outdated
- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.
## When to access memories
- When memories seem relevant, or the user references prior-conversation work.
- You MUST access memory when the user explicitly asks you to check, recall, or remember.
- If the user says to *ignore* or *not use* memory: Do not apply remembered facts, cite, compare against, or mention memory content.
- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources. If a recalled memory conflicts with current information, trust what you observe now — and update or remove the stale memory rather than acting on it.
## Before recommending from memory
A memory that names a specific function, file, or flag is a claim that it existed *when the memory was written*. It may have been renamed, removed, or never merged. Before recommending it:
- If the memory names a file path: check the file exists.
- If the memory names a function or flag: grep for it.
- If the user is about to act on your recommendation (not just asking about history), verify first.
"The memory says X exists" is not the same as "X exists now."
A memory that summarizes repo state (activity logs, architecture snapshots) is frozen in time. If the user asks about *recent* or *current* state, prefer `git log` or reading the code over recalling the snapshot.
## Memory and other forms of persistence
Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.
- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.
- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. When you save new memories, they will appear here.

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),

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.

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
crates/pod/build.rs Normal file
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()));
}

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);
}

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!(

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 {

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;

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"));

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,

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;

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
crates/pod/src/prompts.rs Normal file
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"));
}
}

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),

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') {
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();

View File

@ -0,0 +1,38 @@
# Pod internal prompts (builtin pack).
#
# Values are minijinja template strings. Use `{% include "$prefix/..." %}`
# to pull in long text from the $insomnia / $user / $workspace prompt
# libraries.
#
# Every key here MUST correspond to a `PodPrompt` variant; missing or
# extra keys cause a build-time error (see `crates/pod/build.rs`).
[prompt]
compact_system = "{% include \"$insomnia/internal/compact_system\" %}"
notify_wrapper = """\
[Notification]
{{ message }}
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.\
"""
interrupt_tool_result_summary = "[Interrupted by user]"
interrupt_system_note = "[The previous turn was interrupted by the user. The user's next request follows.]"
working_boundaries_section = """\
---
## Working boundaries
{{ scope_summary }}\
"""
agents_md_section = """\
---
## Project instructions (AGENTS.md)
{{ agents_md }}\
"""
agents_md_truncation_notice = "\n\n[truncated: AGENTS.md exceeded 64KB limit]"

View File

@ -0,0 +1,31 @@
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.
Tools you can call:
- `read_file(file_path, offset?, limit?)` — inspect referenced files before deciding.
- `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`.
- `add_reference(file_path)` — record a file path the next session should know about without embedding its contents.
- `write_summary(text)` — deliver the final structured summary. May be called multiple times; only the last call is kept.
Always finish by calling `write_summary`. Produce the summary in this exact format:
## Completed Tasks
### (task name)
- what was done (use concrete type / file / function names)
- gotchas or facts that came up
## Active Task
### (task name)
- goal
- current state (what is done / not done)
- next step
## Key Decisions
- (decision) — (reason)
## User Directives
- "verbatim user line" — only include directives whose wording the next session should not lose.
## Current Work
(23 lines on what was happening just before compaction).
Keep code snippets and raw tool output OUT of the summary — that is what auto-read and references are for. Target 10002000 tokens.

4
text.txt Normal file
View File

@ -0,0 +1,4 @@
test
line 2
this is a longer third line written as a single sentence to test editing an existing file in the workspace
line 4

View File

@ -138,3 +138,8 @@ auto-discovery は「ユーザー or プロジェクトの永続設定 (翻訳
6. 長文 variant (compact_system など) を `{% include "$insomnia/internal/..." %}` 形式に分離。`$user` / `$workspace` から include で override できることをテスト
各ステップ終了時点でビルド通過・既存テスト合格を維持する。
## Review
- 状態: Approve
- レビュー詳細: [./pod-prompt-catalog.review.md](./pod-prompt-catalog.review.md)
- 日付: 2026-04-22

View File

@ -0,0 +1,46 @@
# Review: Pod 内部プロンプトのカタログ化
## 前提・要件の確認
1. **`PodPrompt` enum (要件1)** — 満たされている。7 variant を列挙し (`crates/pod/src/prompts.rs:60-81`)、各 variant が `key()` とともに declaration-order の `ALL`/`KEYS` 定数に連動する。呼び出し側 (pod/notification_buffer/pod_interceptor/interrupt_and_run/system_prompt/agents_md) はすべてカタログ経由に置換済み。`grep` で `SUMMARY_SYSTEM_PROMPT` / `TRUNCATION_NOTICE` / ハードコード文字列は production コードから一掃されている (テスト文字列のみ残存)。
2. **`internal.toml` builtin pack (要件2)** — 満たされている。`resources/prompts/internal.toml` に 7 key 全てを配置 (`resources/prompts/internal.toml:10-35`)。minijinja テンプレートとして評価され、`compact_system` は `{% include "$insomnia/internal/compact_system" %}` 経由で外部 `.md` を参照。長文分離の設計判断「`include` 一本化」は忠実に実装されている。
3. **ビルド時網羅性検査 (要件3)** — 満たされている。`build.rs` が TOML を parse して `INTERNAL_KEYS` slice を emit、`prompts.rs:122-145` の `const _: ()``PodPrompt::KEYS ↔ INTERNAL_KEYS` を双方向で検査する。ユーザー報告通り、片側除去/余剰で `panic!` メッセージ 2 種が発火することを確認済み。const eval ベースで proc-macro を回避しているのは軽量かつ依存最小で筋が良い。
4. **4 段 overlay merge (要件4)** — 満たされている。`PromptCatalog::load` が builtin → user (`user_pack_file`) → workspace (`workspace_pack_file`) → manifest pack の順で `merge_into` を呼ぶ (`prompts.rs:260-284`)。未知 key は `tracing::warn!` + ignore (`prompts.rs:370-386`)。builtin 層の不整合は build 時 error (要件3 と一体)。テスト `user_pack_overrides_builtin` / `workspace_pack_wins_over_user_pack` / `manifest_pack_wins_over_workspace_pack` / `unknown_key_in_runtime_pack_is_ignored_with_warning` が precedence と warn-ignore を裏打ちしている。
5. **minijinja 統一 + prefix resolver 流用 (要件5)** — 満たされている。`build_catalog` (`prompts.rs:440-482`) が `Environment``path_join_callback`/`loader` に既存 `PromptLoader` を配線し、値内の `{% include "$prefix/..." %}``$insomnia` / `$user` / `$workspace` 全てから引ける。テスト `value_can_pull_long_text_via_include` が builtin の `compact_system` を runtime pack 側から再参照できる挙動を確認している。
6. **`manifest.pod.prompt_pack` (要件6)** — 満たされている。`PodMeta.prompt_pack: Option<String>` を `#[serde(default)]` で追加 (`crates/manifest/src/lib.rs:36-46`)、`PodMetaConfig` のカスケード merge にも反映 (`crates/manifest/src/config.rs:43, 199-206, 340, 415`)。`Pod::from_manifest` / `from_manifest_spawned``PromptCatalog::load(&loader, manifest.pod.prompt_pack.as_deref())` を呼ぶ。`$user/` プレフィックス経由で解決するテスト (`manifest_pack_supports_user_prefix`) 付き。
## アーキテクチャ・スコープ
- **レイヤー境界** — 変更は `crates/pod``crates/manifest` (フィールド追加のみ) に収まり、`llm-worker` は触っていない。Pod 内部プロンプトは Pod 層で扱う、というスコープ方針を守っている。
- **prefix × layer の分離方針** — 設計判断「prefix 名前空間 (resolve where) と layer (merge precedence) は混ぜない」が正しく反映されている。pack 自体は **固定パスの auto-discovery****manifest での明示指名** の 2 経路に限定され、`PromptLoader` の prefix 名前空間 (`$insomnia`/`$user`/`$workspace`) は pack 値内部の `{% include %}` でのみ再利用される。
- **ランタイム vs ビルド時エラーの使い分け** — builtin 不整合は const-eval panic、runtime pack の unknown key は `warn + ignore`。前方互換性の判断通り。
- **tool description に手を入れていない** — scope 外宣言を遵守。`resources/prompts/internal/` ディレクトリは builtin 長文のみ (`compact_system.md` 一件)。
- **cargo add 運用**`[build-dependencies] toml = "1.1.2"` が追加されているが、`cargo add --build` 経由で追加されているかは diff からは直接確認できない。既に `[dependencies]``toml = "1.1.2"` が存在するので workspace の既存バージョンに揃っており、挙動上の懸念は無い (手動編集だったとしても結果は同じ)。
- **影響範囲との一致** — ticket の「影響範囲」リスト (`prompts.rs` 新設 / `internal.toml` / `internal/*.md` / 呼び出し置換 / `build.rs` / `manifest/config.rs`) はすべて diff 上に存在。未対応の項目は無い。
## 指摘事項
### Blocking
- なし。
### Non-blocking / Follow-up
- **[API 表面の二重化]** `PromptCatalog::render(PodPrompt, Value)``compact_system()` 等の typed accessor が同時に `pub` 公開されている (`prompts.rs:289-342`)。ticket は `PodPrompt::CompactSystem.render(&ctx)` を 1 本の API として期待していた。実装の「variant ごとに context 型が固定なので typed accessor が筋」という判断は妥当で、現状の呼び出し元もすべて typed を使っている。`render(PodPrompt, Value)` を `pub(crate)` まで降格するか、typed accessor だけを公開面として残す方が、将来「新しい variant を追加したら typed accessor も実装しないとコンパイル的には気づかない」という弱さを防げる。
- **[`append_trailing_section` の可視性]** `pub fn append_trailing_section` (`system_prompt.rs:188`) は module 内でしか呼ばれていない。`pub(crate)` か private へ落として API 表面を絞るのが望ましい。
- **[factory 側の `.is_file()` と catalog 側の `.is_file()` の二重フィルタ]** `PodFactory::build_prompt_loader` (`factory.rs:202-211`) が既に `.is_file()` で絞った `PathBuf` を渡すのに、`PromptCatalog::load` も `path.is_file()` を再チェックしている (`prompts.rs:267,273`)。冗長で、仕様として「渡されたら読む」なのか「存在チェックは catalog 側の責務」なのかが曖昧。後者に寄せるなら loader 側は無条件に `PathBuf` を渡し、catalog 側だけで分岐させる (既存の挙動を保つ)。前者にするなら loader 側は「pack file が無ければ `None`」という契約を DocComment 化する。ticket にとって致命傷ではない。
- **[`$insomnia/` manifest pack の `.toml` 読み取り経路]** `load_raw_builtin` は拡張子を付けない raw loader だが、これは元々 `$insomnia/` prefix が `.md` 前提で設計されていた `PromptLoader` を「拡張子ごと渡せば読める `.toml` 用 shortcut」として拡張している (`prompt_loader.rs:175-204`)。`$insomnia/` prefix の 2 つの意味 (テンプレートとしての `.md` 参照と、pack ファイルとしての `.toml` 参照) がひとつのローダに同居する形になっている。機能上は問題ないが、`$insomnia/default` (`.md` 付く) と `$insomnia/internal/foo.toml` (拡張子必須) で path の書き方が視覚的に揺れる。ドキュメント or 命名で区別してもよい。
### Nits
- `PromptCatalog::builtins_only()``load(&PromptLoader::builtins_only(), None)` を thin-wrap するだけだが、テスト用に便利なので残しておいて問題なし (多用されている)。
- `CatalogError::UnknownKey``PromptCatalog::render` が未登録テンプレートを引いた場合用だが、現状 `PodPrompt::key()` 経由でしか呼ばれないので到達しづらい。将来外部入力を受ける経路を増やしたときに活きる。
- `single` 関数 (`prompts.rs:345-350`) は `BTreeMap<&'static str, Value>` を毎回組み立てている。typed accessor の呼び出し頻度 (LLM request ごとに高々数回) ではマイクロ最適化する必要はない。
## 判断
**Approve** — 要件 1〜6 はすべて満たされ、設計判断 (prefix × layer 分離 / 単一値型 / ビルド時 vs ランタイム分岐 / tool description 非対象 / auto + manifest 両立) が正確に実装されている。ワークスペーステストは全緑。Non-blocking な API 整理の余地 (render の可視性、loader-catalog 間の is_file 二重チェック) はあるが、ticket を閉じる上の障害ではない。