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}