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}