use std::collections::HashMap;
use std::hash::Hash;
use crate::streaming::{
StreamPartId, SyntheticIds, ToolCallDecoration, ToolInputEnd, UnparseableToolInput,
};
#[derive(Debug, Clone)]
pub struct ToolCallSlot {
key: StreamPartId,
pub id: String,
pub name: String,
pub signature: Option<String>,
pub additional_params: Option<serde_json::Value>,
pub saw_arguments_delta: bool,
saw_non_whitespace_arguments_delta: bool,
pub announce_arguments: Option<serde_json::Value>,
}
impl ToolCallSlot {
pub fn key(&self) -> &StreamPartId {
&self.key
}
pub fn observe_arguments_delta(&mut self, arguments: &str) {
self.saw_arguments_delta = true;
self.saw_non_whitespace_arguments_delta |= !arguments.trim().is_empty();
}
pub fn has_substantive_arguments(&self) -> bool {
self.saw_non_whitespace_arguments_delta || self.announce_arguments.is_some()
}
pub fn end_event(&self, on_unparseable: UnparseableToolInput) -> ToolInputEnd {
let mut end = ToolInputEnd::new(self.key.clone(), on_unparseable);
end.tool_id = crate::streaming::WireId::new(self.id.clone());
end.signature = self.signature.clone();
end.additional_params = self.additional_params.clone();
if !self.saw_arguments_delta {
end.arguments = self.announce_arguments.clone();
}
end
}
}
#[derive(Debug)]
pub struct ToolCallBridge<I> {
slots: HashMap<I, ToolCallSlot>,
minted: SyntheticIds,
}
impl<I> Default for ToolCallBridge<I>
where
I: Eq + Hash + Ord + Copy,
{
fn default() -> Self {
Self::new()
}
}
impl<I> ToolCallBridge<I>
where
I: Eq + Hash + Ord + Copy,
{
pub fn new() -> Self {
Self {
slots: HashMap::new(),
minted: SyntheticIds::tool(),
}
}
pub fn with_minted_namespace(minted: SyntheticIds) -> Self {
Self {
slots: HashMap::new(),
minted,
}
}
pub fn open(
&mut self,
index: I,
wire_id: Option<&str>,
name: Option<&str>,
) -> &mut ToolCallSlot {
let minted = &mut self.minted;
let slot = self.slots.entry(index).or_insert_with(|| ToolCallSlot {
key: match wire_id {
Some(id) if !id.is_empty() => StreamPartId::wire(id),
_ => minted.mint(),
},
id: String::new(),
name: String::new(),
signature: None,
additional_params: None,
saw_arguments_delta: false,
saw_non_whitespace_arguments_delta: false,
announce_arguments: None,
});
if let Some(id) = wire_id
&& !id.is_empty()
{
slot.id = id.to_owned();
}
if let Some(name) = name
&& !name.is_empty()
{
slot.name = name.to_owned();
}
slot
}
pub fn get(&self, index: I) -> Option<&ToolCallSlot> {
self.slots.get(&index)
}
pub fn get_mut(&mut self, index: I) -> Option<&mut ToolCallSlot> {
self.slots.get_mut(&index)
}
pub fn minted_ids(&mut self) -> &mut SyntheticIds {
&mut self.minted
}
pub fn remove(&mut self, index: I) -> Option<ToolCallSlot> {
self.slots.remove(&index)
}
pub fn evict_if(
&mut self,
index: I,
should_evict: impl FnOnce(&ToolCallSlot) -> bool,
) -> Option<ToolCallSlot> {
if self.slots.get(&index).is_some_and(should_evict) {
return self.slots.remove(&index);
}
None
}
pub fn decorate(&mut self, decoration: ToolCallDecoration) {
if decoration.tool_id.is_empty() {
return;
}
if let Some(slot) = self
.slots
.values_mut()
.find(|slot| slot.id == decoration.tool_id)
{
if slot.signature.is_none() {
slot.signature = decoration.signature;
}
if slot.additional_params.is_none() {
slot.additional_params = decoration.additional_params;
}
}
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
pub fn drain_ordered(&mut self) -> Vec<ToolCallSlot> {
self.drain_ordered_indexed()
.into_iter()
.map(|(_, slot)| slot)
.collect()
}
pub fn drain_ordered_indexed(&mut self) -> Vec<(I, ToolCallSlot)> {
let mut slots: Vec<(I, ToolCallSlot)> = self.slots.drain().collect();
slots.sort_by_key(|(index, _)| *index);
slots
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_id_becomes_the_assembly_key() {
let mut bridge = ToolCallBridge::<usize>::new();
let slot = bridge.open(0, Some("call_abc"), Some("get_weather"));
assert_eq!(slot.key(), &StreamPartId::wire("call_abc"));
assert_eq!(slot.id, "call_abc");
assert_eq!(slot.name, "get_weather");
let end = slot.end_event(UnparseableToolInput::Drop);
assert_eq!(end.id, StreamPartId::wire("call_abc"));
assert_eq!(end.tool_id.as_ref().map(|id| id.as_str()), Some("call_abc"));
}
#[test]
fn id_less_open_mints_a_distinct_minted_key_per_index() {
let mut bridge = ToolCallBridge::<usize>::new();
let first_key = bridge.open(0, None, Some("get_weather")).key().clone();
let second_key = bridge.open(1, None, Some("get_time")).key().clone();
assert_ne!(first_key, second_key);
assert!(first_key.is_minted());
assert!(second_key.is_minted());
let slot = bridge.remove(0).expect("slot must be open");
let end = slot.end_event(UnparseableToolInput::Drop);
assert_eq!(end.id, first_key);
assert!(end.tool_id.is_none());
}
#[test]
fn late_wire_id_updates_the_override_but_not_the_key() {
let mut bridge = ToolCallBridge::<usize>::new();
bridge.open(0, None, Some("get_weather"));
let slot = bridge.open(0, Some("call_late"), None);
assert!(slot.key().is_minted());
assert_eq!(slot.id, "call_late");
assert_eq!(slot.name, "get_weather");
}
#[test]
fn evict_if_takes_the_slot_only_when_the_predicate_says_so() {
let mut bridge = ToolCallBridge::<usize>::new();
bridge.open(0, Some("call_a"), Some("get_weather"));
assert!(bridge.evict_if(0, |slot| slot.id == "call_b").is_none());
assert!(bridge.get(0).is_some(), "a refused eviction keeps the slot");
let evicted = bridge
.evict_if(0, |slot| slot.id == "call_a")
.expect("predicate matched: slot must be evicted");
assert_eq!(evicted.key(), &StreamPartId::wire("call_a"));
assert!(bridge.get(0).is_none());
}
#[test]
fn decoration_matches_by_established_provider_id_and_rides_the_end_event() {
let mut bridge = ToolCallBridge::<usize>::new();
bridge.open(0, Some("call_a"), Some("get_weather"));
bridge.open(1, Some("call_b"), Some("get_time"));
bridge.decorate(ToolCallDecoration {
tool_id: "call_b".to_owned(),
signature: Some("sig-b".to_owned()),
additional_params: Some(serde_json::json!({"k": "v"})),
});
let undecorated = bridge.remove(0).expect("slot 0 open");
let end = undecorated.end_event(UnparseableToolInput::Drop);
assert!(end.signature.is_none());
let decorated = bridge.remove(1).expect("slot 1 open");
let end = decorated.end_event(UnparseableToolInput::Drop);
assert_eq!(end.signature.as_deref(), Some("sig-b"));
assert_eq!(end.additional_params, Some(serde_json::json!({"k": "v"})));
}
#[test]
fn an_empty_id_decoration_never_matches_an_id_less_slot() {
let mut bridge = ToolCallBridge::<usize>::new();
bridge.open(0, None, Some("get_weather"));
bridge.open(1, None, Some("get_time"));
bridge.decorate(ToolCallDecoration {
tool_id: String::new(),
signature: Some("sig".to_owned()),
additional_params: None,
});
for index in [0, 1] {
let slot = bridge.remove(index).expect("slot open");
assert!(
slot.signature.is_none(),
"an empty-id decoration must not land on slot {index}"
);
}
}
#[test]
fn decoration_fields_are_first_wins_per_field() {
let mut bridge = ToolCallBridge::<usize>::new();
bridge.open(0, Some("call_a"), Some("get_weather"));
bridge.decorate(ToolCallDecoration {
tool_id: "call_a".to_owned(),
signature: Some("sig-1".to_owned()),
additional_params: None,
});
bridge.decorate(ToolCallDecoration {
tool_id: "call_a".to_owned(),
signature: None,
additional_params: Some(serde_json::json!({"thought": true})),
});
bridge.decorate(ToolCallDecoration {
tool_id: "call_a".to_owned(),
signature: Some("sig-2".to_owned()),
additional_params: Some(serde_json::json!({"other": 1})),
});
let slot = bridge.remove(0).expect("slot open");
assert_eq!(slot.signature.as_deref(), Some("sig-1"));
assert_eq!(
slot.additional_params,
Some(serde_json::json!({"thought": true}))
);
}
#[test]
fn drain_ordered_preserves_wire_index_order() {
let mut bridge = ToolCallBridge::<i32>::new();
bridge.open(2, Some("call_c"), None);
bridge.open(0, Some("call_a"), None);
bridge.open(1, Some("call_b"), None);
let keys: Vec<StreamPartId> = bridge
.drain_ordered()
.into_iter()
.map(|slot| slot.key().clone())
.collect();
assert_eq!(
keys,
vec![
StreamPartId::wire("call_a"),
StreamPartId::wire("call_b"),
StreamPartId::wire("call_c")
]
);
assert!(bridge.is_empty());
}
}