From d20fda07b8ba66c6baaa027d5438ebfce0521461 Mon Sep 17 00:00:00 2001 From: Chris Beck Date: Mon, 8 Dec 2025 22:54:27 -0700 Subject: [PATCH] add support for multiple system prompt files --- signal-gateway/src/claude/mod.rs | 12 ++--- signal-gateway/src/claude/worker.rs | 75 ++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/signal-gateway/src/claude/mod.rs b/signal-gateway/src/claude/mod.rs index 20b2acb..0399645 100644 --- a/signal-gateway/src/claude/mod.rs +++ b/signal-gateway/src/claude/mod.rs @@ -30,14 +30,14 @@ pub struct ClaudeConfig { /// Path to file containing the Claude API key. #[conf(long, env)] pub api_key_file: PathBuf, - /// Path to file containing the system prompt. - #[conf(long, env)] - pub system_prompt_file: PathBuf, + /// Paths to files containing system prompt components (in order, last one is cached). + #[conf(repeat, long, env)] + pub system_prompt_files: Vec, /// Claude API URL. #[conf(long, env, default_value = "https://api.anthropic.com/v1/messages")] pub claude_api_url: String, /// Claude model to use. - #[conf(long, env, default_value = "claude-opus-4-20250514")] + #[conf(long, env, default_value = "claude-sonnet-4-5-20250929")] pub claude_model: String, /// Maximum tokens in the response. #[conf(long, env, default_value = "1024")] @@ -54,8 +54,8 @@ pub enum ClaudeError { #[error("failed to read API key file: {0}")] ApiKeyRead(std::io::Error), /// Failed to read system prompt file. - #[error("failed to read system prompt file: {0}")] - SystemPromptRead(std::io::Error), + #[error("failed to read system prompt file {0:?}: {1}")] + SystemPromptRead(PathBuf, std::io::Error), /// HTTP request failed. #[error("HTTP request failed: {0}")] Request(#[from] reqwest::Error), diff --git a/signal-gateway/src/claude/worker.rs b/signal-gateway/src/claude/worker.rs index a8a271c..94aa0e4 100644 --- a/signal-gateway/src/claude/worker.rs +++ b/signal-gateway/src/claude/worker.rs @@ -75,7 +75,7 @@ pub struct ClaudeWorker { config: ClaudeConfig, client: reqwest::Client, api_key: String, - system_prompt: String, + system_prompts: Vec, // FIXME: use this and append to system prompt within tags #[allow(dead_code)] summary: String, @@ -88,7 +88,7 @@ pub struct ClaudeWorker { impl ClaudeWorker { /// Create a new Claude worker. /// - /// Reads the API key and system prompt from the configured files. + /// Reads the API key and system prompts from the configured files. pub fn new( config: ClaudeConfig, tool_executor: Weak, @@ -100,14 +100,20 @@ impl ClaudeWorker { .trim() .to_owned(); - let system_prompt = std::fs::read_to_string(&config.system_prompt_file) - .map_err(ClaudeError::SystemPromptRead)?; + let system_prompts: Vec = config + .system_prompt_files + .iter() + .map(|path| { + std::fs::read_to_string(path) + .map_err(|e| ClaudeError::SystemPromptRead(path.clone(), e)) + }) + .collect::>()?; Ok(Self { config, client: reqwest::Client::new(), api_key, - system_prompt, + system_prompts, summary: String::new(), messages: Default::default(), tool_executor, @@ -211,6 +217,21 @@ impl ClaudeWorker { info!("Claude request: {}", text); } + // Build system content blocks, caching only the last one + let system: Vec = self + .system_prompts + .iter() + .enumerate() + .map(|(i, text)| { + let content = SystemContent::text(text); + if i == self.system_prompts.len() - 1 { + content.cached() + } else { + content + } + }) + .collect(); + for iteration in 0..max_iterations { // Check for stop before making API call self.check_stop()?; @@ -218,7 +239,7 @@ impl ClaudeWorker { let request_body = MessagesRequest { model: &self.config.claude_model, max_tokens: self.config.claude_max_tokens, - system: &self.system_prompt, + system: &system, messages: &self.messages, tools: tools.clone(), }; @@ -314,12 +335,52 @@ impl ClaudeWorker { struct MessagesRequest<'a> { model: &'a str, max_tokens: u32, - system: &'a str, + system: &'a [SystemContent], messages: &'a [MessageContent], #[serde(skip_serializing_if = "Vec::is_empty")] tools: Vec, } +/// A content block in the system prompt array. +#[derive(Clone, Serialize)] +struct SystemContent { + #[serde(rename = "type")] + content_type: &'static str, + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, +} + +impl SystemContent { + fn text(text: impl Into) -> Self { + Self { + content_type: "text", + text: text.into(), + cache_control: None, + } + } + + fn cached(mut self) -> Self { + self.cache_control = Some(CacheControl::ephemeral()); + self + } +} + +/// Cache control directive for prompt caching. +#[derive(Clone, Serialize)] +struct CacheControl { + #[serde(rename = "type")] + cache_type: &'static str, +} + +impl CacheControl { + fn ephemeral() -> Self { + Self { + cache_type: "ephemeral", + } + } +} + /// A message in the conversation (can have multiple content blocks). #[derive(Clone, Debug, Serialize, Deserialize)] struct MessageContent {