Skip to main content

ag_ui/
metadata.rs

1//! Metadata: extra information attached to events, messages, tool calls and
2//! resume entries.
3//!
4//! Token usage, a trace id, a finish reason — anything an application needs to
5//! carry alongside the conversation goes in `metadata`, an object that is open
6//! by key. Before it existed producers hung undeclared properties off events
7//! and hoped consumers passed them through; metadata is the declared, typed
8//! replacement, and consumers are required to carry it.
9//!
10//! # Where it lives
11//!
12//! Four places, all optional:
13//!
14//! - every event, declared once on [`BaseEvent`](crate::event::BaseEvent) so
15//!   all 36 types have it;
16//! - every message, all seven roles;
17//! - every [`ToolCall`](crate::tool::ToolCall) — a tool call is not a message,
18//!   and several calls can share one parent, so each carries its own;
19//! - every [`ResumeEntry`](crate::outcome::ResumeEntry), for envelope data
20//!   about an answer — signatures, routing keys — as opposed to the answer.
21//!
22//! # Shape
23//!
24//! Any JSON value is allowed under a key, `null` included. The object itself
25//! is **absent or an object, never `null`**: an optional field with no value
26//! is omitted from the JSON entirely, in every official SDK, and this crate
27//! rejects `"metadata": null` at parse time rather than reading it as absent.
28//! Unlike some older optional fields, metadata has no legacy producers to
29//! tolerate — the crate-private `serde_util::reject_null` is the rule. An
30//! empty object is valid and means the same as omitting it.
31//!
32//! The [`AGUI_METADATA_KEY`] (`"ag-ui"`) is reserved for the protocol's own
33//! use. Every other key is yours. Nothing rejects a write to it at runtime —
34//! that would contradict open-by-key — but treat it as off limits.
35//!
36//! # Merging into messages
37//!
38//! A message is assembled from a sequence of events, and the interesting
39//! values are only known at the end: a provider does not know its token usage
40//! until it has finished generating. So a consumer merges each event's
41//! metadata into the message that event builds, as the sequence arrives, with
42//! [`merge_metadata`]: last write wins, key by key, and a nested object or
43//! array is replaced whole rather than blended. The client's applier does
44//! this for the text, tool-call, activity and reasoning-message families; an
45//! event that builds no message — `RUN_*`, `STEP_*`, `STATE_*`, `RAW`,
46//! `CUSTOM`, `REASONING_START`/`END`/`ENCRYPTED_VALUE`, `MESSAGES_SNAPSHOT`,
47//! `SUBAGENT_*` — keeps its metadata to itself.
48//!
49//! ```
50//! use ag_ui::{JsonObject, merge_metadata};
51//! use serde_json::json;
52//!
53//! let start: JsonObject = json!({ "source": "openai", "stage": "start" })
54//!     .as_object().unwrap().clone();
55//! let end: JsonObject = json!({ "stage": "end", "usage": { "output": 340 } })
56//!     .as_object().unwrap().clone();
57//!
58//! let merged = merge_metadata(Some(&start), Some(&end)).unwrap();
59//! assert_eq!(merged["source"], "openai");         // nothing later set it
60//! assert_eq!(merged["stage"], "end");             // last write wins
61//! assert_eq!(merged["usage"]["output"], 340);     // arrived only at the end
62//! ```
63
64use crate::JsonObject;
65
66/// The key reserved for the protocol's own use inside a metadata object.
67///
68/// Reserved by convention: metadata is open by key, so nothing rejects a
69/// write to it. AG-UI may put its own values under it in future versions,
70/// which is why an application should not.
71pub const AGUI_METADATA_KEY: &str = "ag-ui";
72
73/// Folds `incoming` into `existing`, key by key, with the last write winning.
74///
75/// Returns a new object rather than mutating either argument. An absent
76/// `incoming` returns `existing` unchanged (cloned); an empty `incoming`
77/// changes nothing. A key's value is replaced outright — this never recurses,
78/// so an object or array under any key, [`AGUI_METADATA_KEY`] included, is
79/// replaced wholesale rather than blended with what was there before. To add
80/// to a nested structure, send the complete new value.
81pub fn merge_metadata(
82    existing: Option<&JsonObject>,
83    incoming: Option<&JsonObject>,
84) -> Option<JsonObject> {
85    let mut merged = existing.cloned();
86    merge_metadata_into(&mut merged, incoming);
87    merged
88}
89
90/// The in-place form of [`merge_metadata`], for a consumer folding a stream
91/// of events into one message without cloning the accumulated object on every
92/// delta.
93pub fn merge_metadata_into(target: &mut Option<JsonObject>, incoming: Option<&JsonObject>) {
94    let Some(incoming) = incoming else {
95        return;
96    };
97    let target = target.get_or_insert_with(JsonObject::new);
98    for (key, value) in incoming {
99        target.insert(key.clone(), value.clone());
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use serde_json::json;
107
108    fn object(value: serde_json::Value) -> JsonObject {
109        value.as_object().expect("an object literal").clone()
110    }
111
112    #[test]
113    fn absent_incoming_leaves_existing_alone_and_absent_existing_takes_incoming() {
114        let existing = object(json!({ "a": 1 }));
115        assert_eq!(
116            merge_metadata(Some(&existing), None),
117            Some(existing.clone())
118        );
119        assert_eq!(merge_metadata(None, Some(&existing)), Some(existing));
120        assert_eq!(merge_metadata(None, None), None);
121    }
122
123    #[test]
124    fn last_write_wins_and_nested_values_are_replaced_not_blended() {
125        let existing = object(json!({ "tags": ["a", "b"], "keep": true, "ag-ui": { "x": 1 } }));
126        let incoming = object(json!({ "tags": ["z"], "ag-ui": { "y": 2 }, "added": null }));
127        let merged = merge_metadata(Some(&existing), Some(&incoming)).unwrap();
128        assert_eq!(merged["tags"], json!(["z"]));
129        assert_eq!(merged["keep"], json!(true));
130        assert_eq!(merged["ag-ui"], json!({ "y": 2 }));
131        // A null *value* under a key is data, and survives.
132        assert!(merged.contains_key("added"));
133        assert_eq!(merged["added"], json!(null));
134    }
135
136    #[test]
137    fn an_empty_incoming_object_creates_an_empty_target_but_changes_nothing_else() {
138        let mut target = None;
139        merge_metadata_into(&mut target, Some(&JsonObject::new()));
140        assert_eq!(target, Some(JsonObject::new()));
141
142        let mut target = Some(object(json!({ "a": 1 })));
143        merge_metadata_into(&mut target, Some(&JsonObject::new()));
144        assert_eq!(target, Some(object(json!({ "a": 1 }))));
145    }
146}