Skip to main content

rig_core/providers/internal/
tool_call_bridge.rs

1//! Index → grammar-identity bridging for streamed tool calls.
2//!
3//! Several wires key tool-call fragments by a numeric index (the chat-compat
4//! chunk index, Bedrock's `contentBlockIndex`) rather than by the grammar id
5//! the shared accumulator assembles under. Every adapter on such a wire needs
6//! the same bridge: a per-stream map from the wire's index to the identity the
7//! adapter established for that call. [`ToolCallBridge`] is that map, shared
8//! so the mandatory-identity invariant on
9//! [`RawStreamingChoice::ToolCallDelta`](crate::streaming::RawStreamingChoice)
10//! is enforced in exactly one place: when the wire supplies no id, the slot's
11//! grammar id is a `StreamPartId::Minted` from the bridge's [`SyntheticIds`]
12//! counter, so parallel id-less calls can never share an assembly key
13//! downstream — and a minted id structurally cannot serialize upstream as a
14//! wire-genuine one.
15//!
16//! Only the *bridging state* lives here. Argument assembly, internal-id
17//! minting for finalized calls, and finalize policy stay in the shared
18//! accumulator (`PartsAccumulator`); frame triage stays in the driver.
19
20use std::collections::HashMap;
21use std::hash::Hash;
22
23use crate::streaming::{
24    StreamPartId, SyntheticIds, ToolCallDecoration, ToolInputEnd, UnparseableToolInput,
25};
26
27/// Wire identity of a tool call whose input is streaming, as tracked by an
28/// adapter. The slot keeps only what the wire keys by — an index — mapped to
29/// the identity the adapter established for that call.
30#[derive(Debug, Clone)]
31pub struct ToolCallSlot {
32    /// Assembly id: the id under which this call's fragments are emitted.
33    /// Fixed at open: the first-seen provider id, or a minted identity when
34    /// the wire omits one — so parallel id-less calls can never share an
35    /// assembly key downstream.
36    key: StreamPartId,
37    /// Established provider id: updated when a later chunk carries one.
38    /// Empty until the wire supplies one.
39    pub id: String,
40    /// Established tool name: the last non-empty value seen.
41    pub name: String,
42    /// Provider-specific decoration carried onto the call's end event.
43    pub signature: Option<String>,
44    /// Provider-specific decoration carried onto the call's end event.
45    pub additional_params: Option<serde_json::Value>,
46    /// Whether any raw argument fragment streamed for this slot. The done
47    /// item's unparseable restatement re-emits its raw bytes only when NO
48    /// fragment preceded it — the buffer already holds streamed bytes, and
49    /// re-emitting the restatement doubled them.
50    pub saw_arguments_delta: bool,
51    /// Whether any argument fragment carried a non-whitespace byte. An empty
52    /// argument slot under an output-length finish reason is an incomplete
53    /// call, not evidence that the model deliberately invoked a zero-argument
54    /// tool.
55    saw_non_whitespace_arguments_delta: bool,
56    /// The payload the wire announced when it opened the call (Gemini
57    /// Interactions `step.start` usually carries `"arguments": {}`, and
58    /// sometimes the whole payload). Replace-if-no-deltas, never
59    /// concatenated: fragments, when they arrive, ARE the arguments and
60    /// the announce is ignored; only a slot that fragmented nothing falls
61    /// back to what it announced.
62    pub announce_arguments: Option<serde_json::Value>,
63}
64
65impl ToolCallSlot {
66    /// The assembly id this call's fragments are emitted under.
67    pub fn key(&self) -> &StreamPartId {
68        &self.key
69    }
70
71    /// Record a raw argument fragment before it is forwarded to the shared
72    /// accumulator.
73    pub fn observe_arguments_delta(&mut self, arguments: &str) {
74        self.saw_arguments_delta = true;
75        self.saw_non_whitespace_arguments_delta |= !arguments.trim().is_empty();
76    }
77
78    /// Whether the wire supplied enough argument bytes to distinguish this
79    /// slot from a call cut off before its first argument token.
80    pub fn has_substantive_arguments(&self) -> bool {
81        self.saw_non_whitespace_arguments_delta || self.announce_arguments.is_some()
82    }
83
84    /// Build the end event that closes this call's assembly in the shared
85    /// accumulator, carrying the established provider id and any decoration.
86    pub fn end_event(&self, on_unparseable: UnparseableToolInput) -> ToolInputEnd {
87        let mut end = ToolInputEnd::new(self.key.clone(), on_unparseable);
88        // Only an established provider id overrides the assembly key; a
89        // call whose wire never supplied one carries no durable handle at
90        // all (`WireId::new` rejects the empty string by construction).
91        end.tool_id = crate::streaming::WireId::new(self.id.clone());
92        end.signature = self.signature.clone();
93        end.additional_params = self.additional_params.clone();
94        if !self.saw_arguments_delta {
95            end.arguments = self.announce_arguments.clone();
96        }
97        end
98    }
99}
100
101/// Per-stream index → grammar-identity map for streamed tool calls.
102///
103/// `I` is the wire's own index type (`usize` for chat-compat chunk indices,
104/// `i32` for Bedrock content-block indices); it must display so a minted id
105/// can derive from it, and order so a drain preserves wire ordering.
106#[derive(Debug)]
107pub struct ToolCallBridge<I> {
108    slots: HashMap<I, ToolCallSlot>,
109    /// Minter for slot identities on id-less wires. Defaults to the tool
110    /// kind (chat-compat, bedrock); the Responses adapter uses the output
111    /// kind so its tool mints share its reasoning mints' id space on the
112    /// same wire.
113    minted: SyntheticIds,
114}
115
116impl<I> Default for ToolCallBridge<I>
117where
118    I: Eq + Hash + Ord + Copy,
119{
120    fn default() -> Self {
121        Self::new()
122    }
123}
124
125impl<I> ToolCallBridge<I>
126where
127    I: Eq + Hash + Ord + Copy,
128{
129    pub fn new() -> Self {
130        Self {
131            slots: HashMap::new(),
132            minted: SyntheticIds::tool(),
133        }
134    }
135
136    /// A bridge minting slot identities in the given reserved namespace.
137    pub fn with_minted_namespace(minted: SyntheticIds) -> Self {
138        Self {
139            slots: HashMap::new(),
140            minted,
141        }
142    }
143
144    /// Open (or update) the slot for a wire index, establishing its identity.
145    ///
146    /// On first sight the assembly key is fixed: the wire id when one is
147    /// supplied, else a freshly minted identity — the single enforcement
148    /// point of the mandatory-identity invariant. Later fragments update the
149    /// established provider id and name from any non-empty values they
150    /// carry.
151    pub fn open(
152        &mut self,
153        index: I,
154        wire_id: Option<&str>,
155        name: Option<&str>,
156    ) -> &mut ToolCallSlot {
157        let minted = &mut self.minted;
158        let slot = self.slots.entry(index).or_insert_with(|| ToolCallSlot {
159            key: match wire_id {
160                Some(id) if !id.is_empty() => StreamPartId::wire(id),
161                // Id-less wires (several llama.cpp/vllm-style gateways) key
162                // tool calls by index alone; the slot identity is minted so
163                // it can never collide with a wire-genuine id — and can
164                // never serialize upstream.
165                _ => minted.mint(),
166            },
167            id: String::new(),
168            name: String::new(),
169            signature: None,
170            additional_params: None,
171            saw_arguments_delta: false,
172            saw_non_whitespace_arguments_delta: false,
173            announce_arguments: None,
174        });
175
176        if let Some(id) = wire_id
177            && !id.is_empty()
178        {
179            slot.id = id.to_owned();
180        }
181
182        if let Some(name) = name
183            && !name.is_empty()
184        {
185            slot.name = name.to_owned();
186        }
187
188        slot
189    }
190
191    /// The open slot at a wire index, if any.
192    pub fn get(&self, index: I) -> Option<&ToolCallSlot> {
193        self.slots.get(&index)
194    }
195
196    /// The open slot at a wire index, mutably — for fragment bookkeeping
197    /// on an already-open slot without `open`'s insert-if-absent.
198    pub fn get_mut(&mut self, index: I) -> Option<&mut ToolCallSlot> {
199        self.slots.get_mut(&index)
200    }
201
202    /// The bridge's identity minter, for adapters that also mint
203    /// *whole-call* identities: assemblies and whole calls must draw from
204    /// ONE counter so their minted keys stay disjoint.
205    pub fn minted_ids(&mut self) -> &mut SyntheticIds {
206        &mut self.minted
207    }
208
209    /// Close and take the slot at a wire index, if any.
210    pub fn remove(&mut self, index: I) -> Option<ToolCallSlot> {
211        self.slots.remove(&index)
212    }
213
214    /// Evict the slot at a wire index when the predicate says the incoming
215    /// fragment belongs to a *different* call reusing the same index (the
216    /// per-profile eviction semantics — e.g. a distinct id + name pair on a
217    /// wire that restarts indices per call). Returns the evicted slot so the
218    /// caller can flush it to the consumer.
219    pub fn evict_if(
220        &mut self,
221        index: I,
222        should_evict: impl FnOnce(&ToolCallSlot) -> bool,
223    ) -> Option<ToolCallSlot> {
224        if self.slots.get(&index).is_some_and(should_evict) {
225            return self.slots.remove(&index);
226        }
227        None
228    }
229
230    /// Apply a provider decoration to the in-flight call it names, matched by
231    /// the established provider id. Decorations ride the slot onto its end
232    /// event; assembly itself is untouched.
233    ///
234    /// Two matching rules keep this deterministic:
235    /// - A slot whose wire never established a provider id (empty `id`) never
236    ///   matches — a decoration for the empty string would otherwise pick an
237    ///   arbitrary id-less slot out of `HashMap` iteration order.
238    /// - Each field is **first-wins**: a later decoration for the same call
239    ///   fills only the fields still unset, so a gemini-style
240    ///   signature-then-params sequence composes instead of the second
241    ///   decoration clobbering the first's signature with `None`.
242    pub fn decorate(&mut self, decoration: ToolCallDecoration) {
243        if decoration.tool_id.is_empty() {
244            return;
245        }
246        if let Some(slot) = self
247            .slots
248            .values_mut()
249            .find(|slot| slot.id == decoration.tool_id)
250        {
251            if slot.signature.is_none() {
252                slot.signature = decoration.signature;
253            }
254            if slot.additional_params.is_none() {
255                slot.additional_params = decoration.additional_params;
256            }
257        }
258    }
259
260    /// Whether any call is still open.
261    pub fn is_empty(&self) -> bool {
262        self.slots.is_empty()
263    }
264
265    /// Drain every open slot in wire-index order, so a multi-call turn keeps
266    /// its wire ordering when flushed. The caller chooses the unparseable
267    /// policy per flush site when building end events.
268    pub fn drain_ordered(&mut self) -> Vec<ToolCallSlot> {
269        self.drain_ordered_indexed()
270            .into_iter()
271            .map(|(_, slot)| slot)
272            .collect()
273    }
274
275    /// [`ToolCallBridge::drain_ordered`], keeping each slot's wire index —
276    /// for adapters that track per-slot state of their own beside the
277    /// bridge (the Responses adapter's pending `call_id`s).
278    pub fn drain_ordered_indexed(&mut self) -> Vec<(I, ToolCallSlot)> {
279        let mut slots: Vec<(I, ToolCallSlot)> = self.slots.drain().collect();
280        slots.sort_by_key(|(index, _)| *index);
281        slots
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn wire_id_becomes_the_assembly_key() {
291        let mut bridge = ToolCallBridge::<usize>::new();
292        let slot = bridge.open(0, Some("call_abc"), Some("get_weather"));
293        assert_eq!(slot.key(), &StreamPartId::wire("call_abc"));
294        assert_eq!(slot.id, "call_abc");
295        assert_eq!(slot.name, "get_weather");
296
297        // The established id rides the end event as the override.
298        let end = slot.end_event(UnparseableToolInput::Drop);
299        assert_eq!(end.id, StreamPartId::wire("call_abc"));
300        assert_eq!(end.tool_id.as_ref().map(|id| id.as_str()), Some("call_abc"));
301    }
302
303    #[test]
304    fn id_less_open_mints_a_distinct_minted_key_per_index() {
305        let mut bridge = ToolCallBridge::<usize>::new();
306        let first_key = bridge.open(0, None, Some("get_weather")).key().clone();
307        let second_key = bridge.open(1, None, Some("get_time")).key().clone();
308
309        // Parallel id-less calls must never share an assembly key, and a
310        // minted key is minted — structurally unable to serialize upstream
311        // as wire-genuine.
312        assert_ne!(first_key, second_key);
313        assert!(first_key.is_minted());
314        assert!(second_key.is_minted());
315
316        // A call whose wire never supplied an id keeps its minted key with
317        // no provider-id override.
318        let slot = bridge.remove(0).expect("slot must be open");
319        let end = slot.end_event(UnparseableToolInput::Drop);
320        assert_eq!(end.id, first_key);
321        assert!(end.tool_id.is_none());
322    }
323
324    #[test]
325    fn late_wire_id_updates_the_override_but_not_the_key() {
326        let mut bridge = ToolCallBridge::<usize>::new();
327        bridge.open(0, None, Some("get_weather"));
328        let slot = bridge.open(0, Some("call_late"), None);
329        // The assembly key is fixed at open; the late provider id becomes
330        // the end-event override the accumulator surfaces to the consumer.
331        assert!(slot.key().is_minted());
332        assert_eq!(slot.id, "call_late");
333        assert_eq!(slot.name, "get_weather");
334    }
335
336    #[test]
337    fn evict_if_takes_the_slot_only_when_the_predicate_says_so() {
338        let mut bridge = ToolCallBridge::<usize>::new();
339        bridge.open(0, Some("call_a"), Some("get_weather"));
340
341        assert!(bridge.evict_if(0, |slot| slot.id == "call_b").is_none());
342        assert!(bridge.get(0).is_some(), "a refused eviction keeps the slot");
343
344        let evicted = bridge
345            .evict_if(0, |slot| slot.id == "call_a")
346            .expect("predicate matched: slot must be evicted");
347        assert_eq!(evicted.key(), &StreamPartId::wire("call_a"));
348        assert!(bridge.get(0).is_none());
349    }
350
351    #[test]
352    fn decoration_matches_by_established_provider_id_and_rides_the_end_event() {
353        let mut bridge = ToolCallBridge::<usize>::new();
354        bridge.open(0, Some("call_a"), Some("get_weather"));
355        bridge.open(1, Some("call_b"), Some("get_time"));
356
357        bridge.decorate(ToolCallDecoration {
358            tool_id: "call_b".to_owned(),
359            signature: Some("sig-b".to_owned()),
360            additional_params: Some(serde_json::json!({"k": "v"})),
361        });
362
363        let undecorated = bridge.remove(0).expect("slot 0 open");
364        let end = undecorated.end_event(UnparseableToolInput::Drop);
365        assert!(end.signature.is_none());
366
367        let decorated = bridge.remove(1).expect("slot 1 open");
368        let end = decorated.end_event(UnparseableToolInput::Drop);
369        assert_eq!(end.signature.as_deref(), Some("sig-b"));
370        assert_eq!(end.additional_params, Some(serde_json::json!({"k": "v"})));
371    }
372
373    /// An empty-id decoration must never match: id-less slots keep an empty
374    /// established id, and matching `""` would decorate an arbitrary one of
375    /// them in `HashMap` iteration order.
376    #[test]
377    fn an_empty_id_decoration_never_matches_an_id_less_slot() {
378        let mut bridge = ToolCallBridge::<usize>::new();
379        bridge.open(0, None, Some("get_weather"));
380        bridge.open(1, None, Some("get_time"));
381
382        bridge.decorate(ToolCallDecoration {
383            tool_id: String::new(),
384            signature: Some("sig".to_owned()),
385            additional_params: None,
386        });
387
388        for index in [0, 1] {
389            let slot = bridge.remove(index).expect("slot open");
390            assert!(
391                slot.signature.is_none(),
392                "an empty-id decoration must not land on slot {index}"
393            );
394        }
395    }
396
397    /// Decoration fields are first-wins: a gemini-style signature-then-params
398    /// sequence composes, and a later decoration cannot clobber an earlier
399    /// signature with `None`.
400    #[test]
401    fn decoration_fields_are_first_wins_per_field() {
402        let mut bridge = ToolCallBridge::<usize>::new();
403        bridge.open(0, Some("call_a"), Some("get_weather"));
404
405        bridge.decorate(ToolCallDecoration {
406            tool_id: "call_a".to_owned(),
407            signature: Some("sig-1".to_owned()),
408            additional_params: None,
409        });
410        bridge.decorate(ToolCallDecoration {
411            tool_id: "call_a".to_owned(),
412            signature: None,
413            additional_params: Some(serde_json::json!({"thought": true})),
414        });
415        // A third decoration cannot overwrite either established field.
416        bridge.decorate(ToolCallDecoration {
417            tool_id: "call_a".to_owned(),
418            signature: Some("sig-2".to_owned()),
419            additional_params: Some(serde_json::json!({"other": 1})),
420        });
421
422        let slot = bridge.remove(0).expect("slot open");
423        assert_eq!(slot.signature.as_deref(), Some("sig-1"));
424        assert_eq!(
425            slot.additional_params,
426            Some(serde_json::json!({"thought": true}))
427        );
428    }
429
430    #[test]
431    fn drain_ordered_preserves_wire_index_order() {
432        let mut bridge = ToolCallBridge::<i32>::new();
433        bridge.open(2, Some("call_c"), None);
434        bridge.open(0, Some("call_a"), None);
435        bridge.open(1, Some("call_b"), None);
436
437        let keys: Vec<StreamPartId> = bridge
438            .drain_ordered()
439            .into_iter()
440            .map(|slot| slot.key().clone())
441            .collect();
442        assert_eq!(
443            keys,
444            vec![
445                StreamPartId::wire("call_a"),
446                StreamPartId::wire("call_b"),
447                StreamPartId::wire("call_c")
448            ]
449        );
450        assert!(bridge.is_empty());
451    }
452}