Skip to main content

mxblocks_tools/
lib.rs

1//! The `rich_send` / `rich_edit` tools for an LLM agent that posts mxblocks
2//! messages: one definition (names, descriptions, input schemas:
3//! [`tool_specs`]) and one argument check ([`plan`]), for whatever tools
4//! server the agent runs (MCP or a native tool API). Sending is the
5//! caller's: [`plan`] gives the checked payload, text and targets.
6//!
7//! Payloads are checked by [`mxblocks`] (the v1 catalogue); floats become
8//! decimal strings, since Matrix refuses JSON floats.
9
10#![doc = include_str!("../README.md")]
11#![warn(missing_docs)]
12
13use serde_json::{Map, Value, json};
14
15use mxblocks::Rich;
16
17/// `rich_send`'s name.
18pub const RICH_SEND: &str = "rich_send";
19/// `rich_edit`'s name.
20pub const RICH_EDIT: &str = "rich_edit";
21/// Longest `fallback`, in characters (like `say`'s text).
22pub const MAX_FALLBACK: usize = 20000;
23/// Longest `room` argument.
24pub const MAX_ROOM: usize = 300;
25
26/// Which blocks to use for what: shared by the tool description and the
27/// prompts.
28pub const BLOCK_GUIDE: &str = "Pick the block for the answer: weather for weather questions; \
29table for comparing options or listing rows; place for one venue or address; event for a \
30date/time thing (meeting, flight, booking); countdown for a deadline; chart for a trend or \
31numbers over time; card/cards for one or a few products, listings or orders; sources for \
32links you used; code/diff for code; progress for multi-step work; details for long \
33optional text; ticker for prices; tracker for live order/ride/parcel status (send it once \
34with rich_send, then rich_edit it as the status changes, about every 30-60 s, ending with \
35state done); math for equations; gallery for 2+ images (mxc); file for one upload; form \
36for several choices at once; buttons/chips for a quick decision or quick replies. Numbers \
37are whole numbers or decimal strings (\"price\":\"62.99\"): Matrix refuses JSON floats, so a \
38float is sent as its decimal string.";
39
40/// A minimal `rich_send` example (weather).
41pub const WEATHER_EXAMPLE: &str = r#"{"payload":{"v":1,"blocks":[{"type":"weather","location":"Lisbon","temp":24,"unit":"C","condition":"Partly cloudy","high":26,"low":17}]},"fallback":"Lisbon: 24°C, partly cloudy (26/17)."}"#;
42
43/// The prompt line of the tools for a sandboxed context (a served user's
44/// DM, a multi-user room), where they post in this chat.
45pub fn prompt_line() -> String {
46    format!(
47        "- rich_send, rich_edit: rich messages as you in this chat: blocks that Element Web \
48         draws (table, weather, place, event, chart, tracker, …) plus a `fallback` text that \
49         carries everything for other clients (Element on phones shows only the text). Use them \
50         instead of a plain answer when structure helps; then end your turn with `[no reply]` \
51         unless there's more to say. {BLOCK_GUIDE} rich_send returns the new message's event id; \
52         rich_edit replaces that message (only your own, at most one edit every 2.5 s). \
53         Example rich_send: {WEATHER_EXAMPLE}"
54    )
55}
56
57/// One tool: name, description, input schema.
58#[derive(Debug, Clone)]
59pub struct ToolSpec {
60    /// The tool's name.
61    pub name: &'static str,
62    /// What it does, for the model.
63    pub description: String,
64    /// Its input's JSON Schema.
65    pub schema: Value,
66}
67
68/// The two tools. `rooms`: what `room` may name in this context (shown in
69/// its description).
70pub fn tool_specs(rooms: &str) -> Vec<ToolSpec> {
71    let payload = json!({"type": "object", "description":
72        "the mxblocks payload: {\"v\":1,\"blocks\":[…]} (up to 8 blocks; the mxblocks v1 \
73         catalogue, validated before sending)"});
74    let fallback = json!({"type": "string", "description":
75        "the message text (CommonMark, rendered like a normal message): it must carry \
76         everything, since only Element Web draws the blocks"});
77    let room = json!({"type": "string", "description": format!(
78        "where to post (default: this chat). {rooms}")});
79    let event = |d: &str| json!({"type": "string", "description": d});
80    vec![
81        ToolSpec {
82            name: RICH_SEND,
83            description: format!(
84                "Send a rich message as you: blocks (tables, weather, places, events, charts, \
85                 trackers, …) drawn by Element Web, with a text fallback for everything else. \
86                 Returns the new message's event id (keep it to rich_edit it later). {BLOCK_GUIDE} \
87                 Example: {WEATHER_EXAMPLE}"
88            ),
89            schema: json!({"type": "object", "properties": {
90                "payload": payload, "fallback": fallback,
91                "reply_to": event("reply to this event id ($…), optional"),
92                "thread": event("post it in this thread (its root event id $…), optional"),
93                "room": room}, "required": ["payload", "fallback"],
94                "additionalProperties": false}),
95        },
96        ToolSpec {
97            name: RICH_EDIT,
98            description: "Replace one of your own earlier messages in place (an m.replace with \
99                          the new blocks and text; pings nobody). Only your own messages, at \
100                          most one edit per message every 2.5 s (a faster edit waits); a typical \
101                          live update is every 30-60 s."
102                .to_owned(),
103            schema: json!({"type": "object", "properties": {
104                "event_id": event("the message to replace ($…, the one rich_send returned)"),
105                "payload": payload, "fallback": fallback, "room": room},
106                "required": ["event_id", "payload", "fallback"],
107                "additionalProperties": false}),
108        },
109    ]
110}
111
112/// A checked call.
113#[derive(Debug, Clone, PartialEq)]
114pub struct RichCall {
115    /// The checked payload (canonical numbers).
116    pub payload: Rich,
117    /// The message text (Markdown).
118    pub fallback: String,
119    /// `rich_send`: reply to this event.
120    pub reply_to: Option<String>,
121    /// `rich_send`: in this thread (its root event id).
122    pub thread: Option<String>,
123    /// `rich_edit`: the message replaced.
124    pub edit: Option<String>,
125    /// As named by the caller (`dm`, a room id, …); `None`: this chat.
126    pub room: Option<String>,
127}
128
129/// An event id as the tools take it: `$`, then 1 to 254 non-space
130/// characters.
131pub fn valid_event_id(s: &str) -> bool {
132    s.len() <= 255
133        && s.strip_prefix('$')
134            .is_some_and(|r| !r.is_empty() && !r.chars().any(char::is_whitespace))
135}
136
137fn event_arg(a: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
138    match a.get(key) {
139        None | Some(Value::Null) => Ok(None),
140        Some(Value::String(s)) if valid_event_id(s.trim()) => Ok(Some(s.trim().to_owned())),
141        Some(_) => Err(format!("`{key}` must be an event id ($…)")),
142    }
143}
144
145/// Whether `name` is one of these tools.
146pub fn is_rich_tool(name: &str) -> bool {
147    name == RICH_SEND || name == RICH_EDIT
148}
149
150/// Check a call's arguments.
151///
152/// # Errors
153/// What's wrong, for the caller.
154pub fn plan(name: &str, args: &Value) -> Result<RichCall, String> {
155    let empty = Map::new();
156    let a = match args {
157        Value::Object(m) => m,
158        Value::Null => &empty,
159        _ => return Err("arguments must be an object".into()),
160    };
161    let edit = match name {
162        RICH_SEND => false,
163        RICH_EDIT => true,
164        _ => return Err(format!("no tool `{name}`")),
165    };
166    let keys: &[&str] = if edit {
167        &["event_id", "payload", "fallback", "room"]
168    } else {
169        &["payload", "fallback", "reply_to", "thread", "room"]
170    };
171    if let Some(k) = a.keys().find(|k| !keys.contains(&k.as_str())) {
172        return Err(format!(
173            "unknown argument `{}`",
174            k.chars().take(40).collect::<String>()
175        ));
176    }
177    let payload = match a.get("payload") {
178        Some(Value::String(s)) => Rich::parse(s)?,
179        Some(v @ Value::Object(_)) => Rich::parse(&v.to_string())?,
180        _ => return Err("`payload` (a {\"v\":1,\"blocks\":[…]} object) is required".into()),
181    };
182    let fallback = a
183        .get("fallback")
184        .and_then(Value::as_str)
185        .map(str::trim)
186        .unwrap_or_default();
187    if fallback.is_empty() && payload.find_math().is_none() {
188        return Err("`fallback` (the message text) is required".into());
189    }
190    if fallback.chars().count() > MAX_FALLBACK {
191        return Err(format!("`fallback` has at most {MAX_FALLBACK} characters"));
192    }
193    let room = match a.get("room") {
194        None | Some(Value::Null) => None,
195        Some(Value::String(s))
196            if !s.trim().is_empty() && s.len() <= MAX_ROOM && !s.contains(['\n', '\t']) =>
197        {
198            Some(s.trim().to_owned())
199        }
200        Some(_) => return Err("`room` is one line naming a room".into()),
201    };
202    let (reply_to, thread, edit) = if edit {
203        let e = event_arg(a, "event_id")?.ok_or("`event_id` ($…) is required")?;
204        (None, None, Some(e))
205    } else {
206        (event_arg(a, "reply_to")?, event_arg(a, "thread")?, None)
207    };
208    Ok(RichCall {
209        payload,
210        fallback: fallback.to_owned(),
211        reply_to,
212        thread,
213        edit,
214        room,
215    })
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn specs() {
224        let t = tool_specs("`dm` or a session room id.");
225        assert_eq!(
226            t.iter().map(|t| t.name).collect::<Vec<_>>(),
227            [RICH_SEND, RICH_EDIT]
228        );
229        assert!(t[0].description.contains("weather for weather"));
230        assert!(
231            t[0].schema["properties"]["room"]["description"]
232                .as_str()
233                .unwrap()
234                .contains("`dm`")
235        );
236        // The example is a valid call.
237        let ex: Value = serde_json::from_str(WEATHER_EXAMPLE).unwrap();
238        plan(RICH_SEND, &ex).unwrap();
239    }
240
241    #[test]
242    fn floats_become_decimal_strings() {
243        // Synapse refuses floats (M_BAD_JSON): the tools send them as strings.
244        let p = json!({"v": 1, "blocks": [{"type": "ticker", "symbol": "PHP/THB",
245            "price": 0.53396, "change": 0.0038, "series": [0.53, 1.0]}]});
246        for args in [
247            json!({"payload": p, "fallback": "x"}),
248            json!({"payload": p.to_string(), "fallback": "x"}),
249        ] {
250            let c = plan(RICH_SEND, &args).unwrap();
251            let v = serde_json::to_value(&c.payload).unwrap();
252            assert!(!mxblocks::has_float(&v), "{v}");
253            assert_eq!(v["blocks"][0]["price"], "0.53396");
254            assert_eq!(v["blocks"][0]["series"], json!(["0.53", 1]));
255        }
256        let big = json!({"v": 1, "blocks": [{"type": "ticker", "symbol": "X",
257            "price": 9_007_199_254_740_993_u64}]});
258        let e = plan(RICH_SEND, &json!({"payload": big, "fallback": "x"})).unwrap_err();
259        assert!(e.contains("blocks[0].price"), "{e}");
260    }
261
262    #[test]
263    fn calls() {
264        let p = json!({"v": 1, "blocks": [{"type": "table", "columns": ["a"], "rows": [["1"]]}]});
265        let c = plan(RICH_SEND, &json!({"payload": p, "fallback": " a: 1 "})).unwrap();
266        assert_eq!(c.fallback, "a: 1");
267        assert_eq!((c.reply_to, c.edit, c.room), (None, None, None));
268        // A JSON string payload works too.
269        let c = plan(
270            RICH_SEND,
271            &json!({"payload": p.to_string(), "fallback": "x", "reply_to": "$r", "room": "dm"}),
272        )
273        .unwrap();
274        assert_eq!(c.reply_to.as_deref(), Some("$r"));
275        assert_eq!(c.room.as_deref(), Some("dm"));
276        let c = plan(
277            RICH_SEND,
278            &json!({"payload": p.to_string(), "fallback": "x", "thread": "$t"}),
279        )
280        .unwrap();
281        assert_eq!(c.thread.as_deref(), Some("$t"));
282        assert_eq!(c.room, None);
283        let c = plan(
284            RICH_EDIT,
285            &json!({"event_id": "$e", "payload": p, "fallback": "x"}),
286        )
287        .unwrap();
288        assert_eq!(c.edit.as_deref(), Some("$e"));
289        // Math needs no fallback.
290        let m = json!({"v": 1, "blocks": [{"type": "math", "tex": "x^2"}]});
291        assert!(plan(RICH_SEND, &json!({"payload": m})).is_ok());
292
293        for (name, args, why) in [
294            (RICH_SEND, json!({"fallback": "x"}), "payload"),
295            (RICH_SEND, json!({"payload": p}), "fallback"),
296            (
297                RICH_SEND,
298                json!({"payload": p, "fallback": "x", "event_id": "$e"}),
299                "unknown",
300            ),
301            (
302                RICH_EDIT,
303                json!({"payload": p, "fallback": "x"}),
304                "event_id",
305            ),
306            (
307                RICH_EDIT,
308                json!({"event_id": "e", "payload": p, "fallback": "x"}),
309                "event id",
310            ),
311            (
312                RICH_SEND,
313                json!({"payload": p, "fallback": "x", "reply_to": "$ x"}),
314                "event id",
315            ),
316            (
317                RICH_SEND,
318                json!({"payload": p, "fallback": "x", "room": "a\nb"}),
319                "room",
320            ),
321            (
322                RICH_SEND,
323                json!({"payload": {"v": 1, "blocks": [{"type": "weather"}]}, "fallback": "x"}),
324                "weather",
325            ),
326            (
327                RICH_SEND,
328                json!({"payload": p, "fallback": "x".repeat(MAX_FALLBACK + 1)}),
329                "at most",
330            ),
331            ("rich_other", json!({}), "no tool"),
332        ] {
333            let e = plan(name, &args).unwrap_err();
334            assert!(e.contains(why), "{why}: {e}");
335        }
336        assert!(plan(RICH_SEND, &json!([1])).is_err());
337    }
338}