gemini_adk_rs/tool/
commit.rs1use std::collections::HashMap;
23use std::sync::Arc;
24
25use async_trait::async_trait;
26use serde_json::Value;
27
28use super::ToolFunction;
29use crate::error::ToolError;
30use crate::state::State;
31
32pub fn idempotency_key(tool: &str, key: &str) -> String {
34 format!("idempotency:{tool}:{key}")
35}
36
37pub fn compensated_key(tool: &str) -> String {
39 format!("compensated:{tool}")
40}
41
42pub struct CommitGuard {
45 inner: Arc<dyn ToolFunction>,
46 state: State,
47 key_template: Option<String>,
48 compensate: Option<Arc<dyn ToolFunction>>,
49 in_flight: parking_lot::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
52}
53
54impl CommitGuard {
55 pub fn new(inner: Arc<dyn ToolFunction>, state: State) -> Self {
57 Self {
58 inner,
59 state,
60 key_template: None,
61 compensate: None,
62 in_flight: parking_lot::Mutex::new(HashMap::new()),
63 }
64 }
65
66 pub fn idempotency_key(mut self, template: impl Into<String>) -> Self {
71 self.key_template = Some(template.into());
72 self
73 }
74
75 pub fn compensate_with(mut self, tool: Arc<dyn ToolFunction>) -> Self {
77 self.compensate = Some(tool);
78 self
79 }
80
81 fn render_key(&self, args: &Value) -> Option<String> {
82 let template = self.key_template.as_deref()?;
83 let mut out = String::with_capacity(template.len());
84 let mut rest = template;
85 while let Some(open) = rest.find('{') {
86 out.push_str(&rest[..open]);
87 let after = &rest[open + 1..];
88 let close = after.find('}')?;
89 let name = after[..close].trim();
90 let value = args
91 .get(name)
92 .cloned()
93 .or_else(|| self.state.get_raw(name))
94 .filter(|v| !v.is_null());
95 match value {
96 Some(Value::String(s)) => out.push_str(&s),
97 Some(v) => out.push_str(&v.to_string()),
98 None => {
99 tracing::warn!(
100 tool = self.inner.name(),
101 placeholder = name,
102 "idempotency key incomplete; this call is not deduplicated"
103 );
104 return None;
105 }
106 }
107 rest = &after[close + 1..];
108 }
109 out.push_str(rest);
110 Some(out)
111 }
112}
113
114#[async_trait]
115impl ToolFunction for CommitGuard {
116 fn name(&self) -> &str {
117 self.inner.name()
118 }
119
120 fn description(&self) -> &str {
121 self.inner.description()
122 }
123
124 fn parameters(&self) -> Option<Value> {
125 self.inner.parameters()
126 }
127
128 fn requires_confirmation(&self) -> bool {
129 self.inner.requires_confirmation()
130 }
131
132 fn confirmation_message(&self) -> Option<&str> {
133 self.inner.confirmation_message()
134 }
135
136 async fn call(&self, args: Value) -> Result<Value, ToolError> {
137 self.call_with_context(args, super::ToolContext::new(self.state.clone()))
138 .await
139 }
140
141 async fn call_with_context(
142 &self,
143 args: Value,
144 ctx: super::ToolContext,
145 ) -> Result<Value, ToolError> {
146 let tool = self.inner.name();
147 let key = self
148 .render_key(&args)
149 .map(|key| idempotency_key(tool, &key));
150 let lock = key.as_ref().map(|key| {
153 self.in_flight
154 .lock()
155 .entry(key.clone())
156 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
157 .clone()
158 });
159 let _held = match &lock {
160 Some(lock) => Some(lock.lock().await),
161 None => None,
162 };
163 if let Some(key) = &key
164 && let Some(previous) = self.state.get_raw(key)
165 {
166 tracing::info!(tool, "commit already made; returning its result");
167 return Ok(previous);
168 }
169 match self
170 .inner
171 .call_with_context(args.clone(), ctx.clone())
172 .await
173 {
174 Ok(result) => {
175 if let Some(key) = key {
176 let _ = self.state.set(key, &result);
177 }
178 Ok(result)
179 }
180 Err(error) => {
181 if let Some(compensate) = &self.compensate {
182 match compensate.call_with_context(args, ctx).await {
183 Ok(_) => {
184 let _ = self.state.set(compensated_key(tool), true);
185 }
186 Err(e) => tracing::error!(
187 tool,
188 compensating = compensate.name(),
189 "compensation failed: {e}"
190 ),
191 }
192 }
193 Err(error)
194 }
195 }
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::tool::SimpleTool;
203 use serde_json::json;
204 use std::sync::atomic::{AtomicUsize, Ordering};
205
206 fn counting(name: &'static str, calls: Arc<AtomicUsize>, fail: bool) -> Arc<dyn ToolFunction> {
207 Arc::new(SimpleTool::new(name, name, None, move |args| {
208 let calls = calls.clone();
209 async move {
210 let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
211 if fail {
212 Err(ToolError::ExecutionFailed("card declined".into()))
213 } else {
214 Ok(json!({ "charge": n, "amount": args["amount"] }))
215 }
216 }
217 }))
218 }
219
220 #[tokio::test]
221 async fn the_same_commit_runs_once() {
222 let calls = Arc::new(AtomicUsize::new(0));
223 let state = State::new();
224 let _ = state.set("user_id", "u1");
225 let guard = CommitGuard::new(counting("charge", calls.clone(), false), state.clone())
226 .idempotency_key("{user_id}:{amount}");
227
228 let first = guard.call(json!({ "amount": 40 })).await.unwrap();
229 let again = guard.call(json!({ "amount": 40 })).await.unwrap();
230 assert_eq!(first, again, "the retry gets the first charge back");
231 assert_eq!(calls.load(Ordering::SeqCst), 1, "the card was charged once");
232
233 guard.call(json!({ "amount": 55 })).await.unwrap();
234 assert_eq!(
235 calls.load(Ordering::SeqCst),
236 2,
237 "a different amount is a new commit"
238 );
239 assert!(state.get_raw(&idempotency_key("charge", "u1:40")).is_some());
240 }
241
242 #[tokio::test]
243 async fn concurrent_calls_with_one_key_commit_once() {
244 let calls = Arc::new(AtomicUsize::new(0));
245 let counter = calls.clone();
246 let slow: Arc<dyn ToolFunction> =
247 Arc::new(SimpleTool::new("charge", "charge", None, move |_| {
248 let counter = counter.clone();
249 async move {
250 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
251 Ok(json!({ "charge": counter.fetch_add(1, Ordering::SeqCst) + 1 }))
252 }
253 }));
254 let guard = Arc::new(CommitGuard::new(slow, State::new()).idempotency_key("{amount}"));
255 let (a, b) = tokio::join!(
256 guard.call(json!({ "amount": 40 })),
257 guard.call(json!({ "amount": 40 }))
258 );
259 assert_eq!(a.unwrap(), b.unwrap(), "both get the one charge");
260 assert_eq!(calls.load(Ordering::SeqCst), 1);
261 }
262
263 #[tokio::test]
264 async fn an_incomplete_key_never_merges_commits() {
265 let calls = Arc::new(AtomicUsize::new(0));
266 let guard = CommitGuard::new(counting("charge", calls.clone(), false), State::new())
267 .idempotency_key("{user_id}:{amount}");
268 guard.call(json!({ "amount": 40 })).await.unwrap();
269 guard.call(json!({ "amount": 40 })).await.unwrap();
270 assert_eq!(calls.load(Ordering::SeqCst), 2);
271 }
272
273 #[tokio::test]
274 async fn a_failed_commit_is_compensated_and_still_fails() {
275 let charges = Arc::new(AtomicUsize::new(0));
276 let refunds = Arc::new(AtomicUsize::new(0));
277 let state = State::new();
278 let guard = CommitGuard::new(counting("charge", charges, true), state.clone())
279 .compensate_with(counting("refund", refunds.clone(), false));
280 assert!(guard.call(json!({ "amount": 40 })).await.is_err());
281 assert_eq!(refunds.load(Ordering::SeqCst), 1);
282 assert_eq!(state.get::<bool>(&compensated_key("charge")), Some(true));
283 }
284}