Skip to main content

orchestral_runtime/action/
loader.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use serde_json::Value;
6use thiserror::Error;
7use tokio::sync::RwLock;
8
9use orchestral_core::action::ActionMeta;
10use orchestral_core::config::{
11    load_config, ActionInterfaceSpec, ActionSpec, ConfigError, OrchestralConfig,
12};
13use orchestral_core::executor::ActionRegistry;
14
15use super::builtin::skill_activate::SkillActivateAction;
16use super::builtin::tool_lookup::ToolLookupAction;
17use super::builtin::JsonStdoutAction;
18use super::factory::{ActionBuildError, ActionFactory};
19use super::mcp::probe_mcp_server_tools;
20use super::providers::{collect_action_registration_specs, ActionRegistrationSpec};
21use crate::skill::SkillCatalog;
22
23/// Action config errors
24#[derive(Debug, Error)]
25pub enum ActionConfigError {
26    #[error("config error: {0}")]
27    Config(#[from] ConfigError),
28    #[error("build error: {0}")]
29    Build(#[from] ActionBuildError),
30}
31
32/// Loads action specs and maintains a live registry
33pub struct ActionRegistryManager {
34    path: PathBuf,
35    registry: Arc<RwLock<ActionRegistry>>,
36    factory: Arc<dyn ActionFactory>,
37    skill_catalog: Option<Arc<RwLock<SkillCatalog>>>,
38}
39
40impl ActionRegistryManager {
41    pub fn new(path: impl Into<PathBuf>, factory: Arc<dyn ActionFactory>) -> Self {
42        Self {
43            path: path.into(),
44            registry: Arc::new(RwLock::new(ActionRegistry::new())),
45            factory,
46            skill_catalog: None,
47        }
48    }
49
50    pub fn with_skill_catalog(mut self, catalog: Arc<RwLock<SkillCatalog>>) -> Self {
51        self.skill_catalog = Some(catalog);
52        self
53    }
54
55    pub fn registry(&self) -> Arc<RwLock<ActionRegistry>> {
56        self.registry.clone()
57    }
58
59    pub async fn load(&self) -> Result<usize, ActionConfigError> {
60        let config = load_config(&self.path)?;
61        self.load_from_orchestral_config(&config).await
62    }
63
64    /// Load from an already-parsed full orchestral config.
65    pub async fn load_from_orchestral_config(
66        &self,
67        config: &OrchestralConfig,
68    ) -> Result<usize, ActionConfigError> {
69        let specs = collect_action_registration_specs(config, &self.path)?;
70        self.load_from_registration_specs(specs).await
71    }
72
73    async fn load_from_registration_specs(
74        &self,
75        specs: Vec<ActionRegistrationSpec>,
76    ) -> Result<usize, ActionConfigError> {
77        // Separate MCP server specs (to probe) from regular specs.
78        let mut regular_specs = Vec::new();
79        let mut mcp_server_specs = Vec::new();
80        for registration in specs {
81            if registration.spec.kind == "mcp_server" {
82                mcp_server_specs.push(registration);
83            } else {
84                regular_specs.push(registration);
85            }
86        }
87
88        // Probe MCP servers to discover per-tool specs.
89        let mut tool_specs = Vec::new();
90        for server_reg in &mcp_server_specs {
91            let server_name = server_reg.spec.name.clone();
92            match probe_mcp_server_tools(&server_reg.spec).await {
93                Ok(tools) => {
94                    tracing::info!(
95                        server = %server_name,
96                        tool_count = tools.len(),
97                        "MCP server probed successfully"
98                    );
99                    for tool in tools {
100                        let action_name =
101                            format!("{}__{}", server_name, sanitize_tool_name(&tool.name));
102                        let mut config = server_reg.spec.config.clone();
103                        if let Some(obj) = config.as_object_mut() {
104                            obj.insert("tool_name".to_string(), Value::String(tool.name.clone()));
105                            obj.insert("tool_input_schema".to_string(), tool.input_schema.clone());
106                        }
107                        tool_specs.push(ActionRegistrationSpec {
108                            source: server_reg.source,
109                            spec: ActionSpec {
110                                name: action_name,
111                                kind: "mcp_tool".to_string(),
112                                description: if tool.description.is_empty() {
113                                    Some(format!("MCP tool '{}'", tool.name))
114                                } else {
115                                    Some(tool.description.clone())
116                                },
117                                category: None,
118                                config,
119                                interface: Some(ActionInterfaceSpec {
120                                    input_schema: tool.input_schema,
121                                    output_schema: serde_json::json!({
122                                        "type": "object",
123                                        "properties": { "result": {} },
124                                        "required": ["result"]
125                                    }),
126                                }),
127                            },
128                        });
129                    }
130                }
131                Err(err) => {
132                    let required = server_reg
133                        .spec
134                        .config
135                        .get("required")
136                        .and_then(Value::as_bool)
137                        .unwrap_or(false);
138                    if required {
139                        return Err(ActionConfigError::Build(ActionBuildError::InvalidConfig(
140                            format!(
141                                "required MCP server '{}' probe failed: {}",
142                                server_name, err
143                            ),
144                        )));
145                    }
146                    tracing::warn!(
147                        server = %server_name,
148                        error = %err,
149                        "MCP server probe failed, skipping"
150                    );
151                }
152            }
153        }
154
155        // Build and register all actions.
156        let mut registry = ActionRegistry::new();
157        let mut effective: HashMap<String, (ActionRegistrationSpec, ActionMeta)> = HashMap::new();
158
159        // Register regular actions + MCP tool actions (skip raw mcp_server specs).
160        let all_specs = regular_specs.into_iter().chain(tool_specs);
161        for registration in all_specs {
162            let name = registration.spec.name.clone();
163            match self.factory.build(&registration.spec) {
164                Ok(action) => {
165                    let metadata = action.metadata();
166                    registry.register(action);
167                    effective.insert(name, (registration, metadata));
168                }
169                Err(super::factory::ActionBuildError::UnknownKind(ref kind)) => {
170                    tracing::warn!(kind = %kind, "skipping unknown action kind (may require an extension)");
171                }
172                Err(e) => return Err(e.into()),
173            }
174        }
175        registry.register(Arc::new(JsonStdoutAction::internal()));
176        registry.register(Arc::new(ToolLookupAction::new(self.registry.clone())));
177        if let Some(catalog) = &self.skill_catalog {
178            registry.register(Arc::new(SkillActivateAction::new(catalog.clone())));
179        }
180
181        let mut current = self.registry.write().await;
182        *current = registry;
183        let count = current.names().len();
184        drop(current);
185
186        log_registered_actions(&effective);
187        Ok(count)
188    }
189}
190
191fn log_registered_actions(actions: &HashMap<String, (ActionRegistrationSpec, ActionMeta)>) {
192    let mut names = actions.keys().cloned().collect::<Vec<_>>();
193    names.sort();
194    tracing::info!(count = names.len(), "action registry loaded");
195
196    for name in names {
197        let Some((registration, metadata)) = actions.get(&name) else {
198            continue;
199        };
200        let spec = &registration.spec;
201        let description = summarize_description(Some(&metadata.description));
202        let input = summarize_schema(&metadata.input_schema);
203        let output = summarize_schema(&metadata.output_schema);
204        let capabilities = if metadata.capabilities.is_empty() {
205            "-".to_string()
206        } else {
207            metadata.capabilities.join(",")
208        };
209        let category = metadata.category.as_deref().unwrap_or("-");
210
211        tracing::info!(
212            source = registration.source.as_str(),
213            name = %spec.name,
214            kind = %spec.kind,
215            category = %category,
216            desc = %description,
217            capabilities = %capabilities,
218            input = %input,
219            output = %output,
220            "registered action",
221        );
222
223        if !metadata.input_schema.is_null() || !metadata.output_schema.is_null() {
224            tracing::debug!(
225                name = %spec.name,
226                input_schema = %metadata.input_schema,
227                output_schema = %metadata.output_schema,
228                "registered action schemas",
229            );
230        }
231    }
232}
233
234fn summarize_description(description: Option<&str>) -> String {
235    const MAX_CHARS: usize = 200;
236    let compact = description
237        .map(|value| value.split_whitespace().collect::<Vec<_>>().join(" "))
238        .filter(|value| !value.is_empty())
239        .unwrap_or_else(|| "-".to_string());
240    trim_chars(compact, MAX_CHARS)
241}
242
243fn summarize_schema(schema: &Value) -> String {
244    if let Some(obj) = schema.as_object() {
245        let mut fields = obj
246            .get("properties")
247            .and_then(Value::as_object)
248            .map(|props| props.keys().cloned().collect::<Vec<_>>())
249            .unwrap_or_default();
250        fields.sort();
251
252        let mut required = obj
253            .get("required")
254            .and_then(Value::as_array)
255            .map(|items| {
256                items
257                    .iter()
258                    .filter_map(|item| item.as_str().map(ToString::to_string))
259                    .collect::<Vec<_>>()
260            })
261            .unwrap_or_default();
262        required.sort();
263
264        if fields.is_empty() && required.is_empty() {
265            return "object".to_string();
266        }
267        return format!(
268            "fields=[{}] required=[{}]",
269            fields.join(","),
270            required.join(",")
271        );
272    }
273
274    let serialized =
275        serde_json::to_string(schema).unwrap_or_else(|_| "<invalid_schema>".to_string());
276    trim_chars(serialized, 180)
277}
278
279fn sanitize_tool_name(name: &str) -> String {
280    let sanitized: String = name
281        .chars()
282        .map(|c| {
283            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
284                c.to_ascii_lowercase()
285            } else {
286                '_'
287            }
288        })
289        .collect();
290    if sanitized.chars().all(|c| c == '_') {
291        "tool".to_string()
292    } else {
293        sanitized
294    }
295}
296
297fn trim_chars(value: String, max_chars: usize) -> String {
298    if value.chars().count() <= max_chars {
299        return value;
300    }
301
302    let mut out = value.chars().take(max_chars).collect::<String>();
303    out.push_str("...");
304    out
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use serde_json::json;
311
312    #[test]
313    fn summarize_schema_lists_fields_and_required() {
314        let schema = json!({
315            "type": "object",
316            "properties": {
317                "tool": {"type": "string"},
318                "arguments": {"type": "object"}
319            },
320            "required": ["tool"]
321        });
322
323        let summary = summarize_schema(&schema);
324        assert_eq!(summary, "fields=[arguments,tool] required=[tool]");
325    }
326
327    #[test]
328    fn summarize_description_compacts_whitespace() {
329        let summary = summarize_description(Some("  hello\n\nworld\tfrom action  "));
330        assert_eq!(summary, "hello world from action");
331    }
332}