gemini_adk_fluent_rs/patterns.rs
1//! Pre-built patterns — common multi-agent workflows.
2//!
3//! High-level functions that compose agents into standard patterns:
4//! review loops, cascades, fan-out-merge, supervised workflows, etc.
5//!
6//! Each function returns a [`Composable`] that can be compiled into an
7//! executable [`TextAgent`](gemini_adk_rs::text::TextAgent) via
8//! [`Composable::compile()`](crate::operators::Composable::compile).
9//!
10//! # Examples
11//!
12//! ```
13//! use gemini_adk_fluent_rs::prelude::*;
14//!
15//! // Review loop: author writes, reviewer checks, loop until approved
16//! let draft = review_loop(
17//! AgentBuilder::new("author").instruction("Write an essay"),
18//! AgentBuilder::new("reviewer").instruction("Review and set approved=true when good"),
19//! 3,
20//! );
21//!
22//! // Cascade: try agents in order, first success wins
23//! let robust = cascade(vec![
24//! AgentBuilder::new("primary"),
25//! AgentBuilder::new("fallback"),
26//! ]);
27//!
28//! // Fan-out-merge: parallel agents, then merge
29//! let research = fan_out_merge(
30//! vec![AgentBuilder::new("web"), AgentBuilder::new("db")],
31//! AgentBuilder::new("synthesizer"),
32//! );
33//! # let _ = (draft, robust, research);
34//! ```
35
36use crate::builder::AgentBuilder;
37use crate::operators::{Composable, Fallback, FanOut, Loop, LoopPredicate, Pipeline};
38
39/// Review loop: author writes, reviewer checks, loop until approved.
40///
41/// The author agent produces output, then the reviewer evaluates it.
42/// The loop terminates when the reviewer sets `"approved"` to `true`
43/// in the state, or after `max_rounds` iterations.
44///
45/// # Arguments
46///
47/// * `author` — The agent that produces drafts.
48/// * `reviewer` — The agent that evaluates and sets `"approved": true` when satisfied.
49/// * `max_rounds` — Maximum number of author-reviewer cycles.
50///
51/// # Example
52///
53/// ```no_run
54/// # use gemini_adk_fluent_rs::prelude::*;
55/// # use std::sync::Arc;
56/// # fn run(llm: Arc<dyn BaseLlm>) -> Result<(), ConfigError> {
57/// let workflow = review_loop(
58/// AgentBuilder::new("writer").instruction("Write a blog post"),
59/// AgentBuilder::new("editor").instruction("Review. Set approved=true if publication-ready."),
60/// 3,
61/// );
62/// let agent = workflow.compile(llm)?;
63/// # let _ = agent; Ok(())
64/// # }
65/// ```
66pub fn review_loop(author: AgentBuilder, reviewer: AgentBuilder, max_rounds: usize) -> Composable {
67 let inner = Composable::Pipeline(Pipeline::new(vec![
68 Composable::Agent(author),
69 Composable::Agent(reviewer),
70 ]));
71
72 Composable::Loop(Loop {
73 body: Box::new(inner),
74 max: max_rounds as u32,
75 middleware: Vec::new(),
76 name: None,
77 description: None,
78 until: Some(LoopPredicate::new(|state| {
79 state
80 .get("approved")
81 .and_then(serde_json::Value::as_bool)
82 .unwrap_or(false)
83 })),
84 })
85}
86
87/// Review loop with a custom quality key and target value.
88///
89/// Like [`review_loop`] but allows specifying which state key the reviewer
90/// writes to and what value signals completion.
91///
92/// # Arguments
93///
94/// * `worker` — The agent that produces output.
95/// * `reviewer` — The agent that evaluates quality.
96/// * `quality_key` — State key the reviewer writes (e.g., `"quality"`).
97/// * `target` — Value of `quality_key` that signals completion (e.g., `"good"`).
98/// * `max_rounds` — Maximum iterations.
99pub fn review_loop_keyed(
100 worker: AgentBuilder,
101 reviewer: AgentBuilder,
102 quality_key: &str,
103 target: &str,
104 max_rounds: u32,
105) -> Composable {
106 let key = quality_key.to_string();
107 let target = target.to_string();
108
109 let inner = Composable::Pipeline(Pipeline::new(vec![
110 Composable::Agent(worker),
111 Composable::Agent(reviewer),
112 ]));
113
114 Composable::Loop(Loop {
115 body: Box::new(inner),
116 max: max_rounds,
117 middleware: Vec::new(),
118 name: None,
119 description: None,
120 until: Some(LoopPredicate::new(move |state| {
121 state
122 .get(&key)
123 .and_then(|v| v.as_str())
124 .map(|v| v == target)
125 .unwrap_or(false)
126 })),
127 })
128}
129
130/// Cascade: try agents in sequence, first success wins.
131///
132/// This is an alias for a fallback chain. Each agent is tried in order;
133/// the first one that succeeds provides the result.
134///
135/// # Example
136///
137/// ```
138/// # use gemini_adk_fluent_rs::prelude::*;
139/// let robust = cascade(vec![
140/// AgentBuilder::new("fast").instruction("Quick answer"),
141/// AgentBuilder::new("thorough").instruction("Detailed answer"),
142/// ]);
143/// assert!(matches!(robust, Composable::Fallback(_)));
144/// ```
145pub fn cascade(agents: Vec<AgentBuilder>) -> Composable {
146 Composable::Fallback(Fallback::new(
147 agents.into_iter().map(Composable::Agent).collect(),
148 ))
149}
150
151/// Fan-out-merge: run agents in parallel, then merge results with a merger agent.
152///
153/// All `agents` execute concurrently via fan-out. Their combined output is
154/// then fed into the `merger` agent, which synthesizes a final result.
155///
156/// # Arguments
157///
158/// * `agents` — Agents to run in parallel.
159/// * `merger` — Agent that merges the parallel results.
160///
161/// # Example
162///
163/// ```
164/// # use gemini_adk_fluent_rs::prelude::*;
165/// let research = fan_out_merge(
166/// vec![
167/// AgentBuilder::new("web-search").instruction("Search the web"),
168/// AgentBuilder::new("db-lookup").instruction("Query the database"),
169/// ],
170/// AgentBuilder::new("synthesizer").instruction("Combine research findings"),
171/// );
172/// assert!(matches!(research, Composable::Pipeline(_)));
173/// ```
174pub fn fan_out_merge(agents: Vec<AgentBuilder>, merger: AgentBuilder) -> Composable {
175 let fan_out = Composable::FanOut(FanOut::new(
176 agents.into_iter().map(Composable::Agent).collect(),
177 ));
178
179 Composable::Pipeline(Pipeline::new(vec![fan_out, Composable::Agent(merger)]))
180}
181
182/// Chain: simple sequential pipeline of agents.
183///
184/// This is an alias for the `>>` operator but accepts a `Vec`.
185/// Each agent runs in order, with the output of one feeding into the next.
186///
187/// # Example
188///
189/// ```
190/// # use gemini_adk_fluent_rs::prelude::*;
191/// let pipeline = chain(vec![
192/// AgentBuilder::new("extract"),
193/// AgentBuilder::new("transform"),
194/// AgentBuilder::new("load"),
195/// ]);
196/// assert!(matches!(pipeline, Composable::Pipeline(_)));
197/// ```
198pub fn chain(agents: Vec<AgentBuilder>) -> Composable {
199 Composable::Pipeline(Pipeline::new(
200 agents.into_iter().map(Composable::Agent).collect(),
201 ))
202}
203
204/// Conditional: route to one of two agents based on a state predicate.
205///
206/// Evaluates `predicate` against the current state. If it returns `true`,
207/// the `if_true` agent runs; otherwise, the `if_false` agent runs.
208///
209/// # Arguments
210///
211/// * `predicate` — Function that inspects state (as `serde_json::Value`) and returns a bool.
212/// * `if_true` — Agent to run when the predicate is true.
213/// * `if_false` — Agent to run when the predicate is false.
214///
215/// # Example
216///
217/// ```
218/// # use gemini_adk_fluent_rs::prelude::*;
219/// let routed = conditional(
220/// |state| state.get("premium").and_then(|v| v.as_bool()).unwrap_or(false),
221/// AgentBuilder::new("premium-agent").instruction("Full-featured response"),
222/// AgentBuilder::new("basic-agent").instruction("Basic response"),
223/// );
224/// assert!(matches!(routed, Composable::Fallback(_)));
225/// ```
226pub fn conditional(
227 predicate: impl Fn(&serde_json::Value) -> bool + Send + Sync + 'static,
228 if_true: AgentBuilder,
229 if_false: AgentBuilder,
230) -> Composable {
231 let pred = std::sync::Arc::new(predicate);
232 let pred_clone = pred.clone();
233
234 let true_branch = AgentBuilder::new(if_true.name())
235 .instruction(if_true.get_instruction().unwrap_or_default());
236 let false_branch = AgentBuilder::new(if_false.name())
237 .instruction(if_false.get_instruction().unwrap_or_default());
238
239 // Store predicate in a loop with max=1 for the true branch,
240 // fall back to false branch.
241 let guarded = Composable::Loop(Loop {
242 body: Box::new(Composable::Agent(true_branch)),
243 max: 1,
244 middleware: Vec::new(),
245 name: None,
246 description: None,
247 until: Some(LoopPredicate::new(move |state| pred_clone(state))),
248 });
249
250 Composable::Fallback(Fallback::new(vec![
251 guarded,
252 Composable::Agent(false_branch),
253 ]))
254}
255
256/// Supervised: worker with supervisor oversight loop.
257///
258/// The worker agent produces output, then the supervisor reviews it.
259/// The loop repeats until the supervisor sets `"approved"` to `true`
260/// in the state, or after `max_rounds` iterations.
261///
262/// This is semantically similar to [`review_loop`] but framed as a
263/// worker-supervisor relationship rather than author-reviewer.
264///
265/// # Arguments
266///
267/// * `worker` — The agent that performs the task.
268/// * `supervisor` — The agent that oversees and approves work.
269/// * `max_rounds` — Maximum number of worker-supervisor cycles.
270///
271/// # Example
272///
273/// ```
274/// # use gemini_adk_fluent_rs::prelude::*;
275/// let managed = supervised(
276/// AgentBuilder::new("coder").instruction("Write the implementation"),
277/// AgentBuilder::new("lead").instruction("Code review. Set approved=true if ready to merge."),
278/// 5,
279/// );
280/// assert!(matches!(managed, Composable::Loop(_)));
281/// ```
282pub fn supervised(worker: AgentBuilder, supervisor: AgentBuilder, max_rounds: usize) -> Composable {
283 let inner = Composable::Pipeline(Pipeline::new(vec![
284 Composable::Agent(worker),
285 Composable::Agent(supervisor),
286 ]));
287
288 Composable::Loop(Loop {
289 body: Box::new(inner),
290 max: max_rounds as u32,
291 middleware: Vec::new(),
292 name: None,
293 description: None,
294 until: Some(LoopPredicate::new(|state| {
295 state
296 .get("approved")
297 .and_then(serde_json::Value::as_bool)
298 .unwrap_or(false)
299 })),
300 })
301}
302
303/// Supervised with a custom approval key.
304///
305/// Like [`supervised`] but allows specifying which state key signals approval.
306///
307/// # Arguments
308///
309/// * `worker` — The agent that performs the task.
310/// * `supervisor` — The agent that oversees work.
311/// * `approval_key` — State key the supervisor sets to `true` when satisfied.
312/// * `max_revisions` — Maximum iterations.
313pub fn supervised_keyed(
314 worker: AgentBuilder,
315 supervisor: AgentBuilder,
316 approval_key: &str,
317 max_revisions: u32,
318) -> Composable {
319 let key = approval_key.to_string();
320
321 let inner = Composable::Pipeline(Pipeline::new(vec![
322 Composable::Agent(worker),
323 Composable::Agent(supervisor),
324 ]));
325
326 Composable::Loop(Loop {
327 body: Box::new(inner),
328 max: max_revisions,
329 middleware: Vec::new(),
330 name: None,
331 description: None,
332 until: Some(LoopPredicate::new(move |state| {
333 state
334 .get(&key)
335 .and_then(serde_json::Value::as_bool)
336 .unwrap_or(false)
337 })),
338 })
339}
340
341/// Map-over: apply one agent to every item of a state list.
342///
343/// At run time the compiled node reads the JSON array at `state[list_key]`,
344/// runs `agent` once per item with the item in `state["_item"]` (and as the
345/// agent's `input`), and collects the outputs into `state["_results"]`.
346/// Items run sequentially — `MapOver::item_key`/`output_key` rename the
347/// slots. Composes like any other node:
348///
349/// ```
350/// # use gemini_adk_fluent_rs::prelude::*;
351/// let batch = map_over(AgentBuilder::new("summarize"), "documents")
352/// >> AgentBuilder::new("merge");
353/// assert!(matches!(batch, Composable::Pipeline(_)));
354/// ```
355pub fn map_over(agent: AgentBuilder, list_key: impl Into<String>) -> Composable {
356 Composable::MapOver(MapOver::new(agent, list_key))
357}
358
359/// A map-over workflow node — applies one agent to many items. Build with
360/// [`map_over`]; compiles to `MapOverTextAgent`.
361#[derive(Clone, Debug)]
362pub struct MapOver {
363 /// The agent template applied to each item.
364 pub agent: AgentBuilder,
365 /// State key holding the JSON array to iterate.
366 pub list_key: String,
367 /// State key the current item is written to (default `"_item"`).
368 pub item_key: String,
369 /// State key the collected outputs are written to (default `"_results"`).
370 pub output_key: String,
371 /// Name given to the compiled agent (default `"map_over"`).
372 pub name: Option<String>,
373}
374
375impl MapOver {
376 /// Create a map-over node for `agent` over the list at `list_key`.
377 pub fn new(agent: AgentBuilder, list_key: impl Into<String>) -> Self {
378 Self {
379 agent,
380 list_key: list_key.into(),
381 item_key: "_item".into(),
382 output_key: "_results".into(),
383 name: None,
384 }
385 }
386
387 /// State key the current item is written to for each run.
388 pub fn item_key(mut self, key: impl Into<String>) -> Self {
389 self.item_key = key.into();
390 self
391 }
392
393 /// State key the collected outputs are written to.
394 pub fn output_key(mut self, key: impl Into<String>) -> Self {
395 self.output_key = key.into();
396 self
397 }
398
399 /// Name the compiled agent.
400 pub fn name(mut self, name: impl Into<String>) -> Self {
401 self.name = Some(name.into());
402 self
403 }
404}
405
406/// Map-reduce: map `mapper` over the list at `list_key`, then run `reducer`
407/// over the collected results (`state["_results"]`). A pipeline of a
408/// [`map_over`] node and the reducer.
409pub fn map_reduce(
410 mapper: AgentBuilder,
411 reducer: AgentBuilder,
412 list_key: impl Into<String>,
413) -> Composable {
414 Composable::Pipeline(Pipeline::new(vec![
415 map_over(mapper, list_key),
416 Composable::Agent(reducer),
417 ]))
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 fn agent(name: &str) -> AgentBuilder {
425 AgentBuilder::new(name)
426 }
427
428 #[test]
429 fn review_loop_creates_loop_with_pipeline() {
430 let result = review_loop(agent("writer"), agent("reviewer"), 3);
431 match &result {
432 Composable::Loop(l) => {
433 assert_eq!(l.max, 3);
434 assert!(l.until.is_some());
435 assert!(matches!(&*l.body, Composable::Pipeline(p) if p.steps.len() == 2));
436 }
437 _ => panic!("expected Loop"),
438 }
439 }
440
441 #[test]
442 fn review_loop_predicate_checks_approved() {
443 let result = review_loop(agent("w"), agent("r"), 3);
444 if let Composable::Loop(l) = result {
445 let pred = l.until.unwrap();
446 assert!(!pred.check(&serde_json::json!({"approved": false})));
447 assert!(pred.check(&serde_json::json!({"approved": true})));
448 assert!(!pred.check(&serde_json::json!({})));
449 }
450 }
451
452 #[test]
453 fn review_loop_keyed_predicate_works() {
454 let result = review_loop_keyed(agent("w"), agent("r"), "quality", "good", 3);
455 if let Composable::Loop(l) = result {
456 let pred = l.until.unwrap();
457 assert!(!pred.check(&serde_json::json!({"quality": "bad"})));
458 assert!(pred.check(&serde_json::json!({"quality": "good"})));
459 }
460 }
461
462 #[test]
463 fn cascade_creates_fallback() {
464 let result = cascade(vec![agent("a"), agent("b"), agent("c")]);
465 match result {
466 Composable::Fallback(f) => assert_eq!(f.candidates.len(), 3),
467 _ => panic!("expected Fallback"),
468 }
469 }
470
471 #[test]
472 fn fan_out_merge_creates_pipeline_with_fan_out_then_merger() {
473 let result = fan_out_merge(vec![agent("a"), agent("b")], agent("merger"));
474 match &result {
475 Composable::Pipeline(p) => {
476 assert_eq!(p.steps.len(), 2);
477 assert!(matches!(&p.steps[0], Composable::FanOut(f) if f.branches.len() == 2));
478 assert!(matches!(&p.steps[1], Composable::Agent(a) if a.name() == "merger"));
479 }
480 _ => panic!("expected Pipeline"),
481 }
482 }
483
484 #[test]
485 fn chain_creates_pipeline() {
486 let result = chain(vec![agent("a"), agent("b"), agent("c")]);
487 match result {
488 Composable::Pipeline(p) => assert_eq!(p.steps.len(), 3),
489 _ => panic!("expected Pipeline"),
490 }
491 }
492
493 #[test]
494 fn conditional_creates_fallback_with_guard() {
495 let result = conditional(
496 |state| {
497 state
498 .get("flag")
499 .and_then(serde_json::Value::as_bool)
500 .unwrap_or(false)
501 },
502 agent("yes").instruction("true branch"),
503 agent("no").instruction("false branch"),
504 );
505 match &result {
506 Composable::Fallback(f) => assert_eq!(f.candidates.len(), 2),
507 _ => panic!("expected Fallback"),
508 }
509 }
510
511 #[test]
512 fn supervised_creates_loop() {
513 let result = supervised(agent("worker"), agent("supervisor"), 5);
514 match &result {
515 Composable::Loop(l) => {
516 assert_eq!(l.max, 5);
517 assert!(l.until.is_some());
518 assert!(matches!(&*l.body, Composable::Pipeline(p) if p.steps.len() == 2));
519 }
520 _ => panic!("expected Loop"),
521 }
522 }
523
524 #[test]
525 fn supervised_predicate_checks_approved() {
526 let result = supervised(agent("w"), agent("s"), 5);
527 if let Composable::Loop(l) = result {
528 let pred = l.until.unwrap();
529 assert!(!pred.check(&serde_json::json!({"approved": false})));
530 assert!(pred.check(&serde_json::json!({"approved": true})));
531 }
532 }
533
534 #[test]
535 fn supervised_keyed_predicate_works() {
536 let result = supervised_keyed(agent("w"), agent("s"), "approved", 5);
537 if let Composable::Loop(l) = result {
538 let pred = l.until.unwrap();
539 assert!(!pred.check(&serde_json::json!({"approved": false})));
540 assert!(pred.check(&serde_json::json!({"approved": true})));
541 }
542 }
543
544 #[test]
545 fn map_over_is_a_composable_node() {
546 match map_over(agent("processor"), "items") {
547 Composable::MapOver(m) => {
548 assert_eq!(m.agent.name(), "processor");
549 assert_eq!(m.list_key, "items");
550 assert_eq!(m.item_key, "_item");
551 }
552 other => panic!("expected MapOver, got {other:?}"),
553 }
554 }
555
556 #[test]
557 fn map_reduce_is_map_over_then_reducer() {
558 match map_reduce(agent("mapper"), agent("reducer"), "items") {
559 Composable::Pipeline(p) => {
560 assert_eq!(p.steps.len(), 2);
561 assert!(
562 matches!(&p.steps[0], Composable::MapOver(m) if m.agent.name() == "mapper")
563 );
564 assert!(matches!(&p.steps[1], Composable::Agent(a) if a.name() == "reducer"));
565 }
566 other => panic!("expected Pipeline, got {other:?}"),
567 }
568 }
569
570 #[tokio::test]
571 async fn map_over_compiles_and_runs_per_item() {
572 use gemini_adk_rs::llm::{BaseLlm, LlmError, LlmRequest, LlmResponse};
573 use gemini_genai_rs::prelude::{Content, Part, Role};
574 use std::sync::Arc;
575
576 struct Echo;
577 #[async_trait::async_trait]
578 impl BaseLlm for Echo {
579 fn model_id(&self) -> &str {
580 "echo"
581 }
582 async fn generate(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
583 let text = req
584 .contents
585 .iter()
586 .flat_map(|c| &c.parts)
587 .filter_map(|p| match p {
588 Part::Text { text } => Some(text.clone()),
589 _ => None,
590 })
591 .collect::<Vec<_>>()
592 .join("");
593 Ok(LlmResponse {
594 content: Content {
595 role: Some(Role::Model),
596 parts: vec![Part::Text { text }],
597 },
598 finish_reason: Some("STOP".into()),
599 usage: None,
600 })
601 }
602 }
603
604 let node = map_over(agent("echo"), "items");
605 let compiled = node.compile(Arc::new(Echo)).expect("compiles");
606 let state = gemini_adk_rs::State::new();
607 let _ = state.set("items", serde_json::json!(["a", "b"]));
608 let out = compiled.run(&state).await.expect("runs");
609 assert!(out.contains("\"a\"") && out.contains("\"b\""), "{out}");
610 let results: Vec<String> = state.get("_results").unwrap_or_default();
611 assert_eq!(results.len(), 2);
612 }
613}