orchestral_runtime/action/
factory.rs1use 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#[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
21pub trait ActionFactory: Send + Sync {
23 fn build(&self, spec: &ActionSpec) -> Result<Arc<dyn Action>, ActionBuildError>;
24}
25
26pub 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(_)) => {} 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}