1use std::collections::{HashMap, HashSet};
4
5use crate::builder::AgentBuilder;
6
7#[derive(Debug, Clone, PartialEq)]
9pub enum ContractViolation {
10 UnproducedKey {
12 consumer: String,
14 key: String,
16 },
17 DuplicateWrite {
19 agents: Vec<String>,
21 key: String,
23 },
24 OrphanedOutput {
26 producer: String,
28 key: String,
30 },
31}
32
33pub fn check_contracts(agents: &[AgentBuilder]) -> Vec<ContractViolation> {
40 let mut violations = Vec::new();
41
42 let mut all_writes: HashMap<String, Vec<String>> = HashMap::new();
44 let mut all_reads: HashSet<String> = HashSet::new();
45 let mut all_written_keys: HashSet<String> = HashSet::new();
46
47 for agent in agents {
48 for key in agent.get_writes() {
49 all_writes
50 .entry(key.clone())
51 .or_default()
52 .push(agent.name().to_string());
53 all_written_keys.insert(key.clone());
54 }
55 for key in agent.get_reads() {
56 all_reads.insert(key.clone());
57 }
58 }
59
60 for agent in agents {
62 for key in agent.get_reads() {
63 if !all_written_keys.contains(key) {
64 violations.push(ContractViolation::UnproducedKey {
65 consumer: agent.name().to_string(),
66 key: key.clone(),
67 });
68 }
69 }
70 }
71
72 for (key, writers) in &all_writes {
74 if writers.len() > 1 {
75 violations.push(ContractViolation::DuplicateWrite {
76 agents: writers.clone(),
77 key: key.clone(),
78 });
79 }
80 }
81
82 for agent in agents {
84 for key in agent.get_writes() {
85 if !all_reads.contains(key) {
86 violations.push(ContractViolation::OrphanedOutput {
87 producer: agent.name().to_string(),
88 key: key.clone(),
89 });
90 }
91 }
92 }
93
94 violations
95}
96
97pub fn infer_data_flow(agents: &[AgentBuilder]) -> Vec<DataFlowEdge> {
101 let mut edges = Vec::new();
102
103 for producer in agents {
104 for consumer in agents {
105 if producer.name() == consumer.name() {
106 continue;
107 }
108 for write_key in producer.get_writes() {
109 if consumer.get_reads().contains(write_key) {
110 edges.push(DataFlowEdge {
111 producer: producer.name().to_string(),
112 consumer: consumer.name().to_string(),
113 key: write_key.clone(),
114 });
115 }
116 }
117 }
118 }
119
120 edges
121}
122
123#[derive(Debug, Clone, PartialEq)]
125pub struct DataFlowEdge {
126 pub producer: String,
128 pub consumer: String,
130 pub key: String,
132}
133
134pub struct AgentHarness {
136 state: gemini_adk_rs::State,
137}
138
139impl AgentHarness {
140 pub fn new() -> Self {
142 Self {
143 state: gemini_adk_rs::State::new(),
144 }
145 }
146
147 pub fn set<V: serde::Serialize>(self, key: &str, value: V) -> Self {
149 let _ = self.state.set(key, value);
150 self
151 }
152
153 pub fn state(&self) -> &gemini_adk_rs::State {
155 &self.state
156 }
157
158 pub async fn run(
160 &self,
161 agent: &dyn gemini_adk_rs::text::TextAgent,
162 ) -> Result<String, gemini_adk_rs::error::AgentError> {
163 agent.run(&self.state).await
164 }
165}
166
167impl Default for AgentHarness {
168 fn default() -> Self {
169 Self::new()
170 }
171}
172
173pub fn diagnose(agent: &AgentBuilder) -> String {
175 let mut lines = Vec::new();
176 lines.push(format!("Agent: {}", agent.name()));
177
178 if let Some(model) = agent.get_model() {
179 lines.push(format!(" Model: {:?}", model));
180 }
181 if let Some(inst) = agent.get_instruction() {
182 let truncated = if inst.len() > 80 {
183 format!("{}...", &inst[..80])
184 } else {
185 inst.to_string()
186 };
187 lines.push(format!(" Instruction: {}", truncated));
188 }
189 if let Some(t) = agent.get_temperature() {
190 lines.push(format!(" Temperature: {}", t));
191 }
192 if agent.tool_count() > 0 {
193 lines.push(format!(" Tools: {}", agent.tool_count()));
194 }
195 if !agent.get_writes().is_empty() {
196 lines.push(format!(" Writes: {:?}", agent.get_writes()));
197 }
198 if !agent.get_reads().is_empty() {
199 lines.push(format!(" Reads: {:?}", agent.get_reads()));
200 }
201 if !agent.get_sub_agents().is_empty() {
202 lines.push(format!(" Sub-agents: {}", agent.get_sub_agents().len()));
203 }
204
205 lines.join("\n")
206}
207
208#[derive(Debug, Clone, PartialEq)]
218pub enum LiveViolation {
219 FlowToolNotRegistered {
224 tool: String,
226 registered: Vec<String>,
228 },
229 PhasesWithoutInitialPhase {
232 phases: Vec<String>,
234 },
235 UnknownInitialPhase {
238 name: String,
240 defined: Vec<String>,
242 },
243 UnreachablePhase {
246 name: String,
248 },
249 UnknownTransitionTarget {
251 from: String,
253 target: String,
255 },
256 ToolsUnresolvedAtCheckTime {
262 count: usize,
264 },
265}
266
267impl std::fmt::Display for LiveViolation {
268 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269 match self {
270 Self::FlowToolNotRegistered { tool, registered } => write!(
271 f,
272 "the governing flow names tool `{tool}`, which this session does not register. \
273 Registered: [{}]. A step whose `allow` list matches nothing denies every tool \
274 while it is active.",
275 registered.join(", ")
276 ),
277 Self::PhasesWithoutInitialPhase { phases } => write!(
278 f,
279 "{} phase(s) defined ({}) but no `initial_phase(..)` — every one of them is \
280 discarded at connect and the session runs unphased.",
281 phases.len(),
282 phases.join(", ")
283 ),
284 Self::UnknownInitialPhase { name, defined } => write!(
285 f,
286 "`initial_phase(\"{name}\")` names a phase that does not exist. Defined: [{}].",
287 defined.join(", ")
288 ),
289 Self::UnreachablePhase { name } => write!(
290 f,
291 "phase `{name}` is not the initial phase and no transition targets it — it can \
292 never be entered."
293 ),
294 Self::UnknownTransitionTarget { from, target } => write!(
295 f,
296 "phase `{from}` transitions to `{target}`, which does not exist."
297 ),
298 Self::ToolsUnresolvedAtCheckTime { count } => write!(
299 f,
300 "{count} tool(s) resolve at connect (MCP/A2A/OpenAPI), so tool-name checks here \
301 are partial. Re-check after connect, or expect connect to catch the rest."
302 ),
303 }
304 }
305}
306
307pub fn check_live(live: &crate::live::Live) -> Vec<LiveViolation> {
326 let mut violations = Vec::new();
327
328 let registered = live.declared_tool_names();
329 if live.pending_tool_count() > 0 {
330 violations.push(LiveViolation::ToolsUnresolvedAtCheckTime {
331 count: live.pending_tool_count(),
332 });
333 }
334
335 if let Some(flow) = live.flow() {
339 let mut flow = flow.clone();
340 crate::live::merge_ambient_for_check(&mut flow, live.ambient_tool_names());
341 let names: Vec<&str> = registered.iter().map(String::as_str).collect();
342 if let Err(errors) = flow.compile_with_tools(&names) {
343 for error in &errors.0 {
344 if let gemini_adk_rs::flow::FlowError::UnknownTool(tool) = error {
345 violations.push(LiveViolation::FlowToolNotRegistered {
346 tool: tool.clone(),
347 registered: registered.clone(),
348 });
349 }
350 }
351 }
352 }
353
354 let defined: Vec<String> = live.phases().iter().map(|p| p.name.clone()).collect();
356 match live.initial_phase_name() {
357 None if !defined.is_empty() => {
358 violations.push(LiveViolation::PhasesWithoutInitialPhase {
359 phases: defined.clone(),
360 });
361 }
362 Some(initial) if !defined.iter().any(|p| p == initial) => {
363 violations.push(LiveViolation::UnknownInitialPhase {
364 name: initial.to_string(),
365 defined: defined.clone(),
366 });
367 }
368 _ => {}
369 }
370
371 let mut targeted: HashSet<String> = HashSet::new();
372 for phase in live.phases() {
373 for transition in &phase.transitions {
374 targeted.insert(transition.target.clone());
375 if !defined.contains(&transition.target) {
376 violations.push(LiveViolation::UnknownTransitionTarget {
377 from: phase.name.clone(),
378 target: transition.target.clone(),
379 });
380 }
381 }
382 }
383 if let Some(initial) = live.initial_phase_name() {
384 for phase in live.phases() {
385 if phase.name != initial && !targeted.contains(&phase.name) {
386 violations.push(LiveViolation::UnreachablePhase {
387 name: phase.name.clone(),
388 });
389 }
390 }
391 }
392
393 violations
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn no_violations_for_matching_contracts() {
402 let writer = AgentBuilder::new("writer").writes("output");
403 let reader = AgentBuilder::new("reader").reads("output");
404 let violations = check_contracts(&[writer, reader]);
405 assert!(violations.is_empty());
406 }
407
408 #[test]
409 fn detects_unproduced_key() {
410 let reader = AgentBuilder::new("reader").reads("missing");
411 let violations = check_contracts(&[reader]);
412 assert_eq!(violations.len(), 1);
413 assert!(matches!(
414 &violations[0],
415 ContractViolation::UnproducedKey {
416 consumer,
417 key,
418 } if consumer == "reader" && key == "missing"
419 ));
420 }
421
422 #[test]
423 fn detects_duplicate_write() {
424 let a = AgentBuilder::new("a").writes("shared");
425 let b = AgentBuilder::new("b").writes("shared").reads("shared");
426 let violations = check_contracts(&[a, b]);
427 assert!(violations.iter().any(
428 |v| matches!(v, ContractViolation::DuplicateWrite { key, .. } if key == "shared")
429 ));
430 }
431
432 #[test]
433 fn detects_orphaned_output() {
434 let writer = AgentBuilder::new("writer").writes("unused");
435 let violations = check_contracts(&[writer]);
436 assert_eq!(violations.len(), 1);
437 assert!(matches!(
438 &violations[0],
439 ContractViolation::OrphanedOutput {
440 producer,
441 key,
442 } if producer == "writer" && key == "unused"
443 ));
444 }
445
446 #[test]
447 fn multiple_violations() {
448 let a = AgentBuilder::new("a").writes("orphan");
449 let b = AgentBuilder::new("b").reads("missing");
450 let violations = check_contracts(&[a, b]);
451 assert_eq!(violations.len(), 2);
452 }
453
454 #[test]
455 fn empty_agents_no_violations() {
456 let violations = check_contracts(&[]);
457 assert!(violations.is_empty());
458 }
459
460 #[test]
461 fn infer_data_flow_finds_edges() {
462 let writer = AgentBuilder::new("writer").writes("output");
463 let reader = AgentBuilder::new("reader").reads("output");
464 let edges = infer_data_flow(&[writer, reader]);
465 assert_eq!(edges.len(), 1);
466 assert_eq!(edges[0].producer, "writer");
467 assert_eq!(edges[0].consumer, "reader");
468 assert_eq!(edges[0].key, "output");
469 }
470
471 #[test]
472 fn infer_data_flow_no_self_edges() {
473 let agent = AgentBuilder::new("self").writes("key").reads("key");
474 let edges = infer_data_flow(&[agent]);
475 assert!(edges.is_empty());
476 }
477
478 #[test]
479 fn diagnose_basic() {
480 let agent = AgentBuilder::new("test")
481 .instruction("Be helpful")
482 .temperature(0.5)
483 .writes("output");
484 let diag = diagnose(&agent);
485 assert!(diag.contains("test"));
486 assert!(diag.contains("Be helpful"));
487 assert!(diag.contains("0.5"));
488 }
489
490 #[test]
491 fn harness_sets_state() {
492 let harness = AgentHarness::new().set("key", "value");
493 let val: Option<String> = harness.state().get("key");
494 assert_eq!(val, Some("value".into()));
495 }
496
497 #[test]
498 fn complex_pipeline_contracts() {
499 let researcher = AgentBuilder::new("researcher")
500 .writes("findings")
501 .writes("sources");
502 let writer = AgentBuilder::new("writer")
503 .reads("findings")
504 .writes("draft");
505 let reviewer = AgentBuilder::new("reviewer")
506 .reads("draft")
507 .writes("quality");
508
509 let violations = check_contracts(&[researcher, writer, reviewer]);
510 let orphans: Vec<_> = violations
512 .iter()
513 .filter(|v| matches!(v, ContractViolation::OrphanedOutput { .. }))
514 .collect();
515 assert_eq!(orphans.len(), 2);
516 }
517
518 use crate::live::Live;
521 use gemini_adk_rs::flow::{Flow, Guard};
522
523 fn book_tool() -> crate::compose::tools::ToolComposite {
524 crate::compose::T::simple("book_table", "Book a table", |_| async {
525 Ok(serde_json::json!({"ok": true}))
526 })
527 }
528
529 #[test]
530 fn a_clean_session_reports_nothing() {
531 let live = Live::builder()
532 .instruction("Be helpful.")
533 .with_tools(book_tool())
534 .govern(
535 Flow::new()
536 .step("book")
537 .allow(["book_table"])
538 .done(Guard::called_ok("book_table"))
539 .build()
540 .expect("valid"),
541 )
542 .phase("greet")
543 .instruction("Say hello")
544 .done()
545 .initial_phase("greet");
546 assert_eq!(check_live(&live), vec![]);
547 }
548
549 #[test]
550 fn a_flow_tool_typo_is_caught_before_connecting() {
551 let live = Live::builder().with_tools(book_tool()).govern(
552 Flow::new()
553 .step("book")
554 .allow(["book_tabel"])
555 .done(Guard::called_ok("book_tabel"))
556 .build()
557 .expect("valid shape"),
558 );
559 let found = check_live(&live);
560 assert!(
561 found.iter().any(|v| matches!(
562 v,
563 LiveViolation::FlowToolNotRegistered { tool, .. } if tool == "book_tabel"
564 )),
565 "{found:?}"
566 );
567 assert!(
568 found[0].to_string().contains("book_table"),
569 "the message must show what is registered so the typo is visible: {}",
570 found[0]
571 );
572 }
573
574 #[test]
575 fn ambient_tools_are_counted_as_the_session_will_see_them() {
576 let live = Live::builder()
580 .with_tools(book_tool())
581 .ambient_tools(["book_table"])
582 .govern(
583 Flow::new()
584 .step("book")
585 .done(Guard::called_ok("book_table"))
586 .build()
587 .expect("valid"),
588 );
589 assert_eq!(check_live(&live), vec![]);
590 }
591
592 #[test]
593 fn phases_without_an_initial_phase_are_reported_as_discarded() {
594 let live = Live::builder()
597 .phase("greet")
598 .instruction("Say hello")
599 .done()
600 .phase("main")
601 .instruction("Help")
602 .done();
603 let found = check_live(&live);
604 assert!(
605 found.iter().any(|v| matches!(
606 v,
607 LiveViolation::PhasesWithoutInitialPhase { phases } if phases.len() == 2
608 )),
609 "{found:?}"
610 );
611 assert!(found[0].to_string().contains("discarded"), "{}", found[0]);
612 }
613
614 #[test]
615 fn an_initial_phase_naming_nothing_is_reported() {
616 let live = Live::builder()
617 .phase("greet")
618 .instruction("Say hello")
619 .done()
620 .initial_phase("greting");
621 assert!(check_live(&live).iter().any(
622 |v| matches!(v, LiveViolation::UnknownInitialPhase { name, .. } if name == "greting")
623 ));
624 }
625
626 #[test]
627 fn an_unreachable_phase_is_reported() {
628 let live = Live::builder()
629 .phase("greet")
630 .instruction("Say hello")
631 .done()
632 .phase("orphan")
633 .instruction("Never entered")
634 .done()
635 .initial_phase("greet");
636 assert!(check_live(&live)
637 .iter()
638 .any(|v| matches!(v, LiveViolation::UnreachablePhase { name } if name == "orphan")));
639 }
640
641 #[test]
642 fn a_transition_to_a_missing_phase_is_reported() {
643 let live = Live::builder()
644 .phase("greet")
645 .instruction("Say hello")
646 .transition("mian", |_| true)
647 .done()
648 .phase("main")
649 .instruction("Help")
650 .done()
651 .initial_phase("greet");
652 let found = check_live(&live);
653 assert!(
654 found.iter().any(|v| matches!(
655 v,
656 LiveViolation::UnknownTransitionTarget { target, .. } if target == "mian"
657 )),
658 "{found:?}"
659 );
660 }
661
662 #[test]
663 fn an_unphased_session_is_not_reported() {
664 let live = Live::builder().instruction("Be helpful.");
666 assert_eq!(check_live(&live), vec![]);
667 }
668}