Skip to main content

orchestral_runtime/action/
factory.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use thiserror::Error;
5
6use orchestral_core::action::{Action, ActionContext, ActionInput, ActionMeta, ActionResult};
7use orchestral_core::config::ActionSpec;
8
9use super::builtin::build_builtin_action;
10use super::mcp::build_mcp_action;
11
12/// Action factory errors
13#[derive(Debug, Error)]
14pub enum ActionBuildError {
15    #[error("unknown action kind: {0}")]
16    UnknownKind(String),
17    #[error("invalid action config: {0}")]
18    InvalidConfig(String),
19}
20
21/// Action factory trait
22pub trait ActionFactory: Send + Sync {
23    fn build(&self, spec: &ActionSpec) -> Result<Arc<dyn Action>, ActionBuildError>;
24}
25
26/// Default factory for built-in actions
27pub struct DefaultActionFactory;
28
29impl DefaultActionFactory {
30    pub fn new() -> Self {
31        Self
32    }
33}
34
35impl Default for DefaultActionFactory {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl ActionFactory for DefaultActionFactory {
42    fn build(&self, spec: &ActionSpec) -> Result<Arc<dyn Action>, ActionBuildError> {
43        let wrap = |action: Arc<dyn Action>| -> Result<Arc<dyn Action>, ActionBuildError> {
44            Ok(Arc::new(ConfiguredAction::new(action, spec)))
45        };
46
47        if let Some(a) = build_builtin_action(spec) {
48            return wrap(Arc::from(a));
49        }
50        if let Some(a) = build_mcp_action(spec)? {
51            return wrap(Arc::from(a));
52        }
53        Err(ActionBuildError::UnknownKind(spec.kind.clone()))
54    }
55}
56
57struct ConfiguredAction {
58    inner: Arc<dyn Action>,
59    metadata: ActionMeta,
60}
61
62impl ConfiguredAction {
63    fn new(inner: Arc<dyn Action>, spec: &ActionSpec) -> Self {
64        let metadata = merge_action_metadata(inner.metadata(), spec);
65        Self { inner, metadata }
66    }
67}
68
69#[async_trait]
70impl Action for ConfiguredAction {
71    fn name(&self) -> &str {
72        self.inner.name()
73    }
74
75    fn description(&self) -> &str {
76        self.inner.description()
77    }
78
79    fn metadata(&self) -> ActionMeta {
80        self.metadata.clone()
81    }
82
83    async fn run(&self, input: ActionInput, ctx: ActionContext) -> ActionResult {
84        self.inner.run(input, ctx).await
85    }
86}
87
88fn merge_action_metadata(base: ActionMeta, spec: &ActionSpec) -> ActionMeta {
89    let mut merged = base;
90    if let Some(category) = &spec.category {
91        if !category.trim().is_empty() {
92            merged.category = Some(category.clone());
93        }
94    }
95    if let Some(interface) = spec.interface.as_ref() {
96        if !interface.input_schema.is_null() {
97            merged.input_schema = interface.input_schema.clone();
98        }
99        if !interface.output_schema.is_null() {
100            merged.output_schema = interface.output_schema.clone();
101        }
102    }
103    merged
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use orchestral_core::config::ActionInterfaceSpec;
110    use serde_json::json;
111
112    fn make_spec(kind: &str) -> ActionSpec {
113        ActionSpec {
114            name: kind.to_string(),
115            kind: kind.to_string(),
116            description: None,
117            category: None,
118            config: json!({}),
119            interface: None,
120        }
121    }
122
123    #[test]
124    fn test_factory_builds_builtin_actions() {
125        let factory = DefaultActionFactory::new();
126        for kind in &["shell", "file_read", "file_write", "http"] {
127            let result = factory.build(&make_spec(kind));
128            assert!(result.is_ok(), "should build '{}' action", kind);
129        }
130    }
131
132    #[test]
133    fn test_factory_rejects_removed_document_structured_kinds() {
134        let factory = DefaultActionFactory::new();
135        for kind in &[
136            "document_inspect",
137            "document_patch",
138            "document_verify_patch",
139            "structured_inspect",
140            "structured_patch",
141            "structured_verify_patch",
142        ] {
143            match factory.build(&make_spec(kind)) {
144                Err(ActionBuildError::UnknownKind(_)) => {} // expected
145                Err(e) => panic!("'{}' should be UnknownKind, got: {}", kind, e),
146                Ok(_) => panic!("'{}' should be rejected but was built successfully", kind),
147            }
148        }
149    }
150
151    #[test]
152    fn test_merge_action_metadata_overrides_declared_interface() {
153        let base = ActionMeta::new("echo", "echo")
154            .with_input_schema(json!({"type":"object","properties":{"old":{"type":"string"}}}))
155            .with_output_schema(json!({"type":"object","properties":{"old":{"type":"string"}}}));
156        let interface = ActionInterfaceSpec {
157            input_schema: json!({"type":"object","properties":{"message":{"type":"string"}}}),
158            output_schema: json!({"type":"object","properties":{"result":{"type":"string"}}}),
159        };
160        let spec = ActionSpec {
161            name: "echo".to_string(),
162            kind: "echo".to_string(),
163            description: None,
164            category: Some("direct".to_string()),
165            config: json!({}),
166            interface: Some(interface.clone()),
167        };
168
169        let merged = merge_action_metadata(base, &spec);
170        assert_eq!(merged.input_schema, interface.input_schema);
171        assert_eq!(merged.output_schema, interface.output_schema);
172        assert_eq!(merged.category.as_deref(), Some("direct"));
173    }
174}