Skip to main content

ares_agent/
plugins.rs

1//! Loader plugin registration for standalone and test hosts.
2//!
3//! This crate owns the single `Execute` loader key. Server extras (ActiveRuns,
4//! MCP, SkillEngine, dynamic AgentRegistry) are provided by Overlay or the
5//! `ServerRuntime` factory, then this factory attaches them from context.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use serde_json::Value;
11
12use crate::config::AgentConfig;
13use crate::execution::Execute;
14use crate::registry::AgentRegistry;
15use crate::{ContextProviderHandle, ToonAgents};
16#[cfg(feature = "pipeline")]
17use cordis::Plugin;
18
19fn block_on_plugin<S: cordis::Service + 'static>(
20    ctx: &std::sync::Arc<cordis::Context>,
21    svc: S,
22) -> Result<cordis::FiberId, cordis::CordisError> {
23    tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(ctx.plugin(svc)))
24}
25
26/// Host-injected [`crate::RunTracker`] (server `ActiveRuns`).
27///
28/// Overlay / `ServerRuntime` provide this so Execute can attach a tracker
29/// without naming the server type.
30pub struct RunTrackerHandle(pub Arc<dyn crate::RunTracker>);
31
32impl cordis::Service for RunTrackerHandle {
33    fn name(&self) -> &'static str {
34        "run_tracker"
35    }
36}
37
38impl RunTrackerHandle {
39    /// Wrap a host tracker (typically `ActiveRuns`).
40    pub fn new(inner: Arc<dyn crate::RunTracker>) -> Self {
41        Self(inner)
42    }
43}
44
45/// Host-injected TOON agent lookup (Overlay `DynamicConfigManager`).
46pub struct ToonAgentsHandle(pub Arc<dyn ToonAgents>);
47
48impl cordis::Service for ToonAgentsHandle {
49    fn name(&self) -> &'static str {
50        "toon_agents"
51    }
52}
53
54impl ToonAgentsHandle {
55    /// Wrap Overlay's live TOON agent map.
56    pub fn new(inner: Arc<dyn ToonAgents>) -> Self {
57        Self(inner)
58    }
59}
60
61/// Static `[agents.*]` map copied from Overlay when Execute config is empty.
62pub struct OverlayAgentConfigs(pub HashMap<String, AgentConfig>);
63
64impl cordis::Service for OverlayAgentConfigs {
65    fn name(&self) -> &'static str {
66        "overlay_agent_configs"
67    }
68}
69
70/// Parse `entry.config` as a map of [`AgentConfig`].
71///
72/// Accepts a raw map of agent name → config, or `{"agents": map}`.
73/// Empty objects and JSON null become an empty map.
74fn parse_agents(config: &Value) -> Result<HashMap<String, AgentConfig>, cordis::CordisError> {
75    if config.is_null() {
76        return Ok(HashMap::new());
77    }
78    let Some(obj) = config.as_object() else {
79        return Err(cordis::CordisError::Configuration(
80            "Execute config must be a JSON object or null".into(),
81        ));
82    };
83    if obj.is_empty() {
84        return Ok(HashMap::new());
85    }
86    let source = match obj.get("agents") {
87        Some(Value::Null) => return Ok(HashMap::new()),
88        Some(agents) => agents,
89        None => config,
90    };
91    if source.as_object().is_some_and(serde_json::Map::is_empty) {
92        return Ok(HashMap::new());
93    }
94    serde_json::from_value(source.clone()).map_err(|e| {
95        cordis::CordisError::Configuration(format!("invalid Execute agents config: {e}"))
96    })
97}
98
99fn factory_execute(
100    ctx: &Arc<cordis::Context>,
101    config: &Value,
102) -> Result<cordis::FiberId, cordis::CordisError> {
103    let tools = ctx
104        .get::<ares_tools::Tools>()
105        .ok_or_else(|| cordis::CordisError::Configuration("Tools is not on context".into()))?;
106    #[cfg(feature = "postgres")]
107    if ctx.get::<ares_store::TenantRealms>().is_none() {
108        ctx.provide(ares_store::TenantRealms::new(
109            std::any::TypeId::of::<ares_tools::Tools>(),
110            std::any::TypeId::of::<Execute>(),
111        ));
112    }
113
114    let registry = if let Some(existing) = ctx.get::<AgentRegistry>() {
115        existing
116    } else {
117        let mut agents = parse_agents(config)?;
118        if agents.is_empty() {
119            if let Some(overlay_agents) = ctx.get::<OverlayAgentConfigs>() {
120                agents = overlay_agents.0.clone();
121            }
122        }
123        let providers = match ctx.get::<ares_llm::Llm>() {
124            Some(llm) => llm.registry(),
125            None => Arc::new(ares_llm::ProviderRegistry::new()),
126        };
127        let registry = if let Some(toon) = ctx.get::<ToonAgentsHandle>() {
128            Arc::new(AgentRegistry::with_dynamic_config(
129                agents,
130                providers,
131                Arc::clone(&tools),
132                Arc::clone(&toon.0),
133            ))
134        } else {
135            Arc::new(AgentRegistry::from_config(
136                agents,
137                providers,
138                Arc::clone(&tools),
139            ))
140        };
141        ctx.provide_arc(Arc::clone(&registry));
142        registry
143    };
144
145    if ctx.get::<crate::EmergencyStop>().is_none() {
146        ctx.provide(crate::EmergencyStop::new(false));
147    }
148
149    #[cfg(feature = "postgres")]
150    if ctx.get::<crate::skills::SkillEngine>().is_none() {
151        if let (Some(pg), Some(llm)) = (
152            ctx.get::<ares_store::PostgresClient>(),
153            ctx.get::<ares_llm::Llm>(),
154        ) {
155            ctx.provide_arc(Arc::new(crate::skills::SkillEngine::new(
156                pg.pool.clone(),
157                Arc::clone(&tools),
158                llm,
159            )));
160        }
161    }
162    let _ = tools;
163
164    let mut execute = Execute::new().with_agent_registry(registry);
165    if let Some(handle) = ctx.get::<RunTrackerHandle>() {
166        execute = execute.with_run_tracker(Arc::clone(&handle.0));
167    }
168    if let Some(handle) = ctx.get::<ContextProviderHandle>() {
169        execute = execute.with_context_provider(Arc::clone(handle.inner()));
170    }
171    block_on_plugin(ctx, execute)
172}
173
174/// Register the `Execute` loader factory. Does not register `AgentRegistry`
175/// or `ExecutionStack`. Engine keys are feature-gated.
176pub fn register_plugins(reg: &cordis::PluginRegistry) {
177    reg.register("Execute", Arc::new(factory_execute));
178    #[cfg(feature = "scheduler")]
179    reg.register("SchedulerService", Arc::new(factory_scheduler));
180    #[cfg(feature = "pipeline")]
181    reg.register("PipelineService", Arc::new(factory_pipeline));
182    #[cfg(feature = "trigger")]
183    reg.register("TriggerService", Arc::new(factory_trigger));
184}
185
186#[cfg(feature = "inventory")]
187inventory::submit! {
188    cordis::CordisPluginFactory { name: "Execute", make: factory_execute }
189}
190#[cfg(all(feature = "inventory", feature = "scheduler"))]
191inventory::submit! {
192    cordis::CordisPluginFactory { name: "SchedulerService", make: factory_scheduler }
193}
194#[cfg(all(feature = "inventory", feature = "pipeline"))]
195inventory::submit! {
196    cordis::CordisPluginFactory { name: "PipelineService", make: factory_pipeline }
197}
198#[cfg(all(feature = "inventory", feature = "trigger"))]
199inventory::submit! {
200    cordis::CordisPluginFactory { name: "TriggerService", make: factory_trigger }
201}
202
203fn inject_sync<T: cordis::Service + 'static>(ctx: &Arc<cordis::Context>) -> Arc<T> {
204    tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(ctx.inject::<T>()))
205}
206
207#[cfg(feature = "scheduler")]
208fn factory_scheduler(
209    ctx: &Arc<cordis::Context>,
210    config: &Value,
211) -> Result<cordis::FiberId, cordis::CordisError> {
212    let cfg: crate::scheduler::SchedulerConfig =
213        if config.is_null() || config.as_object().is_some_and(|o| o.is_empty()) {
214            crate::scheduler::SchedulerConfig::default()
215        } else {
216            serde_json::from_value(config.clone()).unwrap_or_default()
217        };
218    let db = inject_sync::<ares_store::PostgresClient>(ctx);
219    let execution = inject_sync::<Execute>(ctx);
220    block_on_plugin(
221        ctx,
222        crate::scheduler::SchedulerService::new(db, execution, cfg.tick_ms),
223    )
224}
225
226#[cfg(feature = "pipeline")]
227fn factory_pipeline(
228    ctx: &Arc<cordis::Context>,
229    config: &Value,
230) -> Result<cordis::FiberId, cordis::CordisError> {
231    let cfg: crate::pipeline::PipelineConfig =
232        if config.is_null() || config.as_object().is_some_and(|o| o.is_empty()) {
233            crate::pipeline::PipelineConfig::default()
234        } else {
235            serde_json::from_value(config.clone()).unwrap_or_default()
236        };
237    let _ = crate::pipeline::PipelinePlugin.apply(ctx, cfg)?;
238    let db = inject_sync::<ares_store::PostgresClient>(ctx);
239    let execution = inject_sync::<Execute>(ctx);
240    block_on_plugin(ctx, crate::pipeline::PipelineService::new(db, execution))
241}
242
243#[cfg(feature = "trigger")]
244fn factory_trigger(
245    ctx: &Arc<cordis::Context>,
246    _config: &Value,
247) -> Result<cordis::FiberId, cordis::CordisError> {
248    let db = inject_sync::<ares_store::PostgresClient>(ctx);
249    let execution = inject_sync::<Execute>(ctx);
250    block_on_plugin(ctx, crate::trigger::TriggerService::new(db, execution))
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use cordis::PluginRegistry;
257    use serde_json::json;
258
259    #[test]
260    fn register_plugins_registers_execute() {
261        let reg = PluginRegistry::new();
262        register_plugins(&reg);
263        assert!(reg.get("Execute").is_some());
264        assert!(reg.get("AgentRegistry").is_none());
265        assert!(reg.get("ExecutionStack").is_none());
266        assert!(reg.get("ServerRuntime").is_none());
267        #[cfg(feature = "scheduler")]
268        assert!(reg.get("SchedulerService").is_some());
269        #[cfg(not(feature = "scheduler"))]
270        assert!(reg.get("SchedulerService").is_none());
271        #[cfg(feature = "pipeline")]
272        assert!(reg.get("PipelineService").is_some());
273        #[cfg(feature = "trigger")]
274        assert!(reg.get("TriggerService").is_some());
275    }
276
277    #[test]
278    fn parse_agents_null_and_empty() {
279        assert!(parse_agents(&Value::Null).unwrap().is_empty());
280        assert!(parse_agents(&json!({})).unwrap().is_empty());
281        assert!(parse_agents(&json!({"agents": null})).unwrap().is_empty());
282        assert!(parse_agents(&json!({"agents": {}})).unwrap().is_empty());
283    }
284
285    #[test]
286    fn parse_agents_raw_map_and_wrapped() {
287        let raw = json!({
288            "research": { "model": "gpt-4", "system_prompt": "be helpful" }
289        });
290        let wrapped = json!({
291            "agents": {
292                "research": { "model": "gpt-4", "system_prompt": "be helpful" }
293            }
294        });
295        let from_raw = parse_agents(&raw).unwrap();
296        let from_wrapped = parse_agents(&wrapped).unwrap();
297        assert_eq!(from_raw["research"].model, "gpt-4");
298        assert_eq!(from_wrapped["research"].model, "gpt-4");
299        assert_eq!(
300            from_raw["research"].system_prompt.as_deref(),
301            Some("be helpful")
302        );
303    }
304}