diff --git a/crates/chat-cli/src/cli/agent/mod.rs b/crates/chat-cli/src/cli/agent/mod.rs index 369932091..e0d1a1444 100644 --- a/crates/chat-cli/src/cli/agent/mod.rs +++ b/crates/chat-cli/src/cli/agent/mod.rs @@ -69,6 +69,7 @@ use crate::util::{ self, MCP_SERVER_TOOL_DELIMITER, directories, + file_uri, }; pub const DEFAULT_AGENT_NAME: &str = "q_cli_default"; @@ -90,6 +91,12 @@ pub enum AgentConfigError { Io(#[from] std::io::Error), #[error("Failed to parse legacy mcp config: {0}")] BadLegacyMcpConfig(#[from] eyre::Report), + #[error("File URI not found: {uri} (resolved to {path})")] + FileUriNotFound { uri: String, path: PathBuf }, + #[error("Failed to read file URI: {uri} (resolved to {path}): {error}")] + FileUriReadError { uri: String, path: PathBuf, error: std::io::Error }, + #[error("Invalid file URI format: {uri}")] + InvalidFileUri { uri: String }, } /// An [Agent] is a declarative way of configuring a given instance of q chat. Currently, it is @@ -223,10 +230,15 @@ impl Agent { legacy_mcp_config: Option<&McpServerConfig>, output: &mut impl Write, ) -> Result<(), AgentConfigError> { - let Self { mcp_servers, .. } = self; - self.path = Some(path.to_path_buf()); + // Resolve file:// URIs in the prompt field + if let Some(resolved_prompt) = self.resolve_prompt()? { + self.prompt = Some(resolved_prompt); + } + + let Self { mcp_servers, .. } = self; + if let (true, Some(legacy_mcp_config)) = (self.use_legacy_mcp_json, legacy_mcp_config) { for (name, legacy_server) in &legacy_mcp_config.mcp_servers { if mcp_servers.mcp_servers.contains_key(name) { @@ -285,6 +297,48 @@ impl Agent { Ok(serde_json::to_string_pretty(&agent_clone)?) } + /// Resolves the prompt field, handling file:// URIs if present. + /// Returns the prompt content as-is if it doesn't start with file://, + /// or resolves the file URI and returns the file content. + pub fn resolve_prompt(&self) -> Result, AgentConfigError> { + match &self.prompt { + None => Ok(None), + Some(prompt_str) => { + if prompt_str.starts_with("file://") { + // Get the base path from the agent config file path + let base_path = match &self.path { + Some(path) => path.parent().unwrap_or(Path::new(".")), + None => Path::new("."), + }; + + // Resolve the file URI + match file_uri::resolve_file_uri(prompt_str, base_path) { + Ok(content) => Ok(Some(content)), + Err(file_uri::FileUriError::InvalidUri { uri }) => { + Err(AgentConfigError::InvalidFileUri { uri }) + } + Err(file_uri::FileUriError::FileNotFound { path }) => { + Err(AgentConfigError::FileUriNotFound { + uri: prompt_str.clone(), + path + }) + } + Err(file_uri::FileUriError::ReadError { path, source }) => { + Err(AgentConfigError::FileUriReadError { + uri: prompt_str.clone(), + path, + error: source + }) + } + } + } else { + // Return the prompt as-is for backward compatibility + Ok(Some(prompt_str.clone())) + } + } + } + } + /// Retrieves an agent by name. It does so via first seeking the given agent under local dir, /// and falling back to global dir if it does not exist in local. pub async fn get_agent_by_name(os: &Os, agent_name: &str) -> eyre::Result<(Agent, PathBuf)> { @@ -939,6 +993,8 @@ fn validate_agent_name(name: &str) -> eyre::Result<()> { #[cfg(test)] mod tests { use serde_json::json; + use std::fs; + use tempfile::TempDir; use super::*; use crate::cli::agent::hook::Source; @@ -1402,4 +1458,124 @@ mod tests { } } } + + #[test] + fn test_resolve_prompt_file_uri_relative() { + let temp_dir = TempDir::new().unwrap(); + + // Create a prompt file + let prompt_content = "You are a test agent with specific instructions."; + let prompt_file = temp_dir.path().join("test-prompt.md"); + fs::write(&prompt_file, prompt_content).unwrap(); + + // Create agent config file path + let config_file = temp_dir.path().join("test-agent.json"); + + // Create agent with file:// URI prompt + let agent = Agent { + name: "test-agent".to_string(), + prompt: Some("file://./test-prompt.md".to_string()), + path: Some(config_file), + ..Default::default() + }; + + // Test resolve_prompt + let resolved = agent.resolve_prompt().unwrap(); + assert_eq!(resolved, Some(prompt_content.to_string())); + } + + #[test] + fn test_resolve_prompt_file_uri_absolute() { + let temp_dir = TempDir::new().unwrap(); + + // Create a prompt file + let prompt_content = "Absolute path prompt content."; + let prompt_file = temp_dir.path().join("absolute-prompt.md"); + fs::write(&prompt_file, prompt_content).unwrap(); + + // Create agent with absolute file:// URI + let agent = Agent { + name: "test-agent".to_string(), + prompt: Some(format!("file://{}", prompt_file.display())), + path: Some(temp_dir.path().join("test-agent.json")), + ..Default::default() + }; + + // Test resolve_prompt + let resolved = agent.resolve_prompt().unwrap(); + assert_eq!(resolved, Some(prompt_content.to_string())); + } + + #[test] + fn test_resolve_prompt_inline_unchanged() { + let temp_dir = TempDir::new().unwrap(); + + // Create agent with inline prompt + let inline_prompt = "This is an inline prompt."; + let agent = Agent { + name: "test-agent".to_string(), + prompt: Some(inline_prompt.to_string()), + path: Some(temp_dir.path().join("test-agent.json")), + ..Default::default() + }; + + // Test resolve_prompt + let resolved = agent.resolve_prompt().unwrap(); + assert_eq!(resolved, Some(inline_prompt.to_string())); + } + + #[test] + fn test_resolve_prompt_file_not_found_error() { + let temp_dir = TempDir::new().unwrap(); + + // Create agent with non-existent file URI + let agent = Agent { + name: "test-agent".to_string(), + prompt: Some("file://./nonexistent.md".to_string()), + path: Some(temp_dir.path().join("test-agent.json")), + ..Default::default() + }; + + // Test resolve_prompt should fail + let result = agent.resolve_prompt(); + assert!(result.is_err()); + + if let Err(AgentConfigError::FileUriNotFound { uri, .. }) = result { + assert_eq!(uri, "file://./nonexistent.md"); + } else { + panic!("Expected FileUriNotFound error, got: {:?}", result); + } + } + + #[test] + fn test_resolve_prompt_no_prompt_field() { + let temp_dir = TempDir::new().unwrap(); + + // Create agent without prompt field + let agent = Agent { + name: "test-agent".to_string(), + prompt: None, + path: Some(temp_dir.path().join("test-agent.json")), + ..Default::default() + }; + + // Test resolve_prompt + let resolved = agent.resolve_prompt().unwrap(); + assert_eq!(resolved, None); + } + + #[test] + fn test_resolve_prompt_no_path_set() { + // Create agent without path set (should not happen in practice) + let agent = Agent { + name: "test-agent".to_string(), + prompt: Some("file://./test.md".to_string()), + path: None, + ..Default::default() + }; + + // Test resolve_prompt should fail gracefully + let result = agent.resolve_prompt(); + assert!(result.is_err()); + } } diff --git a/crates/chat-cli/src/util/file_uri.rs b/crates/chat-cli/src/util/file_uri.rs new file mode 100644 index 000000000..303d0aa3c --- /dev/null +++ b/crates/chat-cli/src/util/file_uri.rs @@ -0,0 +1,140 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use eyre::Result; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum FileUriError { + #[error("Invalid file URI format: {uri}")] + InvalidUri { uri: String }, + #[error("File not found: {path}")] + FileNotFound { path: PathBuf }, + #[error("Failed to read file {path}: {source}")] + ReadError { path: PathBuf, source: std::io::Error }, +} + +/// Resolves a file:// URI to its content, supporting both relative and absolute paths. +/// +/// # Arguments +/// * `uri` - The file:// URI to resolve +/// * `base_path` - Base path for resolving relative URIs (typically the agent config file directory) +/// +/// # Returns +/// The content of the file as a String +pub fn resolve_file_uri(uri: &str, base_path: &Path) -> Result { + // Validate URI format + if !uri.starts_with("file://") { + return Err(FileUriError::InvalidUri { uri: uri.to_string() }); + } + + // Extract the path part after "file://" + let path_str = uri.trim_start_matches("file://"); + + // Handle empty path + if path_str.is_empty() { + return Err(FileUriError::InvalidUri { uri: uri.to_string() }); + } + + // Resolve the path + let resolved_path = if path_str.starts_with('/') { + // Absolute path + PathBuf::from(path_str) + } else { + // Relative path - resolve relative to base_path + base_path.join(path_str) + }; + + // Check if file exists + if !resolved_path.exists() { + return Err(FileUriError::FileNotFound { path: resolved_path }); + } + + // Check if it's a file (not a directory) + if !resolved_path.is_file() { + return Err(FileUriError::FileNotFound { path: resolved_path }); + } + + // Read the file content + fs::read_to_string(&resolved_path) + .map_err(|source| FileUriError::ReadError { + path: resolved_path, + source + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_invalid_uri_format() { + let base = Path::new("/tmp"); + + // Not a file:// URI + let result = resolve_file_uri("http://example.com", base); + assert!(matches!(result, Err(FileUriError::InvalidUri { .. }))); + + // Empty path + let result = resolve_file_uri("file://", base); + assert!(matches!(result, Err(FileUriError::InvalidUri { .. }))); + } + + #[test] + fn test_file_not_found() { + let base = Path::new("/tmp"); + + let result = resolve_file_uri("file:///nonexistent/file.txt", base); + assert!(matches!(result, Err(FileUriError::FileNotFound { .. }))); + } + + #[test] + fn test_absolute_path_resolution() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let file_path = temp_dir.path().join("test.txt"); + let content = "Hello, World!"; + fs::write(&file_path, content)?; + + let uri = format!("file://{}", file_path.display()); + let base = Path::new("/some/other/path"); + + let result = resolve_file_uri(&uri, base)?; + assert_eq!(result, content); + + Ok(()) + } + + #[test] + fn test_relative_path_resolution() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let file_path = temp_dir.path().join("subdir").join("test.txt"); + fs::create_dir_all(file_path.parent().unwrap())?; + let content = "Relative content"; + fs::write(&file_path, content)?; + + let uri = "file://subdir/test.txt"; + let base = temp_dir.path(); + + let result = resolve_file_uri(uri, base)?; + assert_eq!(result, content); + + Ok(()) + } + + #[test] + fn test_directory_instead_of_file() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let dir_path = temp_dir.path().join("testdir"); + fs::create_dir(&dir_path)?; + + let uri = format!("file://{}", dir_path.display()); + let base = Path::new("/tmp"); + + let result = resolve_file_uri(&uri, base); + assert!(matches!(result, Err(FileUriError::FileNotFound { .. }))); + + Ok(()) + } +} diff --git a/crates/chat-cli/src/util/mod.rs b/crates/chat-cli/src/util/mod.rs index 48d8c94c9..11606c3df 100644 --- a/crates/chat-cli/src/util/mod.rs +++ b/crates/chat-cli/src/util/mod.rs @@ -1,5 +1,6 @@ pub mod consts; pub mod directories; +pub mod file_uri; pub mod knowledge_store; pub mod open; pub mod pattern_matching; diff --git a/docs/agent-format.md b/docs/agent-format.md index 35adc8cfa..7aeae5966 100644 --- a/docs/agent-format.md +++ b/docs/agent-format.md @@ -42,7 +42,9 @@ The `description` field provides a description of what the agent does. This is p ## Prompt Field -The `prompt` field is intended to provide high-level context to the agent, similar to a system prompt. +The `prompt` field is intended to provide high-level context to the agent, similar to a system prompt. It supports both inline text and file:// URIs to reference external files. + +### Inline Prompt ```json { @@ -50,6 +52,38 @@ The `prompt` field is intended to provide high-level context to the agent, simil } ``` +### File URI Prompt + +You can reference external files using `file://` URIs. This allows you to maintain long, complex prompts in separate files for better organization and version control, while keeping your agent configuration clean and readable. + +```json +{ + "prompt": "file://./my-agent-prompt.md" +} +``` + +#### File URI Path Resolution + +- **Relative paths**: Resolved relative to the agent configuration file's directory + - `"file://./prompt.md"` → `prompt.md` in the same directory as the agent config + - `"file://../shared/prompt.md"` → `prompt.md` in a parent directory +- **Absolute paths**: Used as-is + - `"file:///home/user/prompts/agent.md"` → Absolute path to the file + +#### File URI Examples + +```json +{ + "prompt": "file://./prompts/aws-expert.md" +} +``` + +```json +{ + "prompt": "file:///Users/developer/shared-prompts/rust-specialist.md" +} +``` + ## McpServers Field The `mcpServers` field specifies which Model Context Protocol (MCP) servers the agent has access to. Each server is defined with a command and optional arguments. diff --git a/schemas/agent-v1.json b/schemas/agent-v1.json index 5e72b0847..33efc8425 100644 --- a/schemas/agent-v1.json +++ b/schemas/agent-v1.json @@ -50,7 +50,7 @@ "default": null }, "prompt": { - "description": "The intention for this field is to provide high level context to the\nagent. This should be seen as the same category of context as a system prompt.", + "description": "The intention for this field is to provide high level context to the\nagent. This should be seen as the same category of context as a system prompt.\nSupports both inline text and file:// URIs to reference external files.\nExample: \"file://./my-prompt.md\" (relative to agent config file)", "type": [ "string", "null"