context_harness/agents.rs
1//! Agent system for MCP prompts and personas.
2//!
3//! Agents 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//! │ AgentRegistry │
12//! │ ┌─────────┐ ┌─────────┐ ┌────────────┐ │
13//! │ │ TOML │ │ Lua │ │ Custom │ │
14//! │ │ Inline │ │ Script │ │ (Rust) │ │
15//! │ └─────────┘ └─────────┘ └────────────┘ │
16//! └──────────────┬───────────────────────────┘
17//! ▼
18//! GET /agents/list · POST /agents/{name}/prompt
19//! ```
20//!
21//! # Agent Sources
22//!
23//! | Source | Config Key | Struct |
24//! |--------|------------|--------|
25//! | Inline TOML | `[agents.inline.<name>]` | [`TomlAgent`] |
26//! | Lua script | `[agents.script.<name>]` | `LuaAgentAdapter` (in [`crate::agent_script`]) |
27//! | Custom Rust | `registry.register(...)` | User-defined [`Agent`] impl |
28//!
29//! # Usage
30//!
31//! ```rust
32//! use context_harness::agents::{AgentRegistry, TomlAgent};
33//!
34//! let mut agents = AgentRegistry::new();
35//! agents.register(Box::new(TomlAgent::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 `docs/AGENTS.md` 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// Agent Trait
55// ═══════════════════════════════════════════════════════════════════════
56
57/// An agent persona that provides a system prompt and tool scoping.
58///
59/// Implement this trait to create a custom agent in Rust. Agents are
60/// registered in an [`AgentRegistry`] and exposed via `GET /agents/list`
61/// for discovery and `POST /agents/{name}/prompt` for resolution.
62///
63/// # Lifecycle
64///
65/// 1. The agent is registered via [`AgentRegistry::register`].
66/// 2. At discovery time, [`name`](Agent::name), [`description`](Agent::description),
67/// [`tools`](Agent::tools), and [`arguments`](Agent::arguments) are called.
68/// 3. When a user selects the agent, [`resolve`](Agent::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::agents::{Agent, AgentPrompt, AgentArgument};
78/// use context_harness::traits::ToolContext;
79///
80/// pub struct ArchitectAgent;
81///
82/// #[async_trait]
83/// impl Agent for ArchitectAgent {
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<AgentPrompt> {
89/// Ok(AgentPrompt {
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 Agent: Send + Sync {
99 /// Returns the agent's unique name (URL-safe, e.g. `"code-reviewer"`).
100 fn name(&self) -> &str;
101
102 /// Returns a one-line description for agent discovery.
103 fn description(&self) -> &str;
104
105 /// Returns the list of tool names this agent exposes.
106 fn tools(&self) -> Vec<String>;
107
108 /// Returns the agent's source type: `"toml"`, `"lua"`, or `"rust"`.
109 fn source(&self) -> &str {
110 "rust"
111 }
112
113 /// Returns the arguments this agent accepts (may be empty).
114 ///
115 /// Arguments are shown to the user in MCP prompt selection UIs
116 /// and passed to [`resolve`](Agent::resolve) as a JSON object.
117 fn arguments(&self) -> Vec<AgentArgument> {
118 vec![]
119 }
120
121 /// Resolve the agent'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 /// An [`AgentPrompt`] containing the system prompt, tool list,
132 /// and optional pre-injected messages.
133 async fn resolve(&self, args: Value, ctx: &ToolContext) -> Result<AgentPrompt>;
134}
135
136// ═══════════════════════════════════════════════════════════════════════
137// Data Types
138// ═══════════════════════════════════════════════════════════════════════
139
140/// An argument that an agent accepts.
141///
142/// Arguments are shown in MCP prompt selection UIs. When the user
143/// selects an agent, argument values are collected and passed to
144/// [`Agent::resolve`].
145#[derive(Debug, Clone, Serialize)]
146pub struct AgentArgument {
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 agent prompt ready for the LLM.
156///
157/// Returned by [`Agent::resolve`]. The client (Cursor, Claude, etc.)
158/// uses this to configure the LLM conversation.
159#[derive(Debug, Clone, Serialize)]
160pub struct AgentPrompt {
161 /// The system prompt text.
162 pub system: String,
163 /// Which tools should be visible for this agent.
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 agents 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 agent info for the `/agents/list` endpoint.
183#[derive(Debug, Clone, Serialize)]
184pub struct AgentInfo {
185 /// Agent name (used as URL path parameter).
186 pub name: String,
187 /// One-line description.
188 pub description: String,
189 /// Tools this agent uses.
190 pub tools: Vec<String>,
191 /// Source type: `"toml"`, `"lua"`, or `"rust"`.
192 pub source: String,
193 /// Arguments this agent accepts.
194 pub arguments: Vec<AgentArgument>,
195}
196
197// ═══════════════════════════════════════════════════════════════════════
198// TomlAgent
199// ═══════════════════════════════════════════════════════════════════════
200
201/// An agent defined inline in TOML configuration.
202///
203/// The simplest agent type — has a static system prompt and fixed tool
204/// list. No dynamic context injection or arguments.
205///
206/// Created automatically by [`AgentRegistry::from_config`] for each
207/// `[agents.inline.<name>]` entry.
208pub struct TomlAgent {
209 name: String,
210 description: String,
211 tools: Vec<String>,
212 system_prompt: String,
213}
214
215impl TomlAgent {
216 /// Create a new inline TOML agent.
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 Agent for TomlAgent {
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<AgentPrompt> {
251 Ok(AgentPrompt {
252 system: self.system_prompt.clone(),
253 tools: self.tools.clone(),
254 messages: vec![],
255 })
256 }
257}
258
259// ═══════════════════════════════════════════════════════════════════════
260// AgentRegistry
261// ═══════════════════════════════════════════════════════════════════════
262
263/// Registry for agents (TOML, Lua, and custom Rust).
264///
265/// Use [`AgentRegistry::from_config`] to create a registry pre-loaded
266/// with all agents from the config file, then optionally call
267/// [`register`](AgentRegistry::register) to add custom Rust agents.
268///
269/// # Example
270///
271/// ```rust
272/// use context_harness::agents::AgentRegistry;
273///
274/// let mut agents = AgentRegistry::new();
275/// // agents.register(Box::new(MyAgent::new()));
276/// ```
277pub struct AgentRegistry {
278 agents: Vec<Box<dyn Agent>>,
279}
280
281impl AgentRegistry {
282 /// Create an empty agent registry.
283 pub fn new() -> Self {
284 Self { agents: Vec::new() }
285 }
286
287 /// Create a registry pre-loaded with all agents from config.
288 ///
289 /// Loads inline TOML agents from `[agents.inline.*]` entries.
290 /// Lua script agents from `[agents.script.*]` are loaded separately
291 /// via [`crate::agent_script::load_agent_definitions`].
292 pub fn from_config(config: &Config) -> Result<Self> {
293 let mut registry = Self::new();
294
295 // Load inline TOML agents
296 for (name, cfg) in &config.agents.inline {
297 registry.register(Box::new(TomlAgent::new(
298 name.clone(),
299 cfg.description.clone(),
300 cfg.tools.clone(),
301 cfg.system_prompt.clone(),
302 )));
303 }
304
305 // Lua agents are loaded in agent_script::load_agent_definitions
306 // and registered by the caller (server.rs / main.rs).
307
308 Ok(registry)
309 }
310
311 /// Register an agent.
312 pub fn register(&mut self, agent: Box<dyn Agent>) {
313 self.agents.push(agent);
314 }
315
316 /// Get all registered agents.
317 pub fn agents(&self) -> &[Box<dyn Agent>] {
318 &self.agents
319 }
320
321 /// Find an agent by name.
322 pub fn find(&self, name: &str) -> Option<&dyn Agent> {
323 self.agents
324 .iter()
325 .find(|a| a.name() == name)
326 .map(|a| a.as_ref())
327 }
328
329 /// Check if the registry is empty.
330 #[allow(dead_code)]
331 pub fn is_empty(&self) -> bool {
332 self.agents.is_empty()
333 }
334
335 /// Return the count of registered agents.
336 pub fn len(&self) -> usize {
337 self.agents.len()
338 }
339}
340
341impl Default for AgentRegistry {
342 fn default() -> Self {
343 Self::new()
344 }
345}