mod config;
mod event_support;
mod submit;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context;
use tokio::sync::Mutex;
use tokio::time::Duration;
use orchestral_runtime::api::{DefaultRuntimeAppBuilder, RuntimeApi, RuntimeAppBuilder};
use crate::channel::CliRuntime;
use self::config::prepare_runtime_config_path;
const TURN_SETTLE_TIMEOUT: Duration = Duration::from_secs(30);
const TURN_SETTLE_GRACE_TIMEOUT: Duration = Duration::from_secs(60);
const FORWARD_DRAIN_IDLE_TIMEOUT: Duration = Duration::from_millis(120);
#[derive(Clone)]
pub struct RuntimeClient {
runtime: Arc<CliRuntime>,
thread_id: String,
submit_lock: Arc<Mutex<()>>,
boot_info: BootInfo,
}
#[derive(Debug, Clone)]
pub struct BootInfo {
pub planner_backend: String,
pub planner_model: String,
pub action_count: usize,
pub config_source: String,
}
#[derive(Debug, Clone, Default)]
pub struct PlannerOverrides {
pub backend: Option<String>,
pub model_profile: Option<String>,
pub model: Option<String>,
pub temperature: Option<f32>,
}
impl PlannerOverrides {
pub fn is_empty(&self) -> bool {
self.backend.is_none()
&& self.model_profile.is_none()
&& self.model.is_none()
&& self.temperature.is_none()
}
}
impl RuntimeClient {
pub async fn from_config(
config: Option<PathBuf>,
thread_id_override: Option<String>,
planner_overrides: PlannerOverrides,
) -> anyhow::Result<Self> {
let config_path = prepare_runtime_config_path(config, &planner_overrides)?;
let boot_info = build_boot_info(&config_path);
let app_builder: Arc<dyn RuntimeAppBuilder> = Arc::new(DefaultRuntimeAppBuilder);
let api = Arc::new(
RuntimeApi::from_config_path_with_builder(config_path, app_builder)
.await
.context("failed to build runtime api")?,
);
let runtime = CliRuntime::from_api(api, thread_id_override)
.await
.context("failed to build cli runtime from config")?;
Ok(Self {
thread_id: runtime.thread_id().to_string(),
runtime: Arc::new(runtime),
submit_lock: Arc::new(Mutex::new(())),
boot_info,
})
}
pub fn thread_id(&self) -> &str {
&self.thread_id
}
pub fn boot_info(&self) -> &BootInfo {
&self.boot_info
}
}
fn build_boot_info(config_path: &std::path::Path) -> BootInfo {
use orchestral_core::config::load_config;
let config_source = config_path.display().to_string();
match load_config(config_path) {
Ok(config) => {
let planner_backend = config
.planner
.backend
.clone()
.or_else(|| config.providers.default_backend.clone())
.unwrap_or_else(|| "default".to_string());
let planner_model = config
.planner
.model_profile
.as_ref()
.or(config.planner.model.as_ref())
.cloned()
.unwrap_or_else(|| "default".to_string());
let action_count = config.actions.actions.len();
BootInfo {
planner_backend,
planner_model,
action_count,
config_source,
}
}
Err(_) => BootInfo {
planner_backend: "unknown".to_string(),
planner_model: "unknown".to_string(),
action_count: 0,
config_source,
},
}
}
const GENERATED_CONFIG_DIR: &str = ".orchestral/generated";
const GENERATED_CONFIG_FILE: &str = "default.cli.yaml";
const GENERATED_OVERRIDE_CONFIG_SUFFIX: &str = ".runtime.override.yaml";
#[cfg(test)]
mod tests {
use super::*;
use orchestral_core::config::{BackendSpec, ModelPolicy, ModelProfile, OrchestralConfig};
use orchestral_core::store::Event as ChannelEvent;
use serde_json::json;
use serde_yaml::Value as YamlValue;
use std::path::Path;
use crate::runtime::client::config::{
apply_planner_overrides_to_yaml, runtime_override_config_path,
};
use crate::runtime::client::event_support::{
event_interaction_id, format_running_label, normalize_step_action_label,
};
#[test]
fn test_event_interaction_id_from_assistant_output() {
let event = ChannelEvent::assistant_output("thread-1", "int-1", json!({"message":"ok"}));
assert_eq!(event_interaction_id(&event), Some("int-1"));
}
#[test]
fn test_event_interaction_id_from_system_trace_payload() {
let event = ChannelEvent::trace("thread-1", "info", json!({"interaction_id":"int-2"}));
assert_eq!(event_interaction_id(&event), Some("int-2"));
}
#[test]
fn test_normalize_step_action_label_falls_back_to_step_id_for_empty_action() {
assert_eq!(
normalize_step_action_label("process_xlsx", Some("".to_string())),
"process_xlsx"
);
assert_eq!(
normalize_step_action_label("process_xlsx", Some(" ".to_string())),
"process_xlsx"
);
assert_eq!(
normalize_step_action_label("process_xlsx", None),
"process_xlsx"
);
assert_eq!(
normalize_step_action_label("process_xlsx", Some("file_read".to_string())),
"file_read"
);
}
#[test]
fn test_format_running_label_avoids_duplicate_parentheses() {
assert_eq!(
format_running_label("process_xlsx", "process_xlsx"),
"Running process_xlsx"
);
assert_eq!(
format_running_label("read_skill_docs", "file_read"),
"Running read_skill_docs (file_read)"
);
}
#[test]
fn test_apply_planner_overrides_aligns_backend_with_model_profile() {
let mut yaml: YamlValue = serde_yaml::from_str(
r#"
planner:
backend: openrouter
model_profile: claude-sonnet-4-5
model: anthropic/claude-sonnet-4.5
"#,
)
.unwrap();
let mut config = OrchestralConfig::default();
config.providers.backends = vec![
BackendSpec {
name: "openrouter".to_string(),
kind: "openrouter".to_string(),
endpoint: None,
api_key_env: None,
config: json!(null),
},
BackendSpec {
name: "google".to_string(),
kind: "google".to_string(),
endpoint: None,
api_key_env: None,
config: json!(null),
},
];
config.providers.models = vec![ModelProfile {
name: "gemini-2.5-flash".to_string(),
backend: Some("google".to_string()),
model: "gemini-2.5-flash".to_string(),
temperature: Some(0.2),
max_tokens: None,
system_prompt: None,
policy: ModelPolicy::default(),
config: json!(null),
}];
apply_planner_overrides_to_yaml(
&mut yaml,
&config,
&PlannerOverrides {
backend: None,
model_profile: Some("gemini-2.5-flash".to_string()),
model: None,
temperature: None,
},
)
.unwrap();
let planner = yaml
.get("planner")
.and_then(|value| value.as_mapping())
.unwrap();
let backend_key = YamlValue::String("backend".to_string());
let profile_key = YamlValue::String("model_profile".to_string());
let model_key = YamlValue::String("model".to_string());
assert_eq!(
planner.get(&backend_key).and_then(|value| value.as_str()),
Some("google")
);
assert_eq!(
planner.get(&profile_key).and_then(|value| value.as_str()),
Some("gemini-2.5-flash")
);
assert!(planner.get(&model_key).is_some_and(YamlValue::is_null));
}
#[test]
fn test_runtime_override_config_path_is_stable_sibling_file() {
let base = Path::new("configs/orchestral.cli.yaml");
let override_path = runtime_override_config_path(base);
assert_eq!(
override_path,
PathBuf::from("configs/orchestral.cli.runtime.override.yaml")
);
}
#[test]
fn test_build_boot_info_reads_config() {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest.parent().unwrap().parent().unwrap();
let config_path = workspace_root.join("configs/orchestral.cli.yaml");
if !config_path.exists() {
return; }
let info = super::build_boot_info(&config_path);
assert!(!info.planner_backend.is_empty());
assert!(!info.planner_model.is_empty());
assert!(info.action_count > 0);
assert!(info.config_source.contains("orchestral.cli.yaml"));
}
#[test]
fn test_build_boot_info_graceful_on_missing_config() {
let info = super::build_boot_info(Path::new("nonexistent.yaml"));
assert_eq!(info.planner_backend, "unknown");
assert_eq!(info.planner_model, "unknown");
assert_eq!(info.action_count, 0);
}
}