gsm_runner/
tool_node.rs

1use anyhow::{Result, anyhow};
2use handlebars::Handlebars;
3use rand::{Rng, rng};
4use serde_json::{Value, json};
5use tokio::time::{Duration, sleep};
6
7use gsm_core::MessageEnvelope;
8
9pub async fn run_tool(
10    cfg: &crate::model::ToolNode,
11    env: &MessageEnvelope,
12    state: &Value,
13    endpoint: &str,
14) -> Result<Value> {
15    let input = render_tool_input(cfg, env, state)?;
16    run_tool_with_input(cfg, input, endpoint).await
17}
18
19pub fn render_tool_input(
20    cfg: &crate::model::ToolNode,
21    env: &MessageEnvelope,
22    state: &Value,
23) -> Result<Value> {
24    let mut input = cfg.input.clone();
25    render_json_strings(&mut input, &json!({"state":state, "envelope":env}))?;
26    Ok(input)
27}
28
29pub async fn run_tool_with_input(
30    cfg: &crate::model::ToolNode,
31    input: Value,
32    endpoint: &str,
33) -> Result<Value> {
34    let url = format!(
35        "{}/{}/{}",
36        endpoint.trim_end_matches('/'),
37        cfg.tool,
38        cfg.action
39    );
40
41    let retries = cfg.retry.unwrap_or(1);
42    let timeout = cfg.timeout_secs.unwrap_or(10);
43    let base = cfg.delay_secs.unwrap_or(1);
44
45    for attempt in 0..=retries {
46        let resp = tokio::time::timeout(Duration::from_secs(timeout), async {
47            reqwest::Client::new().post(&url).json(&input).send().await
48        })
49        .await;
50
51        match resp {
52            Ok(Ok(r)) if r.status().is_success() => {
53                let v: Value = r.json().await.unwrap_or_else(|_| json!({}));
54                return Ok(v);
55            }
56            _ => {
57                if attempt == retries {
58                    return Err(anyhow!("tool call failed after {} attempts", retries + 1));
59                }
60                let jitter: f64 = rng().random_range(0.5..1.5);
61                let delay = (base as f64 * 2f64.powi(attempt as i32) * jitter).round() as u64;
62                sleep(Duration::from_secs(delay)).await;
63            }
64        }
65    }
66    Err(anyhow!("unreachable"))
67}
68
69#[allow(dead_code)]
70pub fn run_tool_stub_with_input(input: Value) -> Result<Value> {
71    let _ = input;
72    Ok(json!({"ok": true}))
73}
74
75fn render_json_strings(value: &mut Value, ctx: &Value) -> Result<()> {
76    let h = Handlebars::new();
77    match value {
78        Value::String(s) => {
79            *s = h.render_template(s, ctx)?;
80        }
81        Value::Array(arr) => {
82            for v in arr {
83                render_json_strings(v, ctx)?;
84            }
85        }
86        Value::Object(map) => {
87            for (_, v) in map.iter_mut() {
88                render_json_strings(v, ctx)?;
89            }
90        }
91        _ => {}
92    }
93    Ok(())
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn render_json_strings_substitutes_nested_templates() {
102        let mut value = json!({
103            "greeting": "Hello {{state.user}}",
104            "items": ["{{envelope.chat_id}}", "{{state.extra}}"]
105        });
106        let ctx = json!({
107            "state": { "user": "Alice", "extra": "item-2" },
108            "envelope": { "chat_id": "chat-1" }
109        });
110
111        render_json_strings(&mut value, &ctx).unwrap();
112        assert_eq!(value["greeting"], "Hello Alice");
113        assert_eq!(value["items"][0], "chat-1");
114        assert_eq!(value["items"][1], "item-2");
115    }
116
117    #[test]
118    fn render_json_strings_leaves_non_strings() {
119        let mut value = json!({
120            "count": 3,
121            "flags": [true, false],
122            "note": "Hi {{state.name}}"
123        });
124        let ctx = json!({ "state": { "name": "Bob" } });
125
126        render_json_strings(&mut value, &ctx).unwrap();
127        assert_eq!(value["count"], 3);
128        assert_eq!(value["flags"][0], true);
129        assert_eq!(value["note"], "Hi Bob");
130    }
131}