agent_block_mcp/lua_json.rs
1//! Lua ↔ JSON value bridge.
2//!
3//! Moved from `src/bridge/mod.rs` during the 4-crate split so that the MCP
4//! handler can reach these conversions without depending on `agent-block-core`.
5//! `agent-block-core::bridge` re-exports them for the other bridge modules
6//! (llm / mesh / mcp.lua), preserving the historical `crate::bridge::*` API.
7//!
8//! # The empty table, and how a value says which kind it is
9//!
10//! Lua has one table type, so `{}` is an empty array and an empty object
11//! at once and nothing in the value itself tells the two apart. An
12//! untagged empty table is read here as an object, which is what this
13//! bridge has always done — but a producer that means the other one has a
14//! way to say so, because a boundary that cannot express `[]` pushes the
15//! shapes that need one into inventing content that was never there.
16//!
17//! A table declares itself an array by carrying a metatable that says so:
18//!
19//! * `__jsontype = "array"`, the tag Lua writes for itself
20//! (`setmetatable({}, { __jsontype = "array" })`) and the convention the
21//! Lua JSON libraries already use, or
22//! * mlua's own array metatable, so values its serde bridge produced are
23//! read the same way rather than by a second rule.
24//!
25//! A tagged table is encoded from its sequence part, which is mlua's rule
26//! for the same tag. [`json_to_lua`] tags the empty arrays it builds, so
27//! `[]` comes back as `[]` instead of turning into `{}` on the way home;
28//! a non-empty array needs no tag — a sequence is already unambiguous —
29//! and is left as it was, so no existing value changes shape.
30
31use mlua::prelude::*;
32
33/// Metatable field a table carries to declare itself a JSON array.
34pub const ARRAY_METAFIELD: &str = "__jsontype";
35/// The value of [`ARRAY_METAFIELD`] that means "array".
36pub const ARRAY_METAFIELD_VALUE: &str = "array";
37/// Where the shared array metatable is parked, once per Lua state.
38const ARRAY_METATABLE_REGISTRY_KEY: &str = "agent_block.lua_json.array_metatable";
39
40/// The metatable that marks a Lua table as a JSON array.
41///
42/// One table per Lua state, so every empty array [`json_to_lua`] builds
43/// shares it and a caller can compare against it. Plain (`__metatable` is
44/// not set): the tag is a fact about the value, not a lock on it, and Lua
45/// code that wants to give the table a metatable of its own still can.
46pub fn array_metatable(lua: &Lua) -> LuaResult<LuaTable> {
47 if let Ok(LuaValue::Table(existing)) =
48 lua.named_registry_value::<LuaValue>(ARRAY_METATABLE_REGISTRY_KEY)
49 {
50 return Ok(existing);
51 }
52 let mt = lua.create_table()?;
53 mt.raw_set(ARRAY_METAFIELD, ARRAY_METAFIELD_VALUE)?;
54 lua.set_named_registry_value(ARRAY_METATABLE_REGISTRY_KEY, &mt)?;
55 Ok(mt)
56}
57
58/// Whether `table` declares itself an array.
59///
60/// Read raw: the tag is a plain field of the metatable, and an `__index`
61/// on it is the table's own business rather than a source of array-ness.
62fn is_tagged_array(lua: &Lua, table: &LuaTable) -> LuaResult<bool> {
63 let Some(mt) = table.metatable() else {
64 return Ok(false);
65 };
66 if let LuaValue::String(kind) = mt.raw_get::<LuaValue>(ARRAY_METAFIELD)? {
67 if &*kind.to_str()? == ARRAY_METAFIELD_VALUE {
68 return Ok(true);
69 }
70 }
71 // The same question asked of a value mlua's serde bridge built.
72 Ok(mt == lua.array_metatable())
73}
74
75/// Convert a Lua value to a serde_json::Value.
76///
77/// Round-trips with `json_to_lua` and `std.json.encode` (mlua-batteries).
78/// Lua `nil` maps to JSON `null`. Unsupported types (functions, userdata
79/// other than `null`) yield an error so that callers do not silently emit
80/// malformed JSON.
81///
82/// An empty table becomes `{}` unless it is tagged as an array (see the
83/// module docs), in which case it becomes `[]`.
84pub fn lua_to_json(lua: &Lua, val: LuaValue) -> LuaResult<serde_json::Value> {
85 lua_to_json_inner(lua, &val, 0)
86}
87
88fn lua_to_json_inner(lua: &Lua, val: &LuaValue, depth: usize) -> LuaResult<serde_json::Value> {
89 const MAX_DEPTH: usize = 128;
90 if depth > MAX_DEPTH {
91 return Err(LuaError::external(format!(
92 "Lua table nesting too deep for JSON (limit: {MAX_DEPTH})"
93 )));
94 }
95 match val {
96 LuaValue::Nil => Ok(serde_json::Value::Null),
97 // mlua serde uses LightUserData(null_ptr) for JSON null. Treat it
98 // the same as Nil so values produced by `json_to_lua` round-trip.
99 LuaValue::LightUserData(u) if u.0.is_null() => Ok(serde_json::Value::Null),
100 LuaValue::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
101 LuaValue::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
102 LuaValue::Number(n) => serde_json::Number::from_f64(*n)
103 .map(serde_json::Value::Number)
104 .ok_or_else(|| LuaError::external(format!("cannot convert {n} to JSON number"))),
105 LuaValue::String(s) => Ok(serde_json::Value::String(s.to_str()?.to_string())),
106 LuaValue::Table(t) => {
107 let len = t.raw_len();
108 if len > 0 || is_tagged_array(lua, t)? {
109 let mut arr = Vec::with_capacity(len);
110 for i in 1..=len {
111 let v: LuaValue = t.raw_get(i)?;
112 arr.push(lua_to_json_inner(lua, &v, depth + 1)?);
113 }
114 Ok(serde_json::Value::Array(arr))
115 } else {
116 let mut map = serde_json::Map::new();
117 for pair in t.clone().pairs::<LuaValue, LuaValue>() {
118 let (k, v) = pair?;
119 let key = match k {
120 LuaValue::String(s) => s.to_str()?.to_string(),
121 LuaValue::Integer(i) => i.to_string(),
122 LuaValue::Number(n) => n.to_string(),
123 other => {
124 return Err(LuaError::external(format!(
125 "unsupported table key type for JSON: {}",
126 other.type_name()
127 )));
128 }
129 };
130 map.insert(key, lua_to_json_inner(lua, &v, depth + 1)?);
131 }
132 Ok(serde_json::Value::Object(map))
133 }
134 }
135 other => Err(LuaError::external(format!(
136 "unsupported type for JSON conversion: {}",
137 other.type_name()
138 ))),
139 }
140}
141
142/// Convert a serde_json::Value to a Lua value.
143///
144/// JSON `null` maps to the `LightUserData(null_ptr)` sentinel
145/// (`mlua::Value::NULL`), which is the same representation `lua_to_json`
146/// accepts on the way out — so the round-trip is symmetric. Using the
147/// sentinel rather than Lua `nil` means JSON `null` values survive being
148/// placed into Lua tables (tables cannot hold `nil`), so SQL NULL columns
149/// and MCP/LLM JSON payloads do not lose the distinction between "null"
150/// and "absent". Agents can compare a value against the exposed
151/// `std.sql.null` constant to detect it.
152///
153/// Note: this differs from mlua-batteries' `std.json.decode`, which keeps
154/// the Lua-idiomatic "null → nil" lowering for `json.decode` itself. Our
155/// bridge paths (sql / kv / mcp / mesh / llm) prefer round-trip fidelity.
156///
157/// An empty array comes back tagged (see the module docs) so that it is
158/// still an array on the way back through [`lua_to_json`]; every other
159/// value is built exactly as it was before the tag existed.
160pub fn json_to_lua(lua: &Lua, val: serde_json::Value) -> LuaResult<LuaValue> {
161 json_to_lua_inner(lua, &val, 0)
162}
163
164fn json_to_lua_inner(lua: &Lua, val: &serde_json::Value, depth: usize) -> LuaResult<LuaValue> {
165 const MAX_DEPTH: usize = 128;
166 if depth > MAX_DEPTH {
167 return Err(LuaError::external(format!(
168 "JSON nesting too deep (limit: {MAX_DEPTH})"
169 )));
170 }
171 match val {
172 serde_json::Value::Null => Ok(LuaValue::NULL),
173 serde_json::Value::Bool(b) => Ok(LuaValue::Boolean(*b)),
174 serde_json::Value::Number(n) => {
175 if let Some(i) = n.as_i64() {
176 Ok(LuaValue::Integer(i))
177 } else if let Some(f) = n.as_f64() {
178 Ok(LuaValue::Number(f))
179 } else {
180 Err(LuaError::external(format!(
181 "JSON number {n} is not representable as i64 or f64"
182 )))
183 }
184 }
185 serde_json::Value::String(s) => lua.create_string(s).map(LuaValue::String),
186 serde_json::Value::Array(arr) => {
187 let table = lua.create_table()?;
188 for (i, v) in arr.iter().enumerate() {
189 table.set(i + 1, json_to_lua_inner(lua, v, depth + 1)?)?;
190 }
191 // Only the empty one needs saying: a table with a sequence in
192 // it already reads as an array, and tagging it would change a
193 // value that was fine as it was.
194 if arr.is_empty() {
195 table.set_metatable(Some(array_metatable(lua)?))?;
196 }
197 Ok(LuaValue::Table(table))
198 }
199 serde_json::Value::Object(map) => {
200 let table = lua.create_table()?;
201 for (k, v) in map {
202 table.set(k.as_str(), json_to_lua_inner(lua, v, depth + 1)?)?;
203 }
204 Ok(LuaValue::Table(table))
205 }
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use serde_json::json;
213
214 /// `value` after a trip into Lua and back.
215 fn round_trip(lua: &Lua, value: serde_json::Value) -> serde_json::Value {
216 let in_lua = json_to_lua(lua, value.clone()).expect("into Lua");
217 lua_to_json(lua, in_lua).unwrap_or_else(|e| panic!("{value}: {e}"))
218 }
219
220 /// The value a Lua expression evaluates to, as JSON.
221 fn encode(lua: &Lua, chunk: &str) -> serde_json::Value {
222 let value: LuaValue = lua
223 .load(chunk)
224 .eval()
225 .unwrap_or_else(|e| panic!("{chunk}: {e}"));
226 lua_to_json(lua, value).unwrap_or_else(|e| panic!("{chunk}: {e}"))
227 }
228
229 /// The two empty shapes are the ones a single Lua table cannot tell
230 /// apart on its own, so they are what the tag is for: each comes back
231 /// as itself, including where they are nested inside one another.
232 #[test]
233 fn the_two_empty_shapes_survive_a_round_trip() {
234 let lua = Lua::new();
235 for value in [
236 json!([]),
237 json!({}),
238 json!({ "content": [], "usage": {} }),
239 json!([[], {}, [[]]]),
240 json!({ "a": { "b": [] }, "c": [{}] }),
241 json!({ "content": [{ "type": "text", "text": "hi" }] }),
242 ] {
243 assert_eq!(round_trip(&lua, value.clone()), value, "{value}");
244 }
245 }
246
247 /// The tag is the only thing that separates them: an untagged empty
248 /// table is still an object, which is what every caller that never
249 /// heard of the tag keeps getting.
250 #[test]
251 fn an_untagged_empty_table_is_still_an_object() {
252 let lua = Lua::new();
253 assert_eq!(encode(&lua, "return {}"), json!({}));
254 assert_eq!(encode(&lua, "return { a = {} }"), json!({ "a": {} }));
255 }
256
257 /// A table Lua tagged for itself is read as an array, and so is one
258 /// mlua's own serde bridge tagged — two ways of saying it, one answer.
259 #[test]
260 fn a_tagged_empty_table_is_an_array() {
261 let lua = Lua::new();
262 assert_eq!(
263 encode(&lua, r#"return setmetatable({}, { __jsontype = "array" })"#),
264 json!([])
265 );
266
267 lua.globals()
268 .set("mlua_array_mt", lua.array_metatable())
269 .expect("expose mlua's array metatable");
270 assert_eq!(
271 encode(&lua, "return setmetatable({}, mlua_array_mt)"),
272 json!([])
273 );
274
275 // A tag that says something else is not this tag.
276 assert_eq!(
277 encode(
278 &lua,
279 r#"return setmetatable({}, { __jsontype = "object" })"#
280 ),
281 json!({})
282 );
283 assert_eq!(
284 encode(&lua, r#"return setmetatable({}, { __index = {} })"#),
285 json!({})
286 );
287 }
288
289 /// The tag names the shape, it does not lock the table: what comes back
290 /// from JSON can still be given a metatable of its own, and reading its
291 /// metatable answers rather than refusing.
292 #[test]
293 fn the_tag_leaves_the_table_usable() {
294 let lua = Lua::new();
295 lua.globals()
296 .set("empty", json_to_lua(&lua, json!([])).expect("into Lua"))
297 .expect("set");
298 lua.load(
299 r#"
300 assert(#empty == 0, "a tagged empty array is still empty")
301 assert(next(empty) == nil, "the tag must not put a field in the table")
302 assert(getmetatable(empty).__jsontype == "array", "the tag must be readable")
303 assert(pcall(setmetatable, empty, {}), "the tag must not protect the table")
304 "#,
305 )
306 .exec()
307 .expect("tagged empty array chunk");
308 }
309
310 /// One metatable per state, so every empty array a run produces carries
311 /// the same tag and a caller can compare against it.
312 #[test]
313 fn every_empty_array_shares_one_metatable() {
314 let lua = Lua::new();
315 let first = json_to_lua(&lua, json!([])).expect("into Lua");
316 let second = json_to_lua(&lua, json!({ "a": [] })).expect("into Lua");
317 lua.globals().set("first", first).expect("set");
318 lua.globals().set("second", second).expect("set");
319 lua.load(
320 r#"
321 assert(getmetatable(first) == getmetatable(second.a), "two array tags in one state")
322 assert(getmetatable(first) ~= nil)
323 "#,
324 )
325 .exec()
326 .expect("shared metatable chunk");
327 }
328
329 /// Whatever the tag says, a table with a sequence in it is an array,
330 /// and a tagged table is encoded from that sequence — mlua's rule for
331 /// the same tag, so one table does not mean two things.
332 #[test]
333 fn a_sequence_is_an_array_tag_or_no_tag() {
334 let lua = Lua::new();
335 assert_eq!(encode(&lua, "return { 1, 2, 3 }"), json!([1, 2, 3]));
336 assert_eq!(
337 encode(
338 &lua,
339 r#"return setmetatable({ 1, 2 }, { __jsontype = "array" })"#
340 ),
341 json!([1, 2])
342 );
343 assert_eq!(
344 encode(
345 &lua,
346 r#"return setmetatable({ a = 5 }, { __jsontype = "array" })"#
347 ),
348 json!([]),
349 "a tagged table is its sequence part"
350 );
351 }
352}