gemini_adk_fluent_rs/compose/
guards.rs1use std::sync::Arc;
14
15use async_trait::async_trait;
16use gemini_adk_rs::error::AgentError;
17use gemini_adk_rs::llm::{BaseLlm, LlmRequest, LlmResponse};
18use gemini_adk_rs::middleware::Middleware;
19
20use crate::compose::judge::{LlmJudge, render_contents};
21
22#[derive(Clone)]
24pub struct GuardRule {
25 name: &'static str,
26 kind: GuardKind,
27}
28
29#[derive(Clone)]
31enum GuardKind {
32 Sync(#[allow(clippy::type_complexity)] Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync>),
34 Judge(LlmJudge),
36}
37
38impl GuardRule {
39 fn new(
40 name: &'static str,
41 f: impl Fn(&str) -> Result<(), String> + Send + Sync + 'static,
42 ) -> Self {
43 Self {
44 name,
45 kind: GuardKind::Sync(Arc::new(f)),
46 }
47 }
48
49 fn judge(name: &'static str, judge: LlmJudge) -> Self {
50 Self {
51 name,
52 kind: GuardKind::Judge(judge),
53 }
54 }
55
56 pub fn name(&self) -> &str {
58 self.name
59 }
60
61 pub fn check(&self, output: &str) -> Result<(), String> {
65 match &self.kind {
66 GuardKind::Sync(f) => f(output),
67 GuardKind::Judge(_) => Ok(()),
68 }
69 }
70
71 pub async fn check_async(&self, output: &str, context: Option<&str>) -> Result<(), String> {
74 match &self.kind {
75 GuardKind::Sync(f) => f(output),
76 GuardKind::Judge(judge) => {
77 let verdict = judge.judge(output, context).await;
78 if verdict.flagged {
79 Err(verdict.reason)
80 } else {
81 Ok(())
82 }
83 }
84 }
85 }
86}
87
88impl std::fmt::Debug for GuardRule {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.debug_struct("GuardRule")
91 .field("name", &self.name)
92 .finish()
93 }
94}
95
96impl std::ops::BitOr for GuardRule {
98 type Output = GuardComposite;
99
100 fn bitor(self, rhs: GuardRule) -> Self::Output {
101 GuardComposite {
102 guards: vec![self, rhs],
103 }
104 }
105}
106
107#[derive(Clone)]
109#[non_exhaustive]
110pub struct GuardComposite {
111 pub guards: Vec<GuardRule>,
113}
114
115impl GuardComposite {
116 pub fn check_all(&self, output: &str) -> Vec<String> {
119 self.guards
120 .iter()
121 .filter_map(|g| g.check(output).err())
122 .collect()
123 }
124
125 pub async fn check_all_async(&self, output: &str, context: Option<&str>) -> Vec<String> {
128 let mut violations = Vec::new();
129 for g in &self.guards {
130 if let Err(reason) = g.check_async(output, context).await {
131 violations.push(format!("{}: {}", g.name(), reason));
132 }
133 }
134 violations
135 }
136
137 pub fn len(&self) -> usize {
139 self.guards.len()
140 }
141
142 pub fn is_empty(&self) -> bool {
144 self.guards.is_empty()
145 }
146}
147
148impl std::ops::BitOr<GuardRule> for GuardComposite {
149 type Output = GuardComposite;
150
151 fn bitor(mut self, rhs: GuardRule) -> Self::Output {
152 self.guards.push(rhs);
153 self
154 }
155}
156
157impl From<GuardRule> for GuardComposite {
160 fn from(guard: GuardRule) -> Self {
161 GuardComposite {
162 guards: vec![guard],
163 }
164 }
165}
166
167impl GuardComposite {
168 pub fn into_middleware(self) -> Arc<dyn Middleware> {
171 Arc::new(GuardMiddleware { guards: self })
172 }
173}
174
175struct GuardMiddleware {
177 guards: GuardComposite,
178}
179
180#[async_trait]
181impl Middleware for GuardMiddleware {
182 fn name(&self) -> &str {
183 "guard"
184 }
185
186 async fn after_model(
187 &self,
188 request: &LlmRequest,
189 response: &LlmResponse,
190 ) -> Result<Option<LlmResponse>, AgentError> {
191 let context = render_contents(&request.contents);
194 let violations = self
195 .guards
196 .check_all_async(&response.text(), Some(&context))
197 .await;
198 if violations.is_empty() {
199 Ok(None)
200 } else {
201 Err(AgentError::Other(format!(
202 "guard violation: {}",
203 violations.join("; ")
204 )))
205 }
206 }
207}
208
209pub struct G;
211
212impl G {
213 pub fn length(min: usize, max: usize) -> GuardRule {
215 GuardRule::new("length", move |output| {
216 let len = output.len();
217 if len < min {
218 Err(format!("Output too short: {len} < {min}"))
219 } else if len > max {
220 Err(format!("Output too long: {len} > {max}"))
221 } else {
222 Ok(())
223 }
224 })
225 }
226
227 pub fn regex(pattern: &str) -> GuardRule {
229 let pattern = pattern.to_string();
230 GuardRule::new("regex", move |output| {
231 if output.contains(&pattern) {
233 Err(format!("Output matches forbidden pattern: {pattern}"))
234 } else {
235 Ok(())
236 }
237 })
238 }
239
240 pub fn budget(max_tokens: usize) -> GuardRule {
242 GuardRule::new("budget", move |output| {
243 let estimated_tokens = output.len() / 4;
245 if estimated_tokens > max_tokens {
246 Err(format!(
247 "Output exceeds token budget: ~{estimated_tokens} > {max_tokens}"
248 ))
249 } else {
250 Ok(())
251 }
252 })
253 }
254
255 pub fn json() -> GuardRule {
257 GuardRule::new("json", |output| {
258 serde_json::from_str::<serde_json::Value>(output)
259 .map(|_| ())
260 .map_err(|e| format!("Invalid JSON: {e}"))
261 })
262 }
263
264 pub fn pii() -> GuardRule {
266 GuardRule::new("pii", |output| {
267 if output.contains('@') && output.contains('.') {
269 return Err("Output may contain email addresses".to_string());
271 }
272 Ok(())
273 })
274 }
275
276 pub fn topic(deny: &[&str]) -> GuardRule {
278 let deny: Vec<String> = deny.iter().map(|s| s.to_lowercase()).collect();
279 GuardRule::new("topic", move |output| {
280 let lower = output.to_lowercase();
281 for topic in &deny {
282 if lower.contains(topic.as_str()) {
283 return Err(format!("Output mentions denied topic: {topic}"));
284 }
285 }
286 Ok(())
287 })
288 }
289
290 pub fn custom(f: impl Fn(&str) -> Result<(), String> + Send + Sync + 'static) -> GuardRule {
292 GuardRule::new("custom", f)
293 }
294
295 pub fn toxicity(judge: Arc<dyn BaseLlm>) -> GuardRule {
301 GuardRule::judge(
302 "toxicity",
303 LlmJudge::new(
304 judge,
305 "the RESPONSE contains toxic, hateful, harassing, sexual, or otherwise \
306 abusive or unsafe content",
307 ),
308 )
309 }
310
311 pub fn grounded(judge: Arc<dyn BaseLlm>) -> GuardRule {
316 GuardRule::judge(
317 "grounded",
318 LlmJudge::new(
319 judge,
320 "the RESPONSE asserts facts that are NOT supported by, or that \
321 contradict, the provided CONTEXT",
322 )
323 .with_context("CONTEXT"),
324 )
325 }
326
327 pub fn hallucination(judge: Arc<dyn BaseLlm>) -> GuardRule {
329 GuardRule::judge(
330 "hallucination",
331 LlmJudge::new(
332 judge,
333 "the RESPONSE contains fabricated, invented, or unverifiable facts \
334 that are not supported by the CONTEXT",
335 )
336 .with_context("CONTEXT"),
337 )
338 }
339
340 pub fn when(
342 predicate: impl Fn(&str) -> bool + Send + Sync + 'static,
343 inner: GuardRule,
344 ) -> GuardRule {
345 GuardRule::new("when", move |output| {
346 if predicate(output) {
347 inner.check(output)
348 } else {
349 Ok(())
350 }
351 })
352 }
353
354 pub fn llm_judge(judge: Arc<dyn BaseLlm>, rubric: impl Into<String>) -> GuardRule {
360 GuardRule::judge("llm_judge", LlmJudge::new(judge, rubric))
361 }
362
363 pub fn custom_judge(
365 name: &str,
366 f: impl Fn(&str) -> Result<(), String> + Send + Sync + 'static,
367 ) -> GuardRule {
368 let name: &'static str = Box::leak(name.to_string().into_boxed_str());
370 GuardRule::new(name, f)
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn length_guard_passes() {
380 assert!(G::length(1, 100).check("hello").is_ok());
381 }
382
383 #[test]
384 fn length_guard_too_short() {
385 assert!(G::length(10, 100).check("hi").is_err());
386 }
387
388 #[test]
389 fn length_guard_too_long() {
390 assert!(G::length(1, 5).check("too long text").is_err());
391 }
392
393 #[test]
394 fn json_guard_valid() {
395 assert!(G::json().check(r#"{"key": "value"}"#).is_ok());
396 }
397
398 #[test]
399 fn json_guard_invalid() {
400 assert!(G::json().check("not json").is_err());
401 }
402
403 #[test]
404 fn regex_guard_blocks() {
405 assert!(G::regex("secret").check("this is a secret").is_err());
406 }
407
408 #[test]
409 fn regex_guard_passes() {
410 assert!(G::regex("secret").check("this is public").is_ok());
411 }
412
413 #[test]
414 fn budget_guard_passes() {
415 assert!(G::budget(100).check("short").is_ok());
416 }
417
418 #[test]
419 fn topic_guard_blocks() {
420 assert!(G::topic(&["violence"]).check("There was violence").is_err());
421 }
422
423 #[test]
424 fn topic_guard_passes() {
425 assert!(G::topic(&["violence"]).check("A peaceful day").is_ok());
426 }
427
428 #[test]
429 fn compose_with_bitor() {
430 let composite = G::length(1, 1000) | G::json();
431 assert_eq!(composite.len(), 2);
432 }
433
434 #[test]
435 fn check_all_returns_violations() {
436 let composite = G::length(1, 5) | G::json();
437 let violations = composite.check_all("not json and too long text here");
438 assert!(!violations.is_empty());
439 }
440
441 #[test]
442 fn custom_guard() {
443 let g = G::custom(|output| {
444 if output.contains("bad") {
445 Err("Contains 'bad'".into())
446 } else {
447 Ok(())
448 }
449 });
450 assert!(g.check("good output").is_ok());
451 assert!(g.check("bad output").is_err());
452 }
453
454 fn judge_llm() -> Arc<dyn BaseLlm> {
457 use gemini_adk_rs::llm::{LlmError, LlmResponse};
458 use gemini_genai_rs::prelude::{Content, Part, Role};
459
460 struct NoopJudge;
461 #[async_trait]
462 impl BaseLlm for NoopJudge {
463 fn model_id(&self) -> &str {
464 "noop-judge"
465 }
466 async fn generate(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
467 Ok(LlmResponse {
468 content: Content {
469 role: Some(Role::Model),
470 parts: vec![Part::Text {
471 text: r#"{"violation": false, "reason": "ok"}"#.to_string(),
472 }],
473 },
474 finish_reason: Some("STOP".into()),
475 usage: None,
476 })
477 }
478 }
479 Arc::new(NoopJudge)
480 }
481
482 #[test]
483 fn toxicity_guard() {
484 let g = G::toxicity(judge_llm());
485 assert!(g.check("anything").is_ok());
487 assert_eq!(g.name(), "toxicity");
488 }
489
490 #[test]
491 fn grounded_guard() {
492 let g = G::grounded(judge_llm());
493 assert!(g.check("anything").is_ok());
494 assert_eq!(g.name(), "grounded");
495 }
496
497 #[test]
498 fn hallucination_guard() {
499 let g = G::hallucination(judge_llm());
500 assert!(g.check("anything").is_ok());
501 assert_eq!(g.name(), "hallucination");
502 }
503
504 #[tokio::test]
505 async fn judge_guard_runs_async() {
506 use gemini_adk_rs::llm::{LlmError, LlmResponse};
508 use gemini_genai_rs::prelude::{Content, Part, Role};
509 struct FlagAll;
510 #[async_trait]
511 impl BaseLlm for FlagAll {
512 fn model_id(&self) -> &str {
513 "flag-all"
514 }
515 async fn generate(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
516 Ok(LlmResponse {
517 content: Content {
518 role: Some(Role::Model),
519 parts: vec![Part::Text {
520 text: r#"{"violation": true, "reason": "bad"}"#.to_string(),
521 }],
522 },
523 finish_reason: Some("STOP".into()),
524 usage: None,
525 })
526 }
527 }
528 let g = G::toxicity(Arc::new(FlagAll));
529 assert!(g.check_async("hello", None).await.is_err());
530 }
531
532 #[test]
533 fn when_guard_applies() {
534 let inner = G::length(1, 5);
535 let g = G::when(|output| output.starts_with("check:"), inner);
536 assert!(g.check("check: this is way too long").is_err());
538 assert!(g.check("skip: this is way too long").is_ok());
540 assert_eq!(g.name(), "when");
541 }
542
543 #[test]
544 fn llm_judge_guard() {
545 let g = G::llm_judge(judge_llm(), "the response is unhelpful");
546 assert!(g.check("anything").is_ok());
547 assert_eq!(g.name(), "llm_judge");
548 }
549
550 #[test]
551 fn custom_judge_guard() {
552 let g = G::custom_judge("profanity_filter", |output| {
553 if output.contains("bad_word") {
554 Err("Profanity detected".into())
555 } else {
556 Ok(())
557 }
558 });
559 assert!(g.check("clean text").is_ok());
560 assert!(g.check("has bad_word here").is_err());
561 assert_eq!(g.name(), "profanity_filter");
562 }
563
564 #[test]
565 fn compose_new_guards_with_bitor() {
566 let composite =
567 G::toxicity(judge_llm()) | G::grounded(judge_llm()) | G::hallucination(judge_llm());
568 assert_eq!(composite.len(), 3);
569 assert!(composite.check_all("test").is_empty());
571 }
572}