context_harness/profiles.rs
1//! Profile system for reusable MCP prompts and personas.
2//!
3//! Profiles are named personas that combine a system prompt, scoped tools, and
4//! optional dynamic context injection. They enable "assume a role" workflows
5//! in Cursor, Claude Desktop, and other MCP clients.
6//!
7//! # Architecture
8//!
9//! ```text
10//! ┌──────────────────────────────────────────┐
11//! │ ProfileRegistry │
12//! │ ┌─────────┐ ┌─────────┐ ┌────────────┐ │
13//! │ │ TOML │ │ Lua │ │ Custom │ │
14//! │ │ Inline │ │ Script │ │ (Rust) │ │
15//! │ └─────────┘ └─────────┘ └────────────┘ │
16//! └──────────────┬───────────────────────────┘
17//! ▼
18//! GET /profiles/list · POST /profiles/{name}/prompt
19//! ```
20//!
21//! # Profile Sources
22//!
23//! | Source | Config Key | Struct |
24//! |--------|------------|--------|
25//! | Inline TOML | `[profiles.inline.<name>]` | [`TomlProfile`] |
26//! | Lua script | `[profiles.script.<name>]` | `LuaProfileAdapter` (in [`crate::profile_script`]) |
27//! | Custom Rust | `registry.register(...)` | User-defined [`Profile`] impl |
28//!
29//! # Usage
30//!
31//! ```rust
32//! use context_harness::profiles::{ProfileRegistry, TomlProfile};
33//!
34//! let mut profiles = ProfileRegistry::new();
35//! profiles.register(Box::new(TomlProfile::new(
36//! "reviewer".to_string(),
37//! "Reviews code against conventions".to_string(),
38//! vec!["search".to_string(), "get".to_string()],
39//! "You are a senior code reviewer.".to_string(),
40//! )));
41//! ```
42//!
43//! See the profiles documentation for the full specification.
44
45use anyhow::Result;
46use async_trait::async_trait;
47use serde::Serialize;
48use serde_json::Value;
49
50use crate::config::Config;
51use crate::traits::ToolContext;
52
53// ═══════════════════════════════════════════════════════════════════════
54// Profile Trait
55// ═══════════════════════════════════════════════════════════════════════
56
57/// A reusable persona that provides a system prompt and tool scoping.
58///
59/// Implement this trait to create a custom profile in Rust. Profiles are
60/// registered in a [`ProfileRegistry`] and exposed via `GET /profiles/list`
61/// for discovery and `POST /profiles/{name}/prompt` for resolution.
62///
63/// # Lifecycle
64///
65/// 1. The profile is registered via [`ProfileRegistry::register`].
66/// 2. At discovery time, [`name`](Profile::name), [`description`](Profile::description),
67/// [`tools`](Profile::tools), and [`arguments`](Profile::arguments) are called.
68/// 3. When a user selects the profile, [`resolve`](Profile::resolve) is called
69/// with any provided arguments and a [`ToolContext`] for KB access.
70///
71/// # Example
72///
73/// ```rust
74/// use async_trait::async_trait;
75/// use anyhow::Result;
76/// use serde_json::{json, Value};
77/// use context_harness::profiles::{Profile, ProfilePrompt, ProfileArgument};
78/// use context_harness::traits::ToolContext;
79///
80/// pub struct ArchitectProfile;
81///
82/// #[async_trait]
83/// impl Profile for ArchitectProfile {
84/// fn name(&self) -> &str { "architect" }
85/// fn description(&self) -> &str { "Answers architecture questions" }
86/// fn tools(&self) -> Vec<String> { vec!["search".into(), "get".into()] }
87///
88/// async fn resolve(&self, _args: Value, _ctx: &ToolContext) -> Result<ProfilePrompt> {
89/// Ok(ProfilePrompt {
90/// system: "You are a software architect.".to_string(),
91/// tools: self.tools(),
92/// messages: vec![],
93/// })
94/// }
95/// }
96/// ```
97#[async_trait]
98pub trait Profile: Send + Sync {
99 /// Returns the profile's unique name (URL-safe, e.g. `"code-reviewer"`).
100 fn name(&self) -> &str;
101
102 /// Returns a one-line description for profile discovery.
103 fn description(&self) -> &str;
104
105 /// Returns the list of tool names this profile exposes.
106 fn tools(&self) -> Vec<String>;
107
108 /// Returns the profile's source type: `"toml"`, `"lua"`, or `"rust"`.
109 fn source(&self) -> &str {
110 "rust"
111 }
112
113 /// Returns the arguments this profile accepts (may be empty).
114 ///
115 /// Arguments are shown to the user in MCP prompt selection UIs
116 /// and passed to [`resolve`](Profile::resolve) as a JSON object.
117 fn arguments(&self) -> Vec<ProfileArgument> {
118 vec![]
119 }
120
121 /// Resolve the profile's prompt, optionally using the [`ToolContext`]
122 /// for dynamic context injection (e.g., pre-searching the KB).
123 ///
124 /// # Arguments
125 ///
126 /// * `args` — User-provided argument values (JSON object).
127 /// * `ctx` — Bridge to the Context Harness knowledge base.
128 ///
129 /// # Returns
130 ///
131 /// A [`ProfilePrompt`] containing the system prompt, tool list,
132 /// and optional pre-injected messages.
133 async fn resolve(&self, args: Value, ctx: &ToolContext) -> Result<ProfilePrompt>;
134}
135
136// ═══════════════════════════════════════════════════════════════════════
137// Data Types
138// ═══════════════════════════════════════════════════════════════════════
139
140/// An argument that an profile accepts.
141///
142/// Arguments are shown in MCP prompt selection UIs. When the user
143/// selects a profile, argument values are collected and passed to
144/// [`Profile::resolve`].
145#[derive(Debug, Clone, Serialize)]
146pub struct ProfileArgument {
147 /// Argument name (e.g. `"service"`).
148 pub name: String,
149 /// Description shown to the user.
150 pub description: String,
151 /// Whether this argument must be provided.
152 pub required: bool,
153}
154
155/// A resolved profile prompt ready for the LLM.
156///
157/// Returned by [`Profile::resolve`]. The client (Cursor, Claude, etc.)
158/// uses this to configure the LLM conversation.
159#[derive(Debug, Clone, Serialize)]
160pub struct ProfilePrompt {
161 /// The system prompt text.
162 pub system: String,
163 /// Which tools should be visible for this profile.
164 pub tools: Vec<String>,
165 /// Optional additional messages to inject at conversation start.
166 #[serde(skip_serializing_if = "Vec::is_empty")]
167 pub messages: Vec<PromptMessage>,
168}
169
170/// A message to inject into the conversation.
171///
172/// Used by profiles that want to pre-populate context (e.g., pre-fetched
173/// search results) or provide an initial assistant greeting.
174#[derive(Debug, Clone, Serialize)]
175pub struct PromptMessage {
176 /// Message role: `"user"`, `"assistant"`, or `"system"`.
177 pub role: String,
178 /// Message content.
179 pub content: String,
180}
181
182/// Serializable profile info for the `/profiles/list` endpoint.
183#[derive(Debug, Clone, Serialize)]
184pub struct ProfileInfo {
185 /// Profile name (used as URL path parameter).
186 pub name: String,
187 /// One-line description.
188 pub description: String,
189 /// Tools this profile uses.
190 pub tools: Vec<String>,
191 /// Source type: `"toml"`, `"lua"`, or `"rust"`.
192 pub source: String,
193 /// Arguments this profile accepts.
194 pub arguments: Vec<ProfileArgument>,
195}
196
197// ═══════════════════════════════════════════════════════════════════════
198// TomlProfile
199// ═══════════════════════════════════════════════════════════════════════
200
201/// An profile defined inline in TOML configuration.
202///
203/// The simplest profile type — has a static system prompt and fixed tool
204/// list. No dynamic context injection or arguments.
205///
206/// Created automatically by [`ProfileRegistry::from_config`] for each
207/// `[profiles.inline.<name>]` entry.
208pub struct TomlProfile {
209 name: String,
210 description: String,
211 tools: Vec<String>,
212 system_prompt: String,
213}
214
215impl TomlProfile {
216 /// Create a new inline TOML profile.
217 pub fn new(
218 name: String,
219 description: String,
220 tools: Vec<String>,
221 system_prompt: String,
222 ) -> Self {
223 Self {
224 name,
225 description,
226 tools,
227 system_prompt,
228 }
229 }
230}
231
232#[async_trait]
233impl Profile for TomlProfile {
234 fn name(&self) -> &str {
235 &self.name
236 }
237
238 fn description(&self) -> &str {
239 &self.description
240 }
241
242 fn tools(&self) -> Vec<String> {
243 self.tools.clone()
244 }
245
246 fn source(&self) -> &str {
247 "toml"
248 }
249
250 async fn resolve(&self, _args: Value, _ctx: &ToolContext) -> Result<ProfilePrompt> {
251 Ok(ProfilePrompt {
252 system: self.system_prompt.clone(),
253 tools: self.tools.clone(),
254 messages: vec![],
255 })
256 }
257}
258
259// ═══════════════════════════════════════════════════════════════════════
260// ProfileRegistry
261// ═══════════════════════════════════════════════════════════════════════
262
263/// Registry for profiles (TOML, Lua, and custom Rust).
264///
265/// Use [`ProfileRegistry::from_config`] to create a registry pre-loaded
266/// with all profiles from the config file, then optionally call
267/// [`register`](ProfileRegistry::register) to add custom Rust profiles.
268///
269/// # Example
270///
271/// ```rust
272/// use context_harness::profiles::ProfileRegistry;
273///
274/// let mut profiles = ProfileRegistry::new();
275/// // profiles.register(Box::new(MyProfile::new()));
276/// ```
277pub struct ProfileRegistry {
278 profiles: Vec<Box<dyn Profile>>,
279}
280
281impl ProfileRegistry {
282 /// Create an empty profile registry.
283 pub fn new() -> Self {
284 Self {
285 profiles: Vec::new(),
286 }
287 }
288
289 /// Create a registry pre-loaded with all profiles from config.
290 ///
291 /// Loads inline TOML profiles from `[profiles.inline.*]` entries.
292 /// Lua script profiles from `[profiles.script.*]` are loaded separately
293 /// via [`crate::profile_script::load_profile_definitions`].
294 pub fn from_config(config: &Config) -> Result<Self> {
295 let mut registry = Self::new();
296
297 // Load inline TOML profiles
298 for (name, cfg) in &config.profiles.inline {
299 registry.register(Box::new(TomlProfile::new(
300 name.clone(),
301 cfg.description.clone(),
302 cfg.tools.clone(),
303 cfg.system_prompt.clone(),
304 )));
305 }
306
307 // Lua profiles are loaded in profile_script::load_profile_definitions
308 // and registered by the caller (server.rs / main.rs).
309
310 Ok(registry)
311 }
312
313 /// Register a profile.
314 pub fn register(&mut self, profile: Box<dyn Profile>) {
315 self.profiles.push(profile);
316 }
317
318 /// Get all registered profiles.
319 pub fn profiles(&self) -> &[Box<dyn Profile>] {
320 &self.profiles
321 }
322
323 /// Find a profile by name.
324 pub fn find(&self, name: &str) -> Option<&dyn Profile> {
325 self.profiles
326 .iter()
327 .find(|a| a.name() == name)
328 .map(|a| a.as_ref())
329 }
330
331 /// Check if the registry is empty.
332 #[allow(dead_code)]
333 pub fn is_empty(&self) -> bool {
334 self.profiles.is_empty()
335 }
336
337 /// Return the count of registered profiles.
338 pub fn len(&self) -> usize {
339 self.profiles.len()
340 }
341}
342
343impl Default for ProfileRegistry {
344 fn default() -> Self {
345 Self::new()
346 }
347}