Skip to content

Commit b0473f0

Browse files
committed
feat: add cortex-prompt-harness crate for centralized system prompt management
This new crate provides a centralized harness for building and managing system prompts in Cortex CLI. Key features: - Dynamic prompt construction based on context (tasks, agents, environment) - Agent state tracking to detect changes between messages - Update notifications injected into the next prompt (not at update time) - Fluent builder API for constructing prompts with sections and variables - Support for task status tracking and notifications - Comprehensive test suite with 46 unit tests The harness allows the system prompt to vary dynamically based on: - Active agents and their configuration - Task lists and their statuses - Environment changes (cwd, model, etc.) - Custom notifications for any state changes Notifications are queued and only included in the next prompt build, ensuring the agent is informed about changes at the appropriate time.
1 parent b9273fb commit b0473f0

File tree

10 files changed

+2797
-0
lines changed

10 files changed

+2797
-0
lines changed

Cargo.lock

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

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ members = [
6767
"cortex-experimental",
6868
"cortex-commands",
6969
"cortex-skills",
70+
"cortex-prompt-harness",
7071

7172
# CLI - Features (Phase 3 - Codex-inspired)
7273
"cortex-collab",
@@ -225,6 +226,7 @@ cortex-windows-sandbox = { path = "cortex-windows-sandbox" }
225226
cortex-lmstudio = { path = "cortex-lmstudio" }
226227
cortex-ollama = { path = "cortex-ollama" }
227228
cortex-skills = { path = "cortex-skills" }
229+
cortex-prompt-harness = { path = "cortex-prompt-harness" }
228230

229231
# Phase 3 - Codex-inspired crates
230232
cortex-collab = { path = "cortex-collab" }

cortex-prompt-harness/Cargo.toml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
[package]
2+
name = "cortex-prompt-harness"
3+
description = "Centralized system prompt harness for dynamic prompt construction and agent state tracking"
4+
version.workspace = true
5+
edition.workspace = true
6+
rust-version.workspace = true
7+
authors.workspace = true
8+
license.workspace = true
9+
repository.workspace = true
10+
11+
[dependencies]
12+
# Core dependencies
13+
serde = { workspace = true }
14+
serde_json = { workspace = true }
15+
thiserror = { workspace = true }
16+
chrono = { workspace = true }
17+
18+
# Async
19+
tokio = { workspace = true, features = ["sync"] }
20+
21+
# Utilities
22+
tracing = { workspace = true }
23+
uuid = { workspace = true }
24+
indexmap = { workspace = true }
25+
26+
[dev-dependencies]
27+
tokio = { workspace = true, features = ["rt", "macros"] }
28+
pretty_assertions = { workspace = true }
29+
30+
[lints]
31+
workspace = true
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
//! System prompt builder for constructing dynamic prompts.
2+
//!
3+
//! This module provides a fluent API for building system prompts
4+
//! with sections, variables, and templates.
5+
6+
use std::collections::HashMap;
7+
8+
use indexmap::IndexMap;
9+
10+
use crate::sections::{PromptSection, SectionPriority};
11+
12+
/// Builder for constructing system prompts.
13+
///
14+
/// The builder assembles prompts from:
15+
/// - A base template with variable substitution
16+
/// - Ordered sections with priorities
17+
/// - Dynamic content based on context
18+
#[derive(Debug, Clone, Default)]
19+
pub struct SystemPromptBuilder {
20+
/// Base template text.
21+
base: Option<String>,
22+
/// Sections to include (ordered by insertion, sorted by priority on build).
23+
sections: IndexMap<String, PromptSection>,
24+
/// Variables for template substitution.
25+
variables: HashMap<String, String>,
26+
/// Prefix to add before everything.
27+
prefix: Option<String>,
28+
/// Suffix to add after everything.
29+
suffix: Option<String>,
30+
/// Section separator.
31+
section_separator: String,
32+
}
33+
34+
impl SystemPromptBuilder {
35+
/// Create a new empty builder.
36+
pub fn new() -> Self {
37+
Self {
38+
section_separator: "\n\n".to_string(),
39+
..Default::default()
40+
}
41+
}
42+
43+
/// Create a builder with a base template.
44+
pub fn with_base(base: impl Into<String>) -> Self {
45+
Self {
46+
base: Some(base.into()),
47+
section_separator: "\n\n".to_string(),
48+
..Default::default()
49+
}
50+
}
51+
52+
/// Set the base template.
53+
pub fn base(mut self, base: impl Into<String>) -> Self {
54+
self.base = Some(base.into());
55+
self
56+
}
57+
58+
/// Add a section.
59+
pub fn section(mut self, section: PromptSection) -> Self {
60+
self.sections.insert(section.name.clone(), section);
61+
self
62+
}
63+
64+
/// Add a section with name and content.
65+
pub fn add_section(mut self, name: impl Into<String>, content: impl Into<String>) -> Self {
66+
let section = PromptSection::new(name, content);
67+
self.sections.insert(section.name.clone(), section);
68+
self
69+
}
70+
71+
/// Add a section with priority.
72+
pub fn add_section_with_priority(
73+
mut self,
74+
name: impl Into<String>,
75+
content: impl Into<String>,
76+
priority: SectionPriority,
77+
) -> Self {
78+
let section = PromptSection::new(name, content).with_priority(priority);
79+
self.sections.insert(section.name.clone(), section);
80+
self
81+
}
82+
83+
/// Add a high-priority section (appears early).
84+
pub fn add_high_priority_section(
85+
self,
86+
name: impl Into<String>,
87+
content: impl Into<String>,
88+
) -> Self {
89+
self.add_section_with_priority(name, content, SectionPriority::High)
90+
}
91+
92+
/// Add a low-priority section (appears late).
93+
pub fn add_low_priority_section(
94+
self,
95+
name: impl Into<String>,
96+
content: impl Into<String>,
97+
) -> Self {
98+
self.add_section_with_priority(name, content, SectionPriority::Low)
99+
}
100+
101+
/// Set a variable for template substitution.
102+
///
103+
/// Variables can be referenced in templates using `{{variable_name}}` syntax.
104+
pub fn variable(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
105+
self.variables.insert(key.into(), value.into());
106+
self
107+
}
108+
109+
/// Set multiple variables at once.
110+
pub fn variables(mut self, vars: HashMap<String, String>) -> Self {
111+
self.variables.extend(vars);
112+
self
113+
}
114+
115+
/// Set a prefix that appears before everything.
116+
pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
117+
self.prefix = Some(prefix.into());
118+
self
119+
}
120+
121+
/// Set a suffix that appears after everything.
122+
pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
123+
self.suffix = Some(suffix.into());
124+
self
125+
}
126+
127+
/// Set the section separator (default: "\n\n").
128+
pub fn section_separator(mut self, sep: impl Into<String>) -> Self {
129+
self.section_separator = sep.into();
130+
self
131+
}
132+
133+
/// Remove a section by name.
134+
pub fn remove_section(mut self, name: &str) -> Self {
135+
self.sections.shift_remove(name);
136+
self
137+
}
138+
139+
/// Check if a section exists.
140+
pub fn has_section(&self, name: &str) -> bool {
141+
self.sections.contains_key(name)
142+
}
143+
144+
/// Get a section by name.
145+
pub fn get_section(&self, name: &str) -> Option<&PromptSection> {
146+
self.sections.get(name)
147+
}
148+
149+
/// Build the final prompt string.
150+
pub fn build(&self) -> String {
151+
let mut parts = Vec::new();
152+
153+
// Add prefix if present
154+
if let Some(ref prefix) = self.prefix {
155+
parts.push(self.substitute_variables(prefix));
156+
}
157+
158+
// Add base template if present
159+
if let Some(ref base) = self.base {
160+
parts.push(self.substitute_variables(base));
161+
}
162+
163+
// Sort sections by priority and add them
164+
let mut sections: Vec<_> = self.sections.values().collect();
165+
sections.sort_by(|a, b| b.priority.cmp(&a.priority));
166+
167+
for section in sections {
168+
if section.enabled {
169+
let content = self.substitute_variables(&section.render());
170+
parts.push(content);
171+
}
172+
}
173+
174+
// Add suffix if present
175+
if let Some(ref suffix) = self.suffix {
176+
parts.push(self.substitute_variables(suffix));
177+
}
178+
179+
parts.join(&self.section_separator)
180+
}
181+
182+
/// Build and return estimated token count.
183+
pub fn build_with_token_estimate(&self) -> (String, u32) {
184+
let prompt = self.build();
185+
let tokens = estimate_tokens(&prompt);
186+
(prompt, tokens)
187+
}
188+
189+
/// Substitute variables in text.
190+
///
191+
/// Supports both `{{var}}` and `${var}` syntax.
192+
fn substitute_variables(&self, text: &str) -> String {
193+
let mut result = text.to_string();
194+
195+
for (key, value) in &self.variables {
196+
// Handle {{var}} syntax
197+
let pattern1 = format!("{{{{{}}}}}", key);
198+
result = result.replace(&pattern1, value);
199+
200+
// Handle ${var} syntax
201+
let pattern2 = format!("${{{}}}", key);
202+
result = result.replace(&pattern2, value);
203+
}
204+
205+
result
206+
}
207+
}
208+
209+
/// Estimate token count for a string.
210+
///
211+
/// Uses a simple approximation of ~4 characters per token.
212+
fn estimate_tokens(text: &str) -> u32 {
213+
(text.len() as f64 / 4.0).ceil() as u32
214+
}
215+
216+
/// Convenience function to build a simple prompt.
217+
pub fn build_simple_prompt(base: &str, sections: &[(&str, &str)]) -> String {
218+
let mut builder = SystemPromptBuilder::with_base(base);
219+
220+
for (name, content) in sections {
221+
builder = builder.add_section(*name, *content);
222+
}
223+
224+
builder.build()
225+
}
226+
227+
#[cfg(test)]
228+
mod tests {
229+
use super::*;
230+
231+
#[test]
232+
fn test_basic_builder() {
233+
let prompt = SystemPromptBuilder::new()
234+
.base("You are a helpful assistant.")
235+
.build();
236+
237+
assert_eq!(prompt, "You are a helpful assistant.");
238+
}
239+
240+
#[test]
241+
fn test_with_sections() {
242+
let prompt = SystemPromptBuilder::with_base("Base prompt")
243+
.add_section("Rules", "Follow these rules")
244+
.add_section("Context", "Current context")
245+
.build();
246+
247+
assert!(prompt.contains("Base prompt"));
248+
assert!(prompt.contains("## Rules"));
249+
assert!(prompt.contains("## Context"));
250+
}
251+
252+
#[test]
253+
fn test_variable_substitution() {
254+
let prompt = SystemPromptBuilder::with_base("Hello {{name}}!")
255+
.variable("name", "World")
256+
.build();
257+
258+
assert_eq!(prompt, "Hello World!");
259+
}
260+
261+
#[test]
262+
fn test_dollar_brace_syntax() {
263+
let prompt = SystemPromptBuilder::with_base("Working in ${cwd}")
264+
.variable("cwd", "/project")
265+
.build();
266+
267+
assert_eq!(prompt, "Working in /project");
268+
}
269+
270+
#[test]
271+
fn test_section_priority() {
272+
let prompt = SystemPromptBuilder::new()
273+
.add_section_with_priority("Low", "Low priority", SectionPriority::Low)
274+
.add_section_with_priority("High", "High priority", SectionPriority::High)
275+
.add_section_with_priority("Normal", "Normal priority", SectionPriority::Normal)
276+
.build();
277+
278+
// High should appear before Normal, Normal before Low
279+
let high_pos = prompt.find("High priority").unwrap();
280+
let normal_pos = prompt.find("Normal priority").unwrap();
281+
let low_pos = prompt.find("Low priority").unwrap();
282+
283+
assert!(high_pos < normal_pos);
284+
assert!(normal_pos < low_pos);
285+
}
286+
287+
#[test]
288+
fn test_prefix_suffix() {
289+
let prompt = SystemPromptBuilder::with_base("Main content")
290+
.prefix("PREFIX")
291+
.suffix("SUFFIX")
292+
.build();
293+
294+
assert!(prompt.starts_with("PREFIX"));
295+
assert!(prompt.ends_with("SUFFIX"));
296+
}
297+
298+
#[test]
299+
fn test_remove_section() {
300+
let prompt = SystemPromptBuilder::new()
301+
.add_section("Keep", "Keep this")
302+
.add_section("Remove", "Remove this")
303+
.remove_section("Remove")
304+
.build();
305+
306+
assert!(prompt.contains("Keep this"));
307+
assert!(!prompt.contains("Remove this"));
308+
}
309+
310+
#[test]
311+
fn test_token_estimate() {
312+
let (prompt, tokens) =
313+
SystemPromptBuilder::with_base("This is a test prompt.").build_with_token_estimate();
314+
315+
assert!(tokens > 0);
316+
assert!(!prompt.is_empty());
317+
}
318+
319+
#[test]
320+
fn test_build_simple_prompt() {
321+
let prompt =
322+
build_simple_prompt("Base", &[("Rules", "Rule 1"), ("Context", "Context info")]);
323+
324+
assert!(prompt.contains("Base"));
325+
assert!(prompt.contains("Rule 1"));
326+
assert!(prompt.contains("Context info"));
327+
}
328+
}

0 commit comments

Comments
 (0)