1use crate::core::{AgentError, Result};
2use crate::mcp::PROGRESSIVE_DISCOVERY_INSTRUCTIONS;
3use glob::glob;
4use schemars::{JsonSchema, Schema, SchemaGenerator};
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use std::collections::{BTreeMap, HashMap};
7use std::path::{Path, PathBuf};
8use thiserror::Error;
9use tokio::fs;
10use tracing::warn;
11use utils::shell_expander::ShellExpander;
12use utils::substitution::substitute_parameters;
13use utils::variables::VarError;
14use utils::{PathOrObject, ResourcePath, is_false, string_or_object_schema};
15
16#[derive(Debug, Clone, PartialEq)]
17pub enum Prompt {
18 Text(String),
19 File { path: PathBuf, args: Option<HashMap<String, String>>, cwd: PathBuf },
20 McpInstructions(McpInstructions),
21}
22
23#[derive(Debug, Clone, Default, PartialEq)]
24pub struct McpInstructions {
25 pub servers: BTreeMap<String, String>,
26 pub progressive_discovery: bool,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum PromptSource {
35 Text { text: String },
36 File { path: ResourcePath, optional: bool },
37 Glob { pattern: ResourcePath, optional: bool },
38}
39
40impl PromptSource {
41 pub fn file(path: impl Into<ResourcePath>) -> Self {
42 Self::File { path: path.into(), optional: false }
43 }
44
45 pub fn glob(pattern: impl Into<ResourcePath>) -> Self {
46 Self::Glob { pattern: pattern.into(), optional: false }
47 }
48
49 #[must_use]
50 pub fn optional(self) -> Self {
51 match self {
52 Self::File { path, .. } => Self::File { path, optional: true },
53 Self::Glob { pattern, .. } => Self::Glob { pattern, optional: true },
54 Self::Text { .. } => self,
55 }
56 }
57
58 pub fn path(&self) -> Option<&str> {
60 match self {
61 Self::File { path, .. } => Some(path.as_authored()),
62 Self::Glob { pattern, .. } => Some(pattern.as_authored()),
63 Self::Text { .. } => None,
64 }
65 }
66
67 pub fn is_optional(&self) -> bool {
68 match self {
69 Self::File { optional, .. } | Self::Glob { optional, .. } => *optional,
70 Self::Text { .. } => false,
71 }
72 }
73}
74
75impl From<&str> for PromptSource {
76 fn from(value: &str) -> Self {
77 Self::file(value)
78 }
79}
80
81impl From<String> for PromptSource {
82 fn from(value: String) -> Self {
83 Self::file(value)
84 }
85}
86
87impl From<PromptSourceObject> for PromptSource {
88 fn from(object: PromptSourceObject) -> Self {
89 match object {
90 PromptSourceObject::Text { text } => Self::Text { text },
91 PromptSourceObject::File { path, optional } => Self::File { path, optional },
92 PromptSourceObject::Glob { pattern, optional } => Self::Glob { pattern, optional },
93 }
94 }
95}
96
97impl<'de> Deserialize<'de> for PromptSource {
98 fn deserialize<T: Deserializer<'de>>(deserializer: T) -> std::result::Result<Self, T::Error> {
99 Ok(match PathOrObject::<PromptSourceObject>::deserialize(deserializer)? {
100 PathOrObject::Path(path) => Self::File { path, optional: false },
101 PathOrObject::Object(object) => object.into(),
102 })
103 }
104}
105
106impl Serialize for PromptSource {
107 fn serialize<T: Serializer>(&self, serializer: T) -> std::result::Result<T::Ok, T::Error> {
108 match self {
109 Self::File { path, optional: false } => path.serialize(serializer),
110 Self::File { path, optional } => {
111 Serialize::serialize(&PromptSourceObject::File { path: path.clone(), optional: *optional }, serializer)
112 }
113 Self::Text { text } => Serialize::serialize(&PromptSourceObject::Text { text: text.clone() }, serializer),
114 Self::Glob { pattern, optional } => Serialize::serialize(
115 &PromptSourceObject::Glob { pattern: pattern.clone(), optional: *optional },
116 serializer,
117 ),
118 }
119 }
120}
121
122impl JsonSchema for PromptSource {
123 fn schema_name() -> std::borrow::Cow<'static, str> {
124 "PromptSource".into()
125 }
126
127 fn json_schema(generator: &mut SchemaGenerator) -> Schema {
128 string_or_object_schema(
129 include_str!("../docs/prompt_source.md"),
130 &generator.subschema_for::<PromptSourceObject>().to_value(),
131 )
132 }
133}
134
135#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)]
136#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
137enum PromptSourceObject {
138 Text {
139 text: String,
141 },
142 File {
143 path: ResourcePath,
145 #[serde(default, skip_serializing_if = "is_false")]
147 optional: bool,
148 },
149 Glob {
150 pattern: ResourcePath,
152 #[serde(default, skip_serializing_if = "is_false")]
154 optional: bool,
155 },
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Error)]
160pub enum PromptSourceError {
161 #[error("Invalid glob pattern '{pattern}': {error}")]
163 InvalidGlobPattern { pattern: String, error: String },
164
165 #[error("Prompt file '{path}' does not exist")]
167 Missing { path: String },
168
169 #[error("Prompt glob '{pattern}' matched no files")]
171 ZeroMatch { pattern: String },
172
173 #[error("Prompt entry '{pattern}' references undefined variable '{variable}'")]
175 UnresolvedVariable { pattern: String, variable: String },
176}
177
178impl Prompt {
179 pub fn text(str: &str) -> Self {
180 Self::Text(str.to_string())
181 }
182
183 pub fn file(path: impl Into<PathBuf>, cwd: impl Into<PathBuf>) -> Self {
184 Self::File { path: path.into(), args: None, cwd: cwd.into() }
185 }
186
187 pub fn from_sources(
189 workspace_root: &Path,
190 sources: &[PromptSource],
191 ) -> std::result::Result<Vec<Prompt>, PromptSourceError> {
192 let mut prompts = Vec::new();
193 for source in sources {
194 if let PromptSource::Text { text } = source {
195 prompts.push(Prompt::text(text));
196 continue;
197 }
198 match resolve_source_files(workspace_root, source) {
199 Ok(paths) => {
200 for path in paths {
201 prompts.push(Prompt::file(path, workspace_root.to_path_buf()));
202 }
203 }
204 Err(PromptSourceError::Missing { .. }) if source.is_optional() => {}
205 Err(PromptSourceError::UnresolvedVariable { variable, .. }) if source.is_optional() => {
206 warn!(
207 "Skipping optional prompt entry '{}': variable '{variable}' is not defined",
208 source.path().unwrap_or_default()
209 );
210 }
211 Err(error) => return Err(error),
212 }
213 }
214 Ok(prompts)
215 }
216
217 pub async fn build(&self) -> Result<String> {
219 match self {
220 Prompt::Text(text) => Ok(text.clone()),
221 Prompt::File { path, args, cwd } => {
222 let content = Self::resolve_file(path).await?;
223 let substituted = substitute_parameters(&content, args);
224 let expander = ShellExpander::new();
225 Ok(expander.expand(&substituted, cwd).await)
226 }
227 Prompt::McpInstructions(instructions) => Ok(format_mcp_instructions(instructions)),
228 }
229 }
230
231 pub async fn build_all(prompts: &[Prompt]) -> Result<String> {
233 let mut parts = Vec::with_capacity(prompts.len());
234 for p in prompts {
235 let part = p.build().await?;
236 if !part.is_empty() {
237 parts.push(part);
238 }
239 }
240 Ok(parts.join("\n\n"))
241 }
242
243 async fn resolve_file(path: &Path) -> Result<String> {
244 fs::read_to_string(path)
245 .await
246 .map_err(|e| AgentError::IoError(format!("Failed to read file '{}': {e}", path.display())))
247 }
248}
249
250fn resolve_source_files(
251 workspace_root: &Path,
252 source: &PromptSource,
253) -> std::result::Result<Vec<PathBuf>, PromptSourceError> {
254 match source {
255 PromptSource::Text { .. } => Ok(Vec::new()),
256 PromptSource::File { path, .. } => {
257 let full_path = resolve_path(path, workspace_root)?;
258 if full_path.is_file() {
259 Ok(vec![full_path])
260 } else {
261 Err(PromptSourceError::Missing { path: path.as_authored().to_string() })
262 }
263 }
264 PromptSource::Glob { pattern, optional } => {
265 let full_pattern = resolve_path(pattern, workspace_root)?;
266 let mut paths: Vec<PathBuf> = glob(&full_pattern.to_string_lossy())
267 .map_err(|e| PromptSourceError::InvalidGlobPattern {
268 pattern: pattern.as_authored().to_string(),
269 error: e.to_string(),
270 })?
271 .filter_map(std::result::Result::ok)
272 .filter(|path| path.is_file())
273 .collect();
274 paths.sort();
275 if paths.is_empty() && !*optional {
276 Err(PromptSourceError::ZeroMatch { pattern: pattern.as_authored().to_string() })
277 } else {
278 Ok(paths)
279 }
280 }
281 }
282}
283
284fn resolve_path(path: &ResourcePath, workspace_root: &Path) -> std::result::Result<PathBuf, PromptSourceError> {
285 path.resolve(workspace_root).map_err(|VarError::NotFound(variable)| PromptSourceError::UnresolvedVariable {
286 pattern: path.as_authored().to_string(),
287 variable,
288 })
289}
290
291pub struct PromptCache {
292 prompts: Vec<Prompt>,
293 entries: Vec<(Prompt, String)>,
294}
295
296impl PromptCache {
297 pub fn new(mut prompts: Vec<Prompt>) -> Self {
298 if !prompts.iter().any(|p| matches!(p, Prompt::McpInstructions(_))) {
299 prompts.push(Prompt::McpInstructions(McpInstructions::default()));
300 }
301 Self { prompts, entries: Vec::new() }
302 }
303
304 pub fn set_mcp_instructions(&mut self, instructions: McpInstructions) -> bool {
305 let Some(Prompt::McpInstructions(current)) =
306 self.prompts.iter_mut().find(|prompt| matches!(prompt, Prompt::McpInstructions(_)))
307 else {
308 return false;
309 };
310 if *current == instructions {
311 return false;
312 }
313 *current = instructions;
314 true
315 }
316
317 pub async fn render(&mut self) -> Result<String> {
318 self.entries.truncate(self.prompts.len());
319 let mut rendered_prompt = String::new();
320 for i in 0..self.prompts.len() {
321 let prompt = &self.prompts[i];
322 match self.entries.get_mut(i) {
323 Some((cached, _)) if *cached == *prompt => {}
324 Some(entry) => *entry = (prompt.clone(), prompt.build().await?),
325 None => self.entries.push((prompt.clone(), prompt.build().await?)),
326 }
327
328 let (_, body) = &self.entries[i];
329 if !body.is_empty() {
330 if !rendered_prompt.is_empty() {
331 rendered_prompt.push_str("\n\n");
332 }
333 rendered_prompt.push_str(body);
334 }
335 }
336 Ok(rendered_prompt)
337 }
338}
339
340fn format_mcp_instructions(instructions: &McpInstructions) -> String {
341 let mut sections = Vec::new();
342 if !instructions.servers.is_empty() {
343 let mut parts = vec!["# MCP Server Instructions\n".to_string()];
344 parts.push("You are connected to the following MCP servers:\n".to_string());
345 for (server_name, body) in &instructions.servers {
346 parts.push(format!("<mcp-server name=\"{server_name}\">\n{body}\n</mcp-server>\n"));
347 }
348 sections.push(parts.join("\n"));
349 }
350 if instructions.progressive_discovery {
351 sections.push(format!("# Deferred MCP Tools\n\n{PROGRESSIVE_DISCOVERY_INSTRUCTIONS}"));
352 }
353 sections.join("\n\n")
354}
355
356#[cfg(test)]
357mod tests {
358 use std::fs::{create_dir_all, write};
359
360 use super::*;
361 use crate::testing::mcp_instructions as instructions;
362
363 #[tokio::test]
364 async fn build_text_prompt() {
365 let prompt = Prompt::text("Hello, world!");
366 let result = prompt.build().await.unwrap();
367 assert_eq!(result, "Hello, world!");
368 }
369
370 #[tokio::test]
371 async fn build_all_concatenates_prompts() {
372 let prompts = vec![Prompt::text("Part one"), Prompt::text("Part two")];
373 let result = Prompt::build_all(&prompts).await.unwrap();
374 assert_eq!(result, "Part one\n\nPart two");
375 }
376
377 #[tokio::test]
378 async fn build_all_concatenates_multiple_files() {
379 let dir = tempfile::tempdir().unwrap();
380 std::fs::write(dir.path().join("AGENTS.md"), "Agent instructions").unwrap();
381 std::fs::write(dir.path().join("SYSTEM.md"), "System prompt").unwrap();
382
383 let prompts = vec![
384 Prompt::file(dir.path().join("AGENTS.md"), dir.path()),
385 Prompt::file(dir.path().join("SYSTEM.md"), dir.path()),
386 ];
387 let result = Prompt::build_all(&prompts).await.unwrap();
388 assert!(result.contains("Agent instructions"));
389 assert!(result.contains("System prompt"));
390 assert!(result.contains("\n\n"));
391 }
392
393 #[tokio::test]
394 async fn build_all_skips_empty_parts() {
395 let prompts = vec![Prompt::text("Part one"), Prompt::text(""), Prompt::text("Part two")];
396 let result = Prompt::build_all(&prompts).await.unwrap();
397 assert_eq!(result, "Part one\n\nPart two");
398 }
399
400 #[tokio::test]
401 async fn prompt_cache_render_matches_build_all_on_first_render() {
402 let prompts = vec![
403 Prompt::text("first"),
404 Prompt::McpInstructions(instructions(&[("srv", "body")])),
405 Prompt::text("last"),
406 ];
407 let expected = Prompt::build_all(&prompts).await.unwrap();
408 let mut cache = PromptCache::new(prompts);
409 assert_eq!(cache.render().await.unwrap(), expected);
410 }
411
412 #[tokio::test]
413 async fn prompt_cache_reuses_unchanged_slots() {
414 use std::fs::{remove_file, write};
415
416 let dir = tempfile::tempdir().unwrap();
417 write(dir.path().join("AGENTS.md"), "cached body").unwrap();
418 let mut cache = PromptCache::new(vec![
419 Prompt::file(dir.path().join("AGENTS.md"), dir.path()),
420 Prompt::McpInstructions(McpInstructions::default()),
421 ]);
422
423 cache.render().await.unwrap();
424
425 remove_file(dir.path().join("AGENTS.md")).unwrap();
427 cache.set_mcp_instructions(instructions(&[("srv", "instr")]));
428
429 let rendered = cache.render().await.unwrap();
430 assert!(rendered.contains("cached body"));
431 assert!(rendered.contains("instr"));
432 }
433
434 #[tokio::test]
435 async fn prompt_cache_empty_renders_empty() {
436 assert_eq!(PromptCache::new(vec![]).render().await.unwrap(), "");
437 }
438
439 #[tokio::test]
440 async fn prompt_cache_drops_empty_slots() {
441 let mut cache = PromptCache::new(vec![Prompt::text("a"), Prompt::text("b")]);
442 assert_eq!(cache.render().await.unwrap(), "a\n\nb");
443 }
444
445 #[tokio::test]
446 async fn build_file_expands_shell_commands() {
447 let dir = tempfile::tempdir().unwrap();
448 write(dir.path().join("AGENTS.md"), "Instructions\n\nbranch: !`echo main`\n\nRules").unwrap();
449
450 let prompt = Prompt::file(dir.path().join("AGENTS.md"), dir.path().to_path_buf());
451 let result = prompt.build().await.unwrap();
452 assert!(result.contains("Instructions"));
453 assert!(result.contains("branch: main"));
454 assert!(result.contains("Rules"));
455 assert!(!result.contains("!`"));
456 }
457
458 #[tokio::test]
459 async fn build_file_runs_shell_in_cwd() {
460 let dir = tempfile::tempdir().unwrap();
461 write(dir.path().join("sentinel.txt"), "").unwrap();
462 let prompt_path = dir.path().join("AGENTS.md");
463 write(&prompt_path, "files: !`ls`").unwrap();
464
465 let prompt = Prompt::file(prompt_path, dir.path().to_path_buf());
466 let result = prompt.build().await.unwrap();
467 assert!(result.contains("sentinel.txt"), "expected sentinel.txt in output: {result}");
468 }
469
470 #[tokio::test]
471 async fn build_file_handles_multiple_commands() {
472 let dir = tempfile::tempdir().unwrap();
473 let prompt_path = dir.path().join("AGENTS.md");
474 write(&prompt_path, "a=!`echo one`, b=!`echo two`").unwrap();
475
476 let prompt = Prompt::file(prompt_path, dir.path().to_path_buf());
477 let result = prompt.build().await.unwrap();
478 assert_eq!(result, "a=one, b=two");
479 }
480
481 #[tokio::test]
482 async fn build_file_substitutes_empty_on_failure() {
483 let dir = tempfile::tempdir().unwrap();
484 let prompt_path = dir.path().join("AGENTS.md");
485 write(&prompt_path, "before !`exit 1` after").unwrap();
486
487 let prompt = Prompt::file(prompt_path, dir.path().to_path_buf());
488 let result = prompt.build().await.unwrap();
489 assert_eq!(result, "before after");
490 }
491
492 #[tokio::test]
493 async fn build_file_trims_trailing_whitespace() {
494 let dir = tempfile::tempdir().unwrap();
495 let prompt_path = dir.path().join("AGENTS.md");
496 write(&prompt_path, "!`printf 'hi\\n\\n'`").unwrap();
497
498 let prompt = Prompt::file(prompt_path, dir.path().to_path_buf());
499 let result = prompt.build().await.unwrap();
500 assert_eq!(result, "hi");
501 }
502
503 #[test]
504 fn optional_file_source_skips_missing_file() {
505 let dir = tempfile::tempdir().unwrap();
506 write(dir.path().join("EXISTS.md"), "exists").unwrap();
507
508 let sources = vec![PromptSource::file("EXISTS.md"), PromptSource::file("MISSING.md").optional()];
509 let prompts = Prompt::from_sources(dir.path(), &sources).unwrap();
510 assert_eq!(prompts.len(), 1);
511 }
512
513 #[test]
514 fn optional_glob_source_skips_zero_matches() {
515 let dir = tempfile::tempdir().unwrap();
516 write(dir.path().join("EXISTS.md"), "exists").unwrap();
517
518 let sources = vec![PromptSource::file("EXISTS.md"), PromptSource::glob("nonexistent*.md").optional()];
519 let prompts = Prompt::from_sources(dir.path(), &sources).unwrap();
520 assert_eq!(prompts.len(), 1);
521 }
522
523 #[test]
524 fn required_glob_source_expands_to_one_prompt_per_match() {
525 let dir = tempfile::tempdir().unwrap();
526 let rules_dir = dir.path().join(".aether/rules");
527 create_dir_all(&rules_dir).unwrap();
528 write(rules_dir.join("a.md"), "a").unwrap();
529 write(rules_dir.join("b.md"), "b").unwrap();
530
531 let sources = vec![PromptSource::glob(".aether/rules/*.md")];
532 let prompts = Prompt::from_sources(dir.path(), &sources).unwrap();
533 assert_eq!(prompts.len(), 2);
534 }
535
536 #[test]
537 fn required_glob_source_with_no_matches_errors() {
538 let dir = tempfile::tempdir().unwrap();
539 let sources = vec![PromptSource::glob("nonexistent*.md")];
540 let err = Prompt::from_sources(dir.path(), &sources).unwrap_err();
541 assert!(matches!(err, PromptSourceError::ZeroMatch { .. }));
542 }
543
544 #[test]
545 fn optional_glob_source_still_errors_on_invalid_pattern() {
546 let dir = tempfile::tempdir().unwrap();
547 let sources = vec![PromptSource::glob("[invalid").optional()];
548 let err = Prompt::from_sources(dir.path(), &sources).unwrap_err();
549 assert!(matches!(err, PromptSourceError::InvalidGlobPattern { .. }));
550 }
551
552 #[test]
553 fn optional_file_source_skips_unresolved_variable() {
554 let dir = tempfile::tempdir().unwrap();
555 write(dir.path().join("EXISTS.md"), "exists").unwrap();
556
557 let sources = vec![
558 PromptSource::file("EXISTS.md"),
559 PromptSource::file("${DEFINITELY_NOT_SET_VAR_PROMPT_FILE}/foo.md").optional(),
560 ];
561 let prompts = Prompt::from_sources(dir.path(), &sources).unwrap();
562 assert_eq!(prompts.len(), 1);
563 }
564
565 #[test]
566 fn optional_glob_source_skips_unresolved_variable() {
567 let dir = tempfile::tempdir().unwrap();
568 write(dir.path().join("EXISTS.md"), "exists").unwrap();
569
570 let sources = vec![
571 PromptSource::file("EXISTS.md"),
572 PromptSource::glob("${DEFINITELY_NOT_SET_VAR_PROMPT_GLOB}/*.md").optional(),
573 ];
574 let prompts = Prompt::from_sources(dir.path(), &sources).unwrap();
575 assert_eq!(prompts.len(), 1);
576 }
577
578 #[test]
579 fn required_file_source_errors_on_unresolved_variable() {
580 let dir = tempfile::tempdir().unwrap();
581 let sources = vec![PromptSource::file("${DEFINITELY_NOT_SET_VAR_PROMPT_REQ}/foo.md")];
582 let err = Prompt::from_sources(dir.path(), &sources).unwrap_err();
583 assert!(matches!(err, PromptSourceError::UnresolvedVariable { .. }));
584 }
585
586 #[test]
587 fn prompt_source_string_shorthand_is_required_file() {
588 let source: PromptSource = serde_json::from_str(r#""SYSTEM.md""#).unwrap();
589 assert_eq!(source, PromptSource::file("SYSTEM.md"));
590 }
591
592 #[test]
593 fn optional_prompt_source_serializes_as_typed_object() {
594 let source = PromptSource::file("${WORKSPACE}/AGENTS.md").optional();
595 let value = serde_json::to_value(&source).unwrap();
596 assert_eq!(value, serde_json::json!({"type":"file","path":"${WORKSPACE}/AGENTS.md","optional":true}));
597
598 let source = PromptSource::file("SYSTEM.md");
600 let value = serde_json::to_value(&source).unwrap();
601 assert_eq!(value, serde_json::json!("SYSTEM.md"));
602 }
603
604 #[test]
605 fn optional_prompt_source_deserializes_from_typed_object() {
606 let source: PromptSource =
607 serde_json::from_str(r#"{"type":"file","path":"${WORKSPACE}/AGENTS.md","optional":true}"#).unwrap();
608 assert_eq!(source, PromptSource::file("${WORKSPACE}/AGENTS.md").optional());
609 }
610
611 #[test]
612 fn optional_glob_source_deserializes_from_typed_object() {
613 let source: PromptSource =
614 serde_json::from_str(r#"{"type":"glob","pattern":"${WORKSPACE}/.aether/rules/*.md","optional":true}"#)
615 .unwrap();
616 assert_eq!(source, PromptSource::glob("${WORKSPACE}/.aether/rules/*.md").optional());
617 }
618}