Skip to content

Commit da314d1

Browse files
authored
Merge pull request #44 from tcdent/claude/dynamic-system-prompts-V7foF
Add mdsh support for dynamic system prompts
2 parents 451a60b + ee21a4f commit da314d1

7 files changed

Lines changed: 238 additions & 40 deletions

File tree

Cargo.lock

Lines changed: 40 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ clap = { version = "4", features = ["derive", "env"], optional = true }
5151
anyhow = "1"
5252
thiserror = "2"
5353

54+
# Markdown shell preprocessor (for dynamic system prompts)
55+
mdsh = { git = "https://github.com/zimbatm/mdsh" }
56+
5457
# Utilities
5558
fancy-regex = "0.14"
5659
unicode-width = "0.2"

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,34 @@ max_tokens = 8192
8787
theme = "base16-ocean.dark"
8888
```
8989

90+
## Custom System Prompts
91+
92+
You can extend Codey's system prompt by creating `SYSTEM.md` files that are automatically appended to the base prompt. These files are loaded from two locations (in order):
93+
94+
1. **User config**: `~/.config/codey/SYSTEM.md` - personal customizations
95+
2. **Project**: `.codey/SYSTEM.md` - project-specific instructions
96+
97+
### Dynamic Content with mdsh
98+
99+
SYSTEM.md files support [mdsh](https://github.com/zimbatm/mdsh) syntax, allowing you to embed shell commands that are executed dynamically. This is useful for including context that changes over time.
100+
101+
### Example SYSTEM.md
102+
103+
```markdown
104+
Today is `$ date '+%d %B %Y'`.
105+
106+
```$
107+
if which linctl > /dev/null 2>&1; then
108+
echo "Use linctl to manage Linear tickets."
109+
fi
110+
```
111+
112+
## Guidelines
113+
- Follow the existing code style
114+
```
115+
116+
Commands are re-executed on every LLM request, so the prompt always reflects the current state of your environment. Note that this may cause cache invalidation if command output changes between requests.
117+
90118
## Keybindings
91119

92120
| Key | Action |

src/app.rs

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ use ratatui::{
1717
use tokio::sync::oneshot;
1818

1919
use crate::commands::Command;
20-
use crate::config::{AgentRuntimeConfig, Config, CODEY_DIR};
20+
use crate::config::{AgentRuntimeConfig, Config};
2121
use crate::ide::{Ide, IdeEvent, Nvim};
2222
use crate::llm::{Agent, AgentId, AgentRegistry, AgentStep, RequestMode};
2323
#[cfg(feature = "profiling")]
2424
use crate::{profile_frame, profile_span};
25-
use crate::prompts::{COMPACTION_PROMPT, SYSTEM_MD_FILENAME, SYSTEM_PROMPT, WELCOME_MESSAGE};
25+
use crate::prompts::{SystemPrompt, COMPACTION_PROMPT, WELCOME_MESSAGE};
2626
use crate::tool_filter::ToolFilters;
2727
use crate::tools::{
2828
init_agent_context, update_agent_oauth, Effect, ToolCall, ToolDecision, ToolEvent,
@@ -36,34 +36,6 @@ const MIN_FRAME_TIME: Duration = Duration::from_millis(16);
3636
pub const APP_NAME: &str = "Codey";
3737
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
3838

39-
/// Build the complete system prompt by appending content from SYSTEM.md files.
40-
/// Checks (in order):
41-
/// 1. User config: ~/.config/codey/SYSTEM.md
42-
/// 2. Project: .codey/SYSTEM.md
43-
fn build_system_prompt() -> String {
44-
let mut prompt = SYSTEM_PROMPT.to_string();
45-
46-
// Check user config directory: ~/.config/codey/SYSTEM.md
47-
if let Some(config_dir) = Config::config_dir() {
48-
let user_system_md = config_dir.join(SYSTEM_MD_FILENAME);
49-
if let Ok(content) = std::fs::read_to_string(&user_system_md) {
50-
tracing::debug!("Appending user SYSTEM.md from {:?}", user_system_md);
51-
prompt.push_str("\n\n");
52-
prompt.push_str(&content);
53-
}
54-
}
55-
56-
// Check project directory: .codey/SYSTEM.md
57-
let project_system_md = std::path::Path::new(CODEY_DIR).join(SYSTEM_MD_FILENAME);
58-
if let Ok(content) = std::fs::read_to_string(&project_system_md) {
59-
tracing::debug!("Appending project SYSTEM.md from {:?}", project_system_md);
60-
prompt.push_str("\n\n");
61-
prompt.push_str(&content);
62-
}
63-
64-
prompt
65-
}
66-
6739
/// Result of handling an action
6840
enum ActionResult {
6941
NoOp,
@@ -345,9 +317,11 @@ impl App {
345317
self.oauth.clone(),
346318
);
347319

348-
let mut agent = Agent::new(
320+
// Use dynamic prompt builder so mdsh commands are re-executed on each LLM call
321+
let system_prompt = SystemPrompt::new();
322+
let mut agent = Agent::with_dynamic_prompt(
349323
AgentRuntimeConfig::foreground(&self.config),
350-
&build_system_prompt(),
324+
Box::new(move || system_prompt.build()),
351325
self.oauth.clone(),
352326
self.tool_executor.tools().clone(),
353327
);

src/llm/agent.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ impl RequestMode {
153153
}
154154
}
155155

156+
/// A function that builds a dynamic system prompt.
157+
/// Called before each LLM request to allow prompt content to change.
158+
pub type SystemPromptBuilder = Box<dyn Fn() -> String + Send + Sync>;
159+
156160
/// Agent for handling conversations
157161
pub struct Agent {
158162
client: Client,
@@ -163,6 +167,8 @@ pub struct Agent {
163167
total_usage: Usage,
164168
/// OAuth credentials for Claude Max (if available)
165169
oauth: Option<OAuthCredentials>,
170+
/// Optional dynamic prompt builder - called before each request
171+
system_prompt_builder: Option<SystemPromptBuilder>,
166172

167173
// Streaming state (Some when actively processing)
168174
state: Option<StreamState>,
@@ -194,6 +200,41 @@ impl Agent {
194200
system_prompt: system_prompt.to_string(),
195201
total_usage: Usage::default(),
196202
oauth,
203+
system_prompt_builder: None,
204+
205+
// Streaming state starts empty
206+
state: None,
207+
active_stream: None,
208+
mode: RequestMode::Normal,
209+
210+
// Accumulated during streaming
211+
streaming_text: String::new(),
212+
streaming_tool_calls: Vec::new(),
213+
streaming_thinking: Vec::new(),
214+
tool_responses: Vec::new(),
215+
}
216+
}
217+
218+
/// Create a new agent with a dynamic system prompt builder.
219+
///
220+
/// The builder is called before each LLM request, allowing the prompt
221+
/// to include dynamic content (e.g., mdsh-processed shell command output).
222+
pub fn with_dynamic_prompt(
223+
config: AgentRuntimeConfig,
224+
prompt_builder: SystemPromptBuilder,
225+
oauth: Option<OAuthCredentials>,
226+
tools: ToolRegistry,
227+
) -> Self {
228+
let system_prompt = prompt_builder();
229+
Self {
230+
client: Client::default(),
231+
config,
232+
tools,
233+
messages: vec![ChatMessage::system(&system_prompt)],
234+
system_prompt,
235+
total_usage: Usage::default(),
236+
oauth,
237+
system_prompt_builder: Some(prompt_builder),
197238

198239
// Streaming state starts empty
199240
state: None,
@@ -352,6 +393,28 @@ impl Agent {
352393
self.oauth = oauth;
353394
}
354395

396+
/// Refresh the system prompt if a dynamic builder is configured.
397+
///
398+
/// This is called before each LLM request to allow the prompt content
399+
/// to change (e.g., when mdsh commands return different output).
400+
fn refresh_system_prompt(&mut self) {
401+
if let Some(ref builder) = self.system_prompt_builder {
402+
let new_prompt = builder();
403+
if new_prompt != self.system_prompt {
404+
debug!(
405+
"System prompt changed ({} -> {} chars)",
406+
self.system_prompt.len(),
407+
new_prompt.len()
408+
);
409+
self.system_prompt = new_prompt.clone();
410+
// Update the first message (system message)
411+
if !self.messages.is_empty() {
412+
self.messages[0] = ChatMessage::system(&new_prompt);
413+
}
414+
}
415+
}
416+
}
417+
355418
/// Get total usage statistics
356419
pub fn total_usage(&self) -> Usage {
357420
self.total_usage
@@ -505,6 +568,9 @@ impl Agent {
505568
match self.state.as_ref()? {
506569
StreamState::NeedsChatRequest => {
507570
debug!("Agent state: NeedsChatRequest, clearing streaming data");
571+
// Refresh dynamic system prompt before each request
572+
self.refresh_system_prompt();
573+
508574
// Clear accumulated streaming data for new request
509575
self.streaming_text.clear();
510576
self.streaming_tool_calls.clear();

src/llm/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ mod agent;
44
pub mod background;
55
mod registry;
66

7-
pub use agent::{Agent, AgentStep, RequestMode, Usage};
7+
pub use agent::{Agent, AgentStep, RequestMode, SystemPromptBuilder, Usage};
88
pub use registry::{AgentId, AgentRegistry};

0 commit comments

Comments
 (0)