orchestral_runtime/action/
loader.rs1use 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#[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
32pub 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 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 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 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 let mut registry = ActionRegistry::new();
157 let mut effective: HashMap<String, (ActionRegistrationSpec, ActionMeta)> = HashMap::new();
158
159 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(®istration.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 = ®istration.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}