1use 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
45pub struct Orchestral;
47
48impl Orchestral {
49 pub fn builder() -> OrchestralBuilder {
51 OrchestralBuilder::default()
52 }
53}
54
55pub 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 pub fn action(mut self, action: impl Action + 'static) -> Self {
87 self.custom_actions.push(Arc::new(action));
88 self
89 }
90
91 pub fn hook(mut self, hook: impl LifecycleHook + 'static) -> Self {
93 self.lifecycle_hooks.push(Arc::new(hook));
94 self
95 }
96
97 pub fn hook_arc(mut self, hook: Arc<dyn LifecycleHook>) -> Self {
99 self.lifecycle_hooks.push(hook);
100 self
101 }
102
103 pub fn planner_backend(mut self, backend: impl Into<String>) -> Self {
105 self.planner_backend = Some(backend.into());
106 self
107 }
108
109 pub fn planner_model(mut self, model: impl Into<String>) -> Self {
111 self.planner_model = Some(model.into());
112 self
113 }
114
115 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 pub fn planner_temperature(mut self, temperature: f32) -> Self {
123 self.planner_temperature = Some(temperature);
124 self
125 }
126
127 pub fn max_planner_iterations(mut self, max: usize) -> Self {
129 self.max_planner_iterations = max;
130 self
131 }
132
133 pub fn concurrency_policy(mut self, policy: impl ConcurrencyPolicy + 'static) -> Self {
136 self.concurrency_policy = Some(Arc::new(policy));
137 self
138 }
139
140 pub fn config_path(mut self, path: impl Into<PathBuf>) -> Self {
143 self.config_path = Some(path.into());
144 self
145 }
146
147 pub async fn build(self) -> Result<OrchestralApp, SdkError> {
149 let event_store = Arc::new(InMemoryEventStore::new());
151 let task_store = Arc::new(InMemoryTaskStore::new());
152
153 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 let _ = registry_manager.load().await;
162 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 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 let planner: Arc<dyn Planner> = self.build_planner()?;
186
187 let executor = Executor::with_registry(registry.clone());
189
190 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 let lifecycle_hooks = Arc::new(LifecycleHookRegistry::new());
203 for hook in self.lifecycle_hooks {
204 lifecycle_hooks.register(hook).await;
205 }
206
207 let context_builder = Arc::new(BasicContextBuilder::new(event_store.clone()));
209
210 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 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
283pub 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 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 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 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 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#[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#[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 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 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 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 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 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 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 let result_none = app.assistant_output_for_task("task-zzz").await;
694 assert_eq!(result_none, None);
695 }
696}