orchestral-runtime 0.2.0

Thread Runtime with concurrency, interruption, scheduling, LLM planners, actions, and API for Orchestral
Documentation
use std::collections::HashMap;
use std::time::Duration;

use async_trait::async_trait;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde_json::{json, Map, Value};

use orchestral_core::action::{Action, ActionContext, ActionInput, ActionMeta, ActionResult};
use orchestral_core::config::ActionSpec;

use super::support::{config_string, config_u64, params_get_string};

pub(super) struct HttpAction {
    name: String,
    description: String,
    default_method: String,
    default_url: Option<String>,
    default_headers: HashMap<String, String>,
    client: reqwest::Client,
}

impl HttpAction {
    pub(super) fn from_spec(spec: &ActionSpec) -> Self {
        let default_method =
            config_string(&spec.config, "default_method").unwrap_or_else(|| "GET".to_string());
        let default_url = config_string(&spec.config, "default_url");
        let default_headers =
            headers_from_value(spec.config.get("headers").unwrap_or(&Value::Null));
        let timeout_ms = config_u64(&spec.config, "timeout_ms");

        let client = {
            let builder = reqwest::Client::builder();
            let builder = if let Some(ms) = timeout_ms {
                builder.timeout(Duration::from_millis(ms))
            } else {
                builder
            };
            builder.build().unwrap_or_else(|_| reqwest::Client::new())
        };

        Self {
            name: spec.name.clone(),
            description: spec.description_or("Performs an HTTP request"),
            default_method,
            default_url,
            default_headers,
            client,
        }
    }
}

#[async_trait]
impl Action for HttpAction {
    fn name(&self) -> &str {
        &self.name
    }

    fn description(&self) -> &str {
        &self.description
    }

    fn metadata(&self) -> ActionMeta {
        ActionMeta::new(self.name(), self.description())
            .with_category("direct")
            .with_capabilities(["network_io", "side_effect"])
            .with_input_kinds(["network.request"])
            .with_output_kinds(["network.response", "text"])
            .with_input_schema(json!({
                "type": "object",
                "properties": {
                    "method": {
                        "type": "string",
                        "description": "HTTP method, such as GET/POST/PUT.",
                        "default": self.default_method
                    },
                    "url": {
                        "type": "string",
                        "description": "Request URL. Required when no default_url is configured.",
                        "default": self.default_url
                    },
                    "headers": {
                        "type": "object",
                        "description": "Extra request headers merged with action defaults."
                    },
                    "body": {
                        "description": "Raw request body. If not a string, it is sent as JSON."
                    },
                    "json": {
                        "description": "JSON payload. Takes precedence over body when both are provided."
                    }
                }
            }))
            .with_output_schema(json!({
                "type": "object",
                "properties": {
                    "status": {
                        "type": "integer",
                        "description": "HTTP status code."
                    },
                    "url": {
                        "type": "string",
                        "description": "Resolved request URL."
                    },
                    "headers": {
                        "type": "object",
                        "description": "Response headers."
                    },
                    "body": {
                        "type": "string",
                        "description": "Response body as text."
                    }
                },
                "required": ["status", "url", "headers", "body"]
            }))
    }

    async fn run(&self, input: ActionInput, _ctx: ActionContext) -> ActionResult {
        let params = &input.params;
        let method =
            params_get_string(params, "method").unwrap_or_else(|| self.default_method.clone());
        let url = params_get_string(params, "url").or_else(|| self.default_url.clone());

        let url = match url {
            Some(u) => u,
            None => return ActionResult::error("Missing url for http action"),
        };

        let override_headers = params
            .get("headers")
            .map(headers_from_value)
            .unwrap_or_default();
        let headers = merge_headers(&self.default_headers, &override_headers);

        let request = match method.parse::<reqwest::Method>() {
            Ok(m) => self.client.request(m, url.clone()).headers(headers),
            Err(_) => return ActionResult::error(format!("Invalid HTTP method: {}", method)),
        };

        let request = if let Some(json_value) = params.get("json") {
            request.json(json_value)
        } else if let Some(body) = params.get("body") {
            if body.is_string() {
                request.body(body.as_str().unwrap_or_default().to_string())
            } else {
                request.json(body)
            }
        } else {
            request
        };

        let response = match request.send().await {
            Ok(r) => r,
            Err(e) => return ActionResult::error(format!("HTTP request failed: {}", e)),
        };

        let status = response.status().as_u16();
        let headers_map = response
            .headers()
            .iter()
            .filter_map(|(k, v)| {
                v.to_str()
                    .ok()
                    .map(|s| (k.to_string(), Value::String(s.to_string())))
            })
            .collect::<Map<String, Value>>();
        let body = match response.text().await {
            Ok(text) => text,
            Err(e) => return ActionResult::error(format!("HTTP response read failed: {}", e)),
        };

        let mut exports = Map::new();
        exports.insert("status".to_string(), Value::Number(status.into()));
        exports.insert("url".to_string(), Value::String(url));
        exports.insert("headers".to_string(), Value::Object(headers_map));
        exports.insert("body".to_string(), Value::String(body));

        ActionResult::success_with(exports.into_iter().collect())
    }
}

fn headers_from_value(value: &Value) -> HashMap<String, String> {
    value
        .as_object()
        .map(|obj| {
            obj.iter()
                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
                .collect()
        })
        .unwrap_or_default()
}

fn merge_headers(
    defaults: &HashMap<String, String>,
    overrides: &HashMap<String, String>,
) -> HeaderMap {
    let mut map = HeaderMap::new();
    for (k, v) in defaults.iter().chain(overrides.iter()) {
        if let (Ok(name), Ok(value)) = (
            HeaderName::from_bytes(k.as_bytes()),
            HeaderValue::from_str(v),
        ) {
            map.insert(name, value);
        }
    }
    map
}