gsm-runner 0.4.43

Greentic messaging package: runner.
Documentation
use anyhow::{Result, anyhow};
use handlebars::Handlebars;
use rand::{Rng, rng};
use serde_json::{Value, json};
use tokio::time::{Duration, sleep};

use gsm_core::MessageEnvelope;

pub async fn run_tool(
    cfg: &crate::model::ToolNode,
    env: &MessageEnvelope,
    state: &Value,
    endpoint: &str,
) -> Result<Value> {
    let input = render_tool_input(cfg, env, state)?;
    run_tool_with_input(cfg, input, endpoint).await
}

pub fn render_tool_input(
    cfg: &crate::model::ToolNode,
    env: &MessageEnvelope,
    state: &Value,
) -> Result<Value> {
    let mut input = cfg.input.clone();
    render_json_strings(&mut input, &json!({"state":state, "envelope":env}))?;
    Ok(input)
}

pub async fn run_tool_with_input(
    cfg: &crate::model::ToolNode,
    input: Value,
    endpoint: &str,
) -> Result<Value> {
    let url = format!(
        "{}/{}/{}",
        endpoint.trim_end_matches('/'),
        cfg.tool,
        cfg.action
    );

    let retries = cfg.retry.unwrap_or(1);
    let timeout = cfg.timeout_secs.unwrap_or(10);
    let base = cfg.delay_secs.unwrap_or(1);

    for attempt in 0..=retries {
        let resp = tokio::time::timeout(Duration::from_secs(timeout), async {
            reqwest::Client::new().post(&url).json(&input).send().await
        })
        .await;

        match resp {
            Ok(Ok(r)) if r.status().is_success() => {
                let v: Value = r.json().await.unwrap_or_else(|_| json!({}));
                return Ok(v);
            }
            _ => {
                if attempt == retries {
                    return Err(anyhow!("tool call failed after {} attempts", retries + 1));
                }
                let jitter: f64 = rng().random_range(0.5..1.5);
                let delay = (base as f64 * 2f64.powi(attempt as i32) * jitter).round() as u64;
                sleep(Duration::from_secs(delay)).await;
            }
        }
    }
    Err(anyhow!("unreachable"))
}

#[allow(dead_code)]
pub fn run_tool_stub_with_input(input: Value) -> Result<Value> {
    let _ = input;
    Ok(json!({"ok": true}))
}

fn render_json_strings(value: &mut Value, ctx: &Value) -> Result<()> {
    let h = Handlebars::new();
    match value {
        Value::String(s) => {
            *s = h.render_template(s, ctx)?;
        }
        Value::Array(arr) => {
            for v in arr {
                render_json_strings(v, ctx)?;
            }
        }
        Value::Object(map) => {
            for (_, v) in map.iter_mut() {
                render_json_strings(v, ctx)?;
            }
        }
        _ => {}
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn render_json_strings_substitutes_nested_templates() {
        let mut value = json!({
            "greeting": "Hello {{state.user}}",
            "items": ["{{envelope.chat_id}}", "{{state.extra}}"]
        });
        let ctx = json!({
            "state": { "user": "Alice", "extra": "item-2" },
            "envelope": { "chat_id": "chat-1" }
        });

        render_json_strings(&mut value, &ctx).unwrap();
        assert_eq!(value["greeting"], "Hello Alice");
        assert_eq!(value["items"][0], "chat-1");
        assert_eq!(value["items"][1], "item-2");
    }

    #[test]
    fn render_json_strings_leaves_non_strings() {
        let mut value = json!({
            "count": 3,
            "flags": [true, false],
            "note": "Hi {{state.name}}"
        });
        let ctx = json!({ "state": { "name": "Bob" } });

        render_json_strings(&mut value, &ctx).unwrap();
        assert_eq!(value["count"], 3);
        assert_eq!(value["flags"][0], true);
        assert_eq!(value["note"], "Hi Bob");
    }
}