Skip to main content

orchestral_runtime/
sdk.rs

1//! SDK builder for programmatic Orchestral setup.
2//!
3//! ```ignore
4//! use orchestral::prelude::*;
5//! use orchestral::sdk::{Orchestral, RunResult};
6//!
7//! let app = Orchestral::builder()
8//!     .action(MyAction::new())
9//!     .planner_backend("openrouter")
10//!     .planner_model("anthropic/claude-sonnet-4.5")
11//!     .planner_api_key_env("OPENROUTER_API_KEY")
12//!     .build()
13//!     .await
14//!     .unwrap();
15//!
16//! let result = app.run("read README.md").await.unwrap();
17//! println!("{}", result.message);
18//! ```
19
20use std::path::PathBuf;
21use std::sync::Arc;
22
23use serde_json::Value;
24use thiserror::Error;
25
26use orchestral_core::action::Action;
27use orchestral_core::config::BackendSpec;
28use orchestral_core::executor::{ExecutionResult, Executor};
29use orchestral_core::normalizer::PlanNormalizer;
30use orchestral_core::planner::Planner;
31use orchestral_core::spi::lifecycle::{LifecycleHook, LifecycleHookRegistry};
32use orchestral_core::store::{Event, InMemoryEventStore, InMemoryTaskStore};
33
34use crate::action::{ActionRegistryManager, DefaultActionFactory};
35use crate::concurrency::{ConcurrencyPolicy, DefaultConcurrencyPolicy};
36use crate::context::{BasicContextBuilder, TokenBudget};
37use crate::orchestrator::OrchestratorConfig;
38use crate::planner::{
39    build_client_from_backend, LlmInvocationConfig, LlmPlanner, LlmPlannerConfig,
40};
41use crate::thread::Thread;
42use crate::thread_runtime::{ThreadRuntime, ThreadRuntimeConfig};
43use crate::{Orchestrator, OrchestratorResult};
44
45/// Entry point for the SDK.
46pub struct Orchestral;
47
48impl Orchestral {
49    /// Create a new builder for programmatic setup.
50    pub fn builder() -> OrchestralBuilder {
51        OrchestralBuilder::default()
52    }
53}
54
55/// Builder for constructing an OrchestralApp without YAML config.
56pub struct OrchestralBuilder {
57    custom_actions: Vec<Arc<dyn Action>>,
58    lifecycle_hooks: Vec<Arc<dyn LifecycleHook>>,
59    planner_backend: Option<String>,
60    planner_model: Option<String>,
61    planner_api_key_env: Option<String>,
62    planner_temperature: Option<f32>,
63    max_planner_iterations: usize,
64    config_path: Option<PathBuf>,
65    concurrency_policy: Option<Arc<dyn ConcurrencyPolicy>>,
66}
67
68impl Default for OrchestralBuilder {
69    fn default() -> Self {
70        Self {
71            custom_actions: Vec::new(),
72            lifecycle_hooks: Vec::new(),
73            planner_backend: None,
74            planner_model: None,
75            planner_api_key_env: None,
76            planner_temperature: None,
77            max_planner_iterations: 6,
78            config_path: None,
79            concurrency_policy: None,
80        }
81    }
82}
83
84impl OrchestralBuilder {
85    /// Register a custom action.
86    pub fn action(mut self, action: impl Action + 'static) -> Self {
87        self.custom_actions.push(Arc::new(action));
88        self
89    }
90
91    /// Register a lifecycle hook.
92    pub fn hook(mut self, hook: impl LifecycleHook + 'static) -> Self {
93        self.lifecycle_hooks.push(Arc::new(hook));
94        self
95    }
96
97    /// Register a lifecycle hook from an existing Arc (for shared ownership).
98    pub fn hook_arc(mut self, hook: Arc<dyn LifecycleHook>) -> Self {
99        self.lifecycle_hooks.push(hook);
100        self
101    }
102
103    /// Set the LLM backend (e.g., "openrouter", "openai", "anthropic").
104    pub fn planner_backend(mut self, backend: impl Into<String>) -> Self {
105        self.planner_backend = Some(backend.into());
106        self
107    }
108
109    /// Set the LLM model (e.g., "anthropic/claude-sonnet-4.5").
110    pub fn planner_model(mut self, model: impl Into<String>) -> Self {
111        self.planner_model = Some(model.into());
112        self
113    }
114
115    /// Set the environment variable name for the API key.
116    pub fn planner_api_key_env(mut self, env_var: impl Into<String>) -> Self {
117        self.planner_api_key_env = Some(env_var.into());
118        self
119    }
120
121    /// Set the planner temperature (0.0 - 1.0).
122    pub fn planner_temperature(mut self, temperature: f32) -> Self {
123        self.planner_temperature = Some(temperature);
124        self
125    }
126
127    /// Set the maximum planner iterations per turn (default: 6).
128    pub fn max_planner_iterations(mut self, max: usize) -> Self {
129        self.max_planner_iterations = max;
130        self
131    }
132
133    /// Set the concurrency policy (default: InterruptAndStartNew).
134    /// Use `QueueConcurrencyPolicy` for chat bots to serialize message processing.
135    pub fn concurrency_policy(mut self, policy: impl ConcurrencyPolicy + 'static) -> Self {
136        self.concurrency_policy = Some(Arc::new(policy));
137        self
138    }
139
140    /// Optionally load base config from a YAML file.
141    /// Builder settings override YAML values.
142    pub fn config_path(mut self, path: impl Into<PathBuf>) -> Self {
143        self.config_path = Some(path.into());
144        self
145    }
146
147    /// Build the OrchestralApp.
148    pub async fn build(self) -> Result<OrchestralApp, SdkError> {
149        // Stores
150        let event_store = Arc::new(InMemoryEventStore::new());
151        let task_store = Arc::new(InMemoryTaskStore::new());
152
153        // Action registry: load from config if available, then add custom actions
154        let factory = Arc::new(DefaultActionFactory::new());
155        let config_path = self
156            .config_path
157            .clone()
158            .unwrap_or_else(|| PathBuf::from("orchestral.yaml"));
159        let registry_manager = ActionRegistryManager::new(config_path.clone(), factory);
160        // Try to load from config — ignore errors (config may not exist)
161        let _ = registry_manager.load().await;
162        // Register custom actions
163        let registry = registry_manager.registry();
164        {
165            let mut reg = registry.write().await;
166            for action in &self.custom_actions {
167                reg.register(action.clone());
168            }
169        }
170
171        // Normalizer — sync from registry
172        let mut normalizer = PlanNormalizer::new();
173        {
174            let reg = registry.read().await;
175            for name in reg.names() {
176                if let Some(action) = reg.get(&name) {
177                    normalizer.register_action_meta(&orchestral_core::action::extract_meta(
178                        action.as_ref(),
179                    ));
180                }
181            }
182        }
183
184        // Planner
185        let planner: Arc<dyn Planner> = self.build_planner()?;
186
187        // Executor
188        let executor = Executor::with_registry(registry.clone());
189
190        // Thread runtime
191        let policy: Arc<dyn ConcurrencyPolicy> = self
192            .concurrency_policy
193            .unwrap_or_else(|| Arc::new(DefaultConcurrencyPolicy));
194        let thread_runtime = ThreadRuntime::with_policy_and_config(
195            Thread::new(),
196            event_store.clone(),
197            policy,
198            ThreadRuntimeConfig::default(),
199        );
200
201        // Lifecycle hooks
202        let lifecycle_hooks = Arc::new(LifecycleHookRegistry::new());
203        for hook in self.lifecycle_hooks {
204            lifecycle_hooks.register(hook).await;
205        }
206
207        // Context builder
208        let context_builder = Arc::new(BasicContextBuilder::new(event_store.clone()));
209
210        // Orchestrator config
211        let config = OrchestratorConfig {
212            history_limit: 50,
213            context_budget: TokenBudget::new(4096),
214            include_history: true,
215            auto_replan_once: true,
216            auto_repair_plan_once: true,
217            max_planner_iterations: self.max_planner_iterations,
218        };
219
220        let mut orchestrator = Orchestrator::with_config(
221            thread_runtime,
222            planner,
223            normalizer,
224            executor,
225            task_store,
226            config,
227        )
228        .with_context_builder(context_builder)
229        .with_lifecycle_hooks(lifecycle_hooks);
230
231        if let Some(path) = &self.config_path {
232            orchestrator = orchestrator.with_skill_config_path(path.clone());
233        }
234
235        Ok(OrchestralApp { orchestrator })
236    }
237
238    fn build_planner(&self) -> Result<Arc<dyn Planner>, SdkError> {
239        let model = self
240            .planner_model
241            .clone()
242            .unwrap_or_else(|| "anthropic/claude-sonnet-4.5".to_string());
243        let api_key_env = self.planner_api_key_env.clone().unwrap_or_else(|| {
244            // Auto-detect from backend
245            match self.planner_backend.as_deref() {
246                Some("openai") => "OPENAI_API_KEY",
247                Some("anthropic") | Some("claude") => "ANTHROPIC_API_KEY",
248                Some("google") | Some("gemini") => "GOOGLE_API_KEY",
249                _ => "OPENROUTER_API_KEY",
250            }
251            .to_string()
252        });
253
254        let backend_kind = self
255            .planner_backend
256            .clone()
257            .unwrap_or_else(|| "openrouter".to_string());
258        let backend_spec = BackendSpec {
259            name: backend_kind.clone(),
260            kind: backend_kind,
261            endpoint: None,
262            api_key_env: Some(api_key_env),
263            config: Value::Null,
264        };
265        let invocation = LlmInvocationConfig {
266            model: model.clone(),
267            temperature: self.planner_temperature.unwrap_or(0.2),
268            ..LlmInvocationConfig::default()
269        };
270        let client = build_client_from_backend(&backend_spec, &invocation)
271            .map_err(|e| SdkError::Config(format!("failed to build LLM client: {}", e)))?;
272
273        let config = LlmPlannerConfig {
274            model,
275            temperature: self.planner_temperature.unwrap_or(0.2),
276            ..LlmPlannerConfig::default()
277        };
278
279        Ok(Arc::new(LlmPlanner::new(client, config)))
280    }
281}
282
283/// The running Orchestral application.
284pub struct OrchestralApp {
285    pub orchestrator: Orchestrator,
286}
287
288impl std::fmt::Debug for OrchestralApp {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        f.debug_struct("OrchestralApp").finish()
291    }
292}
293
294impl OrchestralApp {
295    /// Run a single user input and return the result.
296    /// The message field contains the assistant's actual output text.
297    pub async fn run(&self, input: &str) -> Result<RunResult, SdkError> {
298        let thread_id = self
299            .orchestrator
300            .thread_runtime
301            .thread_id()
302            .await
303            .to_string();
304        let event = Event::user_input(thread_id.as_str(), "", Value::String(input.to_string()));
305
306        let result = self
307            .orchestrator
308            .handle_event(event)
309            .await
310            .map_err(|e| SdkError::Runtime(e.to_string()))?;
311
312        // Extract task_id to query the correct assistant output for this specific request
313        let task_id = match &result {
314            OrchestratorResult::Started { task_id, .. }
315            | OrchestratorResult::Merged { task_id, .. } => Some(task_id.to_string()),
316            _ => None,
317        };
318        let assistant_message = if let Some(tid) = task_id {
319            self.assistant_output_for_task(&tid).await
320        } else {
321            None
322        };
323
324        Ok(RunResult::from_orchestrator_result(
325            result,
326            assistant_message,
327        ))
328    }
329
330    /// Query the assistant output for a specific task.
331    ///
332    /// AssistantOutput events store task_id inside the payload (not the Event-level
333    /// task_id field), so we match on `payload.task_id`.
334    async fn assistant_output_for_task(&self, task_id: &str) -> Option<String> {
335        let events = self
336            .orchestrator
337            .thread_runtime
338            .query_history(50)
339            .await
340            .ok()?;
341
342        events
343            .iter()
344            .rev()
345            .filter_map(|event| match event {
346                Event::AssistantOutput { payload, .. } => {
347                    // task_id is stored in the payload, not the Event-level field
348                    let event_task_id = payload.get("task_id").and_then(|v| v.as_str())?;
349                    if event_task_id != task_id {
350                        return None;
351                    }
352                    let text = payload
353                        .get("message")
354                        .or_else(|| payload.get("content"))
355                        .or_else(|| payload.get("text"))
356                        .and_then(|v| v.as_str())
357                        .map(String::from)
358                        .unwrap_or_else(|| payload.to_string());
359                    if text.is_empty() {
360                        None
361                    } else {
362                        Some(text)
363                    }
364                }
365                _ => None,
366            })
367            .next()
368    }
369}
370
371/// Result of a single `run()` call.
372#[derive(Debug, Clone)]
373pub struct RunResult {
374    pub status: String,
375    pub interaction_id: String,
376    pub task_id: String,
377    pub message: String,
378}
379
380impl RunResult {
381    fn from_orchestrator_result(
382        result: OrchestratorResult,
383        assistant_message: Option<String>,
384    ) -> Self {
385        match result {
386            OrchestratorResult::Started {
387                interaction_id,
388                task_id,
389                result,
390            }
391            | OrchestratorResult::Merged {
392                interaction_id,
393                task_id,
394                result,
395            } => Self {
396                status: execution_result_status(&result),
397                interaction_id: interaction_id.to_string(),
398                task_id: task_id.to_string(),
399                message: assistant_message.unwrap_or_else(|| execution_result_message(&result)),
400            },
401            OrchestratorResult::Rejected { reason } => Self {
402                status: "rejected".to_string(),
403                interaction_id: String::new(),
404                task_id: String::new(),
405                message: reason,
406            },
407            OrchestratorResult::Queued => Self {
408                status: "queued".to_string(),
409                interaction_id: String::new(),
410                task_id: String::new(),
411                message: "Request queued".to_string(),
412            },
413        }
414    }
415}
416
417fn execution_result_status(result: &ExecutionResult) -> String {
418    match result {
419        ExecutionResult::Completed => "completed".to_string(),
420        ExecutionResult::Failed { .. } => "failed".to_string(),
421        ExecutionResult::WaitingUser { .. } => "waiting_user".to_string(),
422        ExecutionResult::WaitingEvent { .. } => "waiting_event".to_string(),
423    }
424}
425
426fn execution_result_message(result: &ExecutionResult) -> String {
427    match result {
428        ExecutionResult::Completed => "Completed".to_string(),
429        ExecutionResult::Failed { error, .. } => error.clone(),
430        ExecutionResult::WaitingUser { prompt, .. } => prompt.clone(),
431        ExecutionResult::WaitingEvent { event_type, .. } => {
432            format!("Waiting for event: {}", event_type)
433        }
434    }
435}
436
437/// SDK errors.
438#[derive(Debug, Error)]
439pub enum SdkError {
440    #[error("config error: {0}")]
441    Config(String),
442    #[error("runtime error: {0}")]
443    Runtime(String),
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use orchestral_core::action::{Action, ActionContext, ActionInput, ActionMeta, ActionResult};
450    use orchestral_core::planner::{PlanError, Planner, PlannerContext, PlannerOutput};
451    use orchestral_core::spi::lifecycle::LifecycleHook;
452    use orchestral_core::types::Intent;
453
454    struct NoopPlanner;
455
456    #[async_trait::async_trait]
457    impl Planner for NoopPlanner {
458        async fn plan(
459            &self,
460            _intent: &Intent,
461            _context: &PlannerContext,
462        ) -> Result<PlannerOutput, PlanError> {
463            Ok(PlannerOutput::Done("noop".to_string()))
464        }
465    }
466
467    /// A deterministic action for testing — no LLM needed.
468    struct EchoAction;
469
470    #[async_trait::async_trait]
471    impl Action for EchoAction {
472        fn name(&self) -> &str {
473            "echo"
474        }
475        fn description(&self) -> &str {
476            "Echo input back"
477        }
478        fn metadata(&self) -> ActionMeta {
479            ActionMeta::new("echo", "Echo input back")
480                .with_input_schema(serde_json::json!({
481                    "type": "object",
482                    "properties": { "message": { "type": "string" } },
483                    "required": ["message"]
484                }))
485                .with_output_schema(serde_json::json!({
486                    "type": "object",
487                    "properties": { "result": { "type": "string" } },
488                    "required": ["result"]
489                }))
490        }
491        async fn run(&self, input: ActionInput, _ctx: ActionContext) -> ActionResult {
492            let msg = input
493                .params
494                .get("message")
495                .and_then(Value::as_str)
496                .unwrap_or("no message");
497            ActionResult::success_with(std::collections::HashMap::from([(
498                "result".to_string(),
499                Value::String(msg.to_string()),
500            )]))
501        }
502    }
503
504    #[test]
505    fn test_builder_defaults() {
506        let builder = Orchestral::builder();
507        assert_eq!(builder.max_planner_iterations, 6);
508        assert!(builder.custom_actions.is_empty());
509        assert!(builder.lifecycle_hooks.is_empty());
510        assert!(builder.planner_backend.is_none());
511    }
512
513    #[test]
514    fn test_builder_fluent_api() {
515        let builder = Orchestral::builder()
516            .action(EchoAction)
517            .planner_backend("openai")
518            .planner_model("gpt-4o-mini")
519            .planner_api_key_env("OPENAI_API_KEY")
520            .planner_temperature(0.5)
521            .max_planner_iterations(3);
522
523        assert_eq!(builder.custom_actions.len(), 1);
524        assert_eq!(builder.planner_backend.as_deref(), Some("openai"));
525        assert_eq!(builder.planner_model.as_deref(), Some("gpt-4o-mini"));
526        assert_eq!(
527            builder.planner_api_key_env.as_deref(),
528            Some("OPENAI_API_KEY")
529        );
530        assert_eq!(builder.planner_temperature, Some(0.5));
531        assert_eq!(builder.max_planner_iterations, 3);
532    }
533
534    #[test]
535    fn test_builder_with_hooks() {
536        struct TestHook;
537
538        #[async_trait::async_trait]
539        impl LifecycleHook for TestHook {}
540
541        let builder = Orchestral::builder().hook(TestHook).hook(TestHook);
542        assert_eq!(builder.lifecycle_hooks.len(), 2);
543    }
544
545    #[test]
546    fn test_builder_build_fails_with_invalid_backend() {
547        tokio_test::block_on(async {
548            // Use a nonexistent backend kind — should fail
549            let result = Orchestral::builder()
550                .planner_backend("nonexistent_backend_xyz")
551                .planner_api_key_env("__ORCHESTRAL_TEST_NONEXISTENT_KEY__")
552                .build()
553                .await;
554
555            assert!(result.is_err(), "expected error, got Ok");
556        });
557    }
558
559    #[test]
560    fn test_run_result_from_completed() {
561        let result = RunResult::from_orchestrator_result(
562            OrchestratorResult::Started {
563                interaction_id: "i1".into(),
564                task_id: "t1".into(),
565                result: ExecutionResult::Completed,
566            },
567            Some("Hello from assistant".to_string()),
568        );
569        assert_eq!(result.status, "completed");
570        assert_eq!(result.interaction_id, "i1");
571        assert_eq!(result.message, "Hello from assistant");
572    }
573
574    #[test]
575    fn test_run_result_from_completed_no_assistant_output() {
576        let result = RunResult::from_orchestrator_result(
577            OrchestratorResult::Started {
578                interaction_id: "i1".into(),
579                task_id: "t1".into(),
580                result: ExecutionResult::Completed,
581            },
582            None,
583        );
584        assert_eq!(result.message, "Completed");
585    }
586
587    #[test]
588    fn test_run_result_from_failed() {
589        let result = RunResult::from_orchestrator_result(
590            OrchestratorResult::Started {
591                interaction_id: "i1".into(),
592                task_id: "t1".into(),
593                result: ExecutionResult::Failed {
594                    step_id: "s1".into(),
595                    error: "boom".to_string(),
596                },
597            },
598            None,
599        );
600        assert_eq!(result.status, "failed");
601        assert_eq!(result.message, "boom");
602    }
603
604    #[test]
605    fn test_run_result_from_rejected() {
606        let result = RunResult::from_orchestrator_result(
607            OrchestratorResult::Rejected {
608                reason: "too busy".to_string(),
609            },
610            None,
611        );
612        assert_eq!(result.status, "rejected");
613        assert_eq!(result.message, "too busy");
614    }
615
616    #[test]
617    fn test_run_result_from_queued() {
618        let result = RunResult::from_orchestrator_result(OrchestratorResult::Queued, None);
619        assert_eq!(result.status, "queued");
620    }
621
622    #[tokio::test]
623    async fn test_assistant_output_for_task_matches_by_payload_task_id() {
624        use crate::concurrency::DefaultConcurrencyPolicy;
625        use crate::thread::Thread;
626        use crate::thread_runtime::{ThreadRuntime, ThreadRuntimeConfig};
627        use orchestral_core::store::{EventStore, InMemoryEventStore};
628
629        let event_store = Arc::new(InMemoryEventStore::new());
630
631        // Simulate two AssistantOutput events with different task_ids in payload
632        event_store
633            .append(Event::assistant_output(
634                "thread-1",
635                "interaction-1",
636                serde_json::json!({
637                    "task_id": "task-aaa",
638                    "message": "Response for task A"
639                }),
640            ))
641            .await
642            .unwrap();
643        event_store
644            .append(Event::assistant_output(
645                "thread-1",
646                "interaction-1",
647                serde_json::json!({
648                    "task_id": "task-bbb",
649                    "message": "Response for task B"
650                }),
651            ))
652            .await
653            .unwrap();
654
655        let thread_runtime = ThreadRuntime::with_policy_and_config(
656            Thread::with_id("thread-1"),
657            event_store.clone(),
658            Arc::new(DefaultConcurrencyPolicy),
659            ThreadRuntimeConfig::default(),
660        );
661
662        // Build a minimal OrchestralApp just for testing the query method
663        // We only need thread_runtime to be functional
664        let app = OrchestralApp {
665            orchestrator: crate::Orchestrator::with_config(
666                thread_runtime,
667                Arc::new(NoopPlanner),
668                orchestral_core::normalizer::PlanNormalizer::new(),
669                orchestral_core::executor::Executor::with_registry(Arc::new(
670                    tokio::sync::RwLock::new(orchestral_core::executor::ActionRegistry::new()),
671                )),
672                Arc::new(orchestral_core::store::InMemoryTaskStore::new()),
673                crate::orchestrator::OrchestratorConfig {
674                    history_limit: 50,
675                    context_budget: crate::context::TokenBudget::new(4096),
676                    include_history: true,
677                    auto_replan_once: false,
678                    auto_repair_plan_once: false,
679                    max_planner_iterations: 1,
680                },
681            ),
682        };
683
684        // Query for task-aaa should get "Response for task A"
685        let result_a = app.assistant_output_for_task("task-aaa").await;
686        assert_eq!(result_a, Some("Response for task A".to_string()));
687
688        // Query for task-bbb should get "Response for task B"
689        let result_b = app.assistant_output_for_task("task-bbb").await;
690        assert_eq!(result_b, Some("Response for task B".to_string()));
691
692        // Query for nonexistent task should return None
693        let result_none = app.assistant_output_for_task("task-zzz").await;
694        assert_eq!(result_none, None);
695    }
696}