1use std::collections::{HashMap, HashSet};
27
28pub use gemini_adk_rs::clock::{Clock, ManualClock, SharedClock, SystemClock};
29pub use gemini_adk_rs::llm::{LlmRequest, LlmResponse, MockLlm, TokenUsage};
30pub use gemini_adk_rs::tape::{
31 FileTape, MemoryTape, Tape, TapeEntry, TapeMode, TapedLlm, taped_resolver,
32};
33
34pub use crate::live::scripted::{ScriptedRun, ScriptedServer};
35
36use crate::builder::AgentBuilder;
37
38#[derive(Debug, Clone, PartialEq)]
40pub enum ContractViolation {
41 UnproducedKey {
43 consumer: String,
45 key: String,
47 },
48 DuplicateWrite {
50 agents: Vec<String>,
52 key: String,
54 },
55 OrphanedOutput {
57 producer: String,
59 key: String,
61 },
62}
63
64pub fn check_contracts(agents: &[AgentBuilder]) -> Vec<ContractViolation> {
71 let mut violations = Vec::new();
72
73 let mut all_writes: HashMap<String, Vec<String>> = HashMap::new();
75 let mut all_reads: HashSet<String> = HashSet::new();
76 let mut all_written_keys: HashSet<String> = HashSet::new();
77
78 for agent in agents {
79 for key in agent.get_writes() {
80 all_writes
81 .entry(key.clone())
82 .or_default()
83 .push(agent.name().to_string());
84 all_written_keys.insert(key.clone());
85 }
86 for key in agent.get_reads() {
87 all_reads.insert(key.clone());
88 }
89 }
90
91 for agent in agents {
93 for key in agent.get_reads() {
94 if !all_written_keys.contains(key) {
95 violations.push(ContractViolation::UnproducedKey {
96 consumer: agent.name().to_string(),
97 key: key.clone(),
98 });
99 }
100 }
101 }
102
103 for (key, writers) in &all_writes {
105 if writers.len() > 1 {
106 violations.push(ContractViolation::DuplicateWrite {
107 agents: writers.clone(),
108 key: key.clone(),
109 });
110 }
111 }
112
113 for agent in agents {
115 for key in agent.get_writes() {
116 if !all_reads.contains(key) {
117 violations.push(ContractViolation::OrphanedOutput {
118 producer: agent.name().to_string(),
119 key: key.clone(),
120 });
121 }
122 }
123 }
124
125 violations
126}
127
128pub fn infer_data_flow(agents: &[AgentBuilder]) -> Vec<DataFlowEdge> {
132 let mut edges = Vec::new();
133
134 for producer in agents {
135 for consumer in agents {
136 if producer.name() == consumer.name() {
137 continue;
138 }
139 for write_key in producer.get_writes() {
140 if consumer.get_reads().contains(write_key) {
141 edges.push(DataFlowEdge {
142 producer: producer.name().to_string(),
143 consumer: consumer.name().to_string(),
144 key: write_key.clone(),
145 });
146 }
147 }
148 }
149 }
150
151 edges
152}
153
154#[derive(Debug, Clone, PartialEq)]
156pub struct DataFlowEdge {
157 pub producer: String,
159 pub consumer: String,
161 pub key: String,
163}
164
165pub struct AgentHarness {
167 state: gemini_adk_rs::State,
168}
169
170impl AgentHarness {
171 pub fn new() -> Self {
173 Self {
174 state: gemini_adk_rs::State::new(),
175 }
176 }
177
178 pub fn set<V: serde::Serialize>(self, key: &str, value: V) -> Self {
180 let _ = self.state.set(key, value);
181 self
182 }
183
184 pub fn state(&self) -> &gemini_adk_rs::State {
186 &self.state
187 }
188
189 pub async fn run(
191 &self,
192 agent: &dyn gemini_adk_rs::text::TextAgent,
193 ) -> Result<String, gemini_adk_rs::error::AgentError> {
194 agent.run(&self.state).await
195 }
196}
197
198impl Default for AgentHarness {
199 fn default() -> Self {
200 Self::new()
201 }
202}
203
204pub fn diagnose(agent: &AgentBuilder) -> String {
206 let mut lines = Vec::new();
207 lines.push(format!("Agent: {}", agent.name()));
208
209 if let Some(model) = agent.get_model() {
210 lines.push(format!(" Model: {model:?}"));
211 }
212 if let Some(inst) = agent.get_instruction() {
213 let truncated = if inst.len() > 80 {
214 format!("{}...", &inst[..80])
215 } else {
216 inst.to_string()
217 };
218 lines.push(format!(" Instruction: {truncated}"));
219 }
220 if let Some(t) = agent.get_temperature() {
221 lines.push(format!(" Temperature: {t}"));
222 }
223 if agent.tool_count() > 0 {
224 lines.push(format!(" Tools: {}", agent.tool_count()));
225 }
226 if !agent.get_writes().is_empty() {
227 lines.push(format!(" Writes: {:?}", agent.get_writes()));
228 }
229 if !agent.get_reads().is_empty() {
230 lines.push(format!(" Reads: {:?}", agent.get_reads()));
231 }
232 if !agent.get_sub_agents().is_empty() {
233 lines.push(format!(" Sub-agents: {}", agent.get_sub_agents().len()));
234 }
235
236 lines.join("\n")
237}
238
239#[derive(Debug, Clone, PartialEq)]
249#[non_exhaustive]
250pub enum LiveViolation {
251 FlowToolNotRegistered {
256 tool: String,
258 registered: Vec<String>,
260 },
261 PhasesWithoutInitialPhase {
264 phases: Vec<String>,
266 },
267 UnknownInitialPhase {
270 name: String,
272 defined: Vec<String>,
274 },
275 UnreachablePhase {
278 name: String,
280 },
281 UnknownTransitionTarget {
283 from: String,
285 target: String,
287 },
288 ToolsUnresolvedAtCheckTime {
294 count: usize,
296 },
297 UnconfirmedTools {
301 tools: Vec<String>,
303 },
304}
305
306impl std::fmt::Display for LiveViolation {
307 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308 match self {
309 Self::FlowToolNotRegistered { tool, registered } => write!(
310 f,
311 "the governing flow names tool `{tool}`, which this session does not register. \
312 Registered: [{}]. A step whose `allow` list matches nothing denies every tool \
313 while it is active.",
314 registered.join(", ")
315 ),
316 Self::PhasesWithoutInitialPhase { phases } => write!(
317 f,
318 "{} phase(s) defined ({}) but no `initial_phase(..)` — every one of them is \
319 discarded at connect and the session runs unphased.",
320 phases.len(),
321 phases.join(", ")
322 ),
323 Self::UnknownInitialPhase { name, defined } => write!(
324 f,
325 "`initial_phase(\"{name}\")` names a phase that does not exist. Defined: [{}].",
326 defined.join(", ")
327 ),
328 Self::UnreachablePhase { name } => write!(
329 f,
330 "phase `{name}` is not the initial phase and no transition targets it — it can \
331 never be entered."
332 ),
333 Self::UnknownTransitionTarget { from, target } => write!(
334 f,
335 "phase `{from}` transitions to `{target}`, which does not exist."
336 ),
337 Self::UnconfirmedTools { tools } => write!(
338 f,
339 "`{}` must be confirmed before running (`T::confirm`), but the session has no \
340 `confirmation_provider(..)`; connect will refuse it.",
341 tools.join("`, `")
342 ),
343 Self::ToolsUnresolvedAtCheckTime { count } => write!(
344 f,
345 "{count} tool(s) resolve at connect (MCP/A2A/OpenAPI), so tool-name checks here \
346 are partial. Re-check after connect, or expect connect to catch the rest."
347 ),
348 }
349 }
350}
351
352pub fn check_live(live: &crate::live::Live) -> Vec<LiveViolation> {
371 let mut violations = Vec::new();
372
373 let registered = live.declared_tool_names();
374 if live.pending_tool_count() > 0 {
375 violations.push(LiveViolation::ToolsUnresolvedAtCheckTime {
376 count: live.pending_tool_count(),
377 });
378 }
379
380 let unconfirmed = live.unconfirmed_tools();
381 if !unconfirmed.is_empty() {
382 violations.push(LiveViolation::UnconfirmedTools { tools: unconfirmed });
383 }
384
385 if let Some(flow) = live.flow() {
389 let mut flow = flow.clone();
390 crate::live::merge_ambient_for_check(&mut flow, live.ambient_tool_names());
391 let names: Vec<&str> = registered.iter().map(String::as_str).collect();
392 if let Err(errors) = flow.compile_with_tools(&names) {
393 for error in &errors.0 {
394 if let gemini_adk_rs::flow::FlowError::UnknownTool(tool) = error {
395 violations.push(LiveViolation::FlowToolNotRegistered {
396 tool: tool.clone(),
397 registered: registered.clone(),
398 });
399 }
400 }
401 }
402 }
403
404 let defined: Vec<String> = live.phases().iter().map(|p| p.name.clone()).collect();
406 match live.initial_phase_name() {
407 None if !defined.is_empty() => {
408 violations.push(LiveViolation::PhasesWithoutInitialPhase {
409 phases: defined.clone(),
410 });
411 }
412 Some(initial) if !defined.iter().any(|p| p == initial) => {
413 violations.push(LiveViolation::UnknownInitialPhase {
414 name: initial.to_string(),
415 defined: defined.clone(),
416 });
417 }
418 _ => {}
419 }
420
421 let mut targeted: HashSet<String> = HashSet::new();
422 for phase in live.phases() {
423 for transition in &phase.transitions {
424 targeted.insert(transition.target.clone());
425 if !defined.contains(&transition.target) {
426 violations.push(LiveViolation::UnknownTransitionTarget {
427 from: phase.name.clone(),
428 target: transition.target.clone(),
429 });
430 }
431 }
432 }
433 if let Some(initial) = live.initial_phase_name() {
434 for phase in live.phases() {
435 if phase.name != initial && !targeted.contains(&phase.name) {
436 violations.push(LiveViolation::UnreachablePhase {
437 name: phase.name.clone(),
438 });
439 }
440 }
441 }
442
443 violations
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
451 fn declared_artifacts_take_part_in_contract_checks() {
452 use crate::compose::artifacts::A;
453 let researcher =
454 AgentBuilder::new("researcher").artifacts(A::json_output("report", "Findings"));
455 let writer = AgentBuilder::new("writer")
456 .artifacts(A::json_input("report", "Findings") + A::text_input("brief", "The brief"));
457 let violations = check_contracts(&[researcher, writer]);
458 assert!(
459 violations.contains(&ContractViolation::UnproducedKey {
460 consumer: "writer".into(),
461 key: "artifact:brief".into(),
462 }),
463 "{violations:?}"
464 );
465 assert!(
466 !violations.iter().any(|v| matches!(
467 v,
468 ContractViolation::UnproducedKey { key, .. } if key == "artifact:report"
469 )),
470 "the report is produced: {violations:?}"
471 );
472 }
473
474 #[test]
475 fn no_violations_for_matching_contracts() {
476 let writer = AgentBuilder::new("writer").writes("output");
477 let reader = AgentBuilder::new("reader").reads("output");
478 let violations = check_contracts(&[writer, reader]);
479 assert!(violations.is_empty());
480 }
481
482 #[test]
483 fn detects_unproduced_key() {
484 let reader = AgentBuilder::new("reader").reads("missing");
485 let violations = check_contracts(&[reader]);
486 assert_eq!(violations.len(), 1);
487 assert!(matches!(
488 &violations[0],
489 ContractViolation::UnproducedKey {
490 consumer,
491 key,
492 } if consumer == "reader" && key == "missing"
493 ));
494 }
495
496 #[test]
497 fn detects_duplicate_write() {
498 let a = AgentBuilder::new("a").writes("shared");
499 let b = AgentBuilder::new("b").writes("shared").reads("shared");
500 let violations = check_contracts(&[a, b]);
501 assert!(violations.iter().any(
502 |v| matches!(v, ContractViolation::DuplicateWrite { key, .. } if key == "shared")
503 ));
504 }
505
506 #[test]
507 fn detects_orphaned_output() {
508 let writer = AgentBuilder::new("writer").writes("unused");
509 let violations = check_contracts(&[writer]);
510 assert_eq!(violations.len(), 1);
511 assert!(matches!(
512 &violations[0],
513 ContractViolation::OrphanedOutput {
514 producer,
515 key,
516 } if producer == "writer" && key == "unused"
517 ));
518 }
519
520 #[test]
521 fn multiple_violations() {
522 let a = AgentBuilder::new("a").writes("orphan");
523 let b = AgentBuilder::new("b").reads("missing");
524 let violations = check_contracts(&[a, b]);
525 assert_eq!(violations.len(), 2);
526 }
527
528 #[test]
529 fn empty_agents_no_violations() {
530 let violations = check_contracts(&[]);
531 assert!(violations.is_empty());
532 }
533
534 #[test]
535 fn infer_data_flow_finds_edges() {
536 let writer = AgentBuilder::new("writer").writes("output");
537 let reader = AgentBuilder::new("reader").reads("output");
538 let edges = infer_data_flow(&[writer, reader]);
539 assert_eq!(edges.len(), 1);
540 assert_eq!(edges[0].producer, "writer");
541 assert_eq!(edges[0].consumer, "reader");
542 assert_eq!(edges[0].key, "output");
543 }
544
545 #[test]
546 fn infer_data_flow_no_self_edges() {
547 let agent = AgentBuilder::new("self").writes("key").reads("key");
548 let edges = infer_data_flow(&[agent]);
549 assert!(edges.is_empty());
550 }
551
552 #[test]
553 fn diagnose_basic() {
554 let agent = AgentBuilder::new("test")
555 .instruction("Be helpful")
556 .temperature(0.5)
557 .writes("output");
558 let diag = diagnose(&agent);
559 assert!(diag.contains("test"));
560 assert!(diag.contains("Be helpful"));
561 assert!(diag.contains("0.5"));
562 }
563
564 #[test]
565 fn harness_sets_state() {
566 let harness = AgentHarness::new().set("key", "value");
567 let val: Option<String> = harness.state().get("key");
568 assert_eq!(val, Some("value".into()));
569 }
570
571 #[test]
572 fn complex_pipeline_contracts() {
573 let researcher = AgentBuilder::new("researcher")
574 .writes("findings")
575 .writes("sources");
576 let writer = AgentBuilder::new("writer")
577 .reads("findings")
578 .writes("draft");
579 let reviewer = AgentBuilder::new("reviewer")
580 .reads("draft")
581 .writes("quality");
582
583 let violations = check_contracts(&[researcher, writer, reviewer]);
584 let orphans: Vec<_> = violations
586 .iter()
587 .filter(|v| matches!(v, ContractViolation::OrphanedOutput { .. }))
588 .collect();
589 assert_eq!(orphans.len(), 2);
590 }
591
592 use crate::live::Live;
595 use gemini_adk_rs::flow::{Flow, Guard};
596
597 fn book_tool() -> crate::compose::tools::ToolComposite {
598 crate::compose::T::simple("book_table", "Book a table", |_| async {
599 Ok(serde_json::json!({"ok": true}))
600 })
601 }
602
603 #[test]
604 fn a_clean_session_reports_nothing() {
605 let live = Live::builder()
606 .instruction("Be helpful.")
607 .tools(book_tool())
608 .govern(
609 Flow::new()
610 .step("book")
611 .allow(["book_table"])
612 .done(Guard::called_ok("book_table"))
613 .build()
614 .expect("valid"),
615 )
616 .phase("greet")
617 .instruction("Say hello")
618 .done()
619 .initial_phase("greet");
620 assert_eq!(check_live(&live), vec![]);
621 }
622
623 #[test]
624 fn a_flow_tool_typo_is_caught_before_connecting() {
625 let live = Live::builder().tools(book_tool()).govern(
626 Flow::new()
627 .step("book")
628 .allow(["book_tabel"])
629 .done(Guard::called_ok("book_tabel"))
630 .build()
631 .expect("valid shape"),
632 );
633 let found = check_live(&live);
634 assert!(
635 found.iter().any(|v| matches!(
636 v,
637 LiveViolation::FlowToolNotRegistered { tool, .. } if tool == "book_tabel"
638 )),
639 "{found:?}"
640 );
641 assert!(
642 found[0].to_string().contains("book_table"),
643 "the message must show what is registered so the typo is visible: {}",
644 found[0]
645 );
646 }
647
648 #[test]
649 fn ambient_tools_are_counted_as_the_session_will_see_them() {
650 let live = Live::builder()
654 .tools(book_tool())
655 .ambient_tools(["book_table"])
656 .govern(
657 Flow::new()
658 .step("book")
659 .done(Guard::called_ok("book_table"))
660 .build()
661 .expect("valid"),
662 );
663 assert_eq!(check_live(&live), vec![]);
664 }
665
666 #[test]
667 fn phases_without_an_initial_phase_are_reported_as_discarded() {
668 let live = Live::builder()
671 .phase("greet")
672 .instruction("Say hello")
673 .done()
674 .phase("main")
675 .instruction("Help")
676 .done();
677 let found = check_live(&live);
678 assert!(
679 found.iter().any(|v| matches!(
680 v,
681 LiveViolation::PhasesWithoutInitialPhase { phases } if phases.len() == 2
682 )),
683 "{found:?}"
684 );
685 assert!(found[0].to_string().contains("discarded"), "{}", found[0]);
686 }
687
688 #[test]
689 fn an_initial_phase_naming_nothing_is_reported() {
690 let live = Live::builder()
691 .phase("greet")
692 .instruction("Say hello")
693 .done()
694 .initial_phase("greting");
695 assert!(check_live(&live).iter().any(
696 |v| matches!(v, LiveViolation::UnknownInitialPhase { name, .. } if name == "greting")
697 ));
698 }
699
700 #[test]
701 fn an_unreachable_phase_is_reported() {
702 let live = Live::builder()
703 .phase("greet")
704 .instruction("Say hello")
705 .done()
706 .phase("orphan")
707 .instruction("Never entered")
708 .done()
709 .initial_phase("greet");
710 assert!(
711 check_live(&live)
712 .iter()
713 .any(|v| matches!(v, LiveViolation::UnreachablePhase { name } if name == "orphan"))
714 );
715 }
716
717 #[test]
718 fn a_transition_to_a_missing_phase_is_reported() {
719 let live = Live::builder()
720 .phase("greet")
721 .instruction("Say hello")
722 .transition("mian", |_| true)
723 .done()
724 .phase("main")
725 .instruction("Help")
726 .done()
727 .initial_phase("greet");
728 let found = check_live(&live);
729 assert!(
730 found.iter().any(|v| matches!(
731 v,
732 LiveViolation::UnknownTransitionTarget { target, .. } if target == "mian"
733 )),
734 "{found:?}"
735 );
736 }
737
738 #[test]
739 fn an_unphased_session_is_not_reported() {
740 let live = Live::builder().instruction("Be helpful.");
742 assert_eq!(check_live(&live), vec![]);
743 }
744}