From 3b6abb675cb88b3325bd2e508d7ced471fec8d1f Mon Sep 17 00:00:00 2001 From: Madhu Date: Wed, 13 Aug 2025 03:22:35 +0530 Subject: [PATCH] Email GTM B2B Agent and 9_2, 9_3 of google adk --- .../ai_email_gtm_outreach_agent/README.md | 59 +++ .../ai_email_gtm_outreach_agent.py | 355 ++++++++++++++++++ .../requirements.txt | 5 + .../9_1_sequential_agent/agent.py | 33 +- .../9_2_loop_agent/.env.example | 3 + .../9_2_loop_agent/README.md | 87 +++++ .../9_2_loop_agent/agent.py | 222 +++++++++++ .../9_2_loop_agent/app.py | 76 ++++ .../9_3_parallel_agent/.env.example | 3 + .../9_3_parallel_agent/README.md | 75 ++++ .../9_3_parallel_agent/agent.py | 116 ++++++ .../9_3_parallel_agent/app.py | 62 +++ .../9_3_parallel_agent/requirements.txt | 5 + 13 files changed, 1090 insertions(+), 11 deletions(-) create mode 100644 advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/README.md create mode 100644 advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/ai_email_gtm_outreach_agent.py create mode 100644 advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/requirements.txt create mode 100644 ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/.env.example create mode 100644 ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/README.md create mode 100644 ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/agent.py create mode 100644 ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/app.py create mode 100644 ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/.env.example create mode 100644 ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/README.md create mode 100644 ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/agent.py create mode 100644 ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/app.py create mode 100644 ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/requirements.txt diff --git a/advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/README.md b/advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/README.md new file mode 100644 index 0000000..26f1ac5 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/README.md @@ -0,0 +1,59 @@ +### AI Email GTM Outreach Agent + +An end-to-end, multi-agent Streamlit app that automates B2B outreach using GPT-5 and Exa. It discovers relevant companies, finds the right contacts (Founder's Office, GTM/Sales leadership, Partnerships/BD, Product Marketing), researches website + Reddit insights, and drafts tailored emails in your selected style. + +## Features + +- **Multi-agent workflow**: + - **Company Finder**: Uses Exa to discover companies matching your targeting and offering. + - **Contact Finder**: Finds 2–3 relevant decision makers per company and emails (marks inferred emails clearly if needed). + - **Researcher**: Pulls 2–4 interesting insights per company from their website and Reddit to enable genuine personalization. + - **Email Writer**: Uses GPT-5 to produce concise, structured outreach emails. + +- **Operator controls**: + - **Number of companies** to target (1–10) + - **Email style**: Professional, Casual, Cold, or Consultative + - Live stage-by-stage progress UI and results with clean section dividers + +- **Security-first**: + - API keys entered in the Streamlit sidebar; not hardcoded or committed + +## Requirements + +Install dependencies from `requirements.txt`: + +```bash +pip install -r advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/requirements.txt +``` + +Required environment variables (set via sidebar or your shell): + +- `OPENAI_API_KEY` +- `EXA_API_KEY` + +## How to Run + +```bash +streamlit run advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/ai_email_gtm_outreach_agent.py +``` + +## Usage + +1. Enter your `OPENAI_API_KEY` and `EXA_API_KEY` in the left sidebar. +2. Provide targeting description and offering. +3. Choose number of companies and an email style. +4. Click “Start Outreach”. Watch the stages: Companies → Contacts → Research → Emails. +5. Review companies, contacts, research insights, and download or copy suggested emails. + +## Notes + +- The app uses the `gpt-5` model via OpenAI. If unavailable in your account, switch the model in `ai_email_gtm_outreach_agent.py` to one you have access to. +- Exa is used for web discovery; ensure your `EXA_API_KEY` is valid. + +## Troubleshooting + +- If the app stalls on a stage, verify your API keys and network connectivity. +- If JSON parsing errors occur, rerun the stage; models occasionally add extra text around JSON. +- For rate limits, reduce number of companies. + + diff --git a/advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/ai_email_gtm_outreach_agent.py b/advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/ai_email_gtm_outreach_agent.py new file mode 100644 index 0000000..31450fc --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/ai_email_gtm_outreach_agent.py @@ -0,0 +1,355 @@ +import json +import os +import sys +from typing import Any, Dict, List, Optional + +import streamlit as st +from agno.agent import Agent +from agno.memory.v2 import Memory +from agno.models.openai import OpenAIChat +from agno.tools.exa import ExaTools + + +def require_env(var_name: str) -> None: + if not os.getenv(var_name): + print(f"Error: {var_name} not set. export {var_name}=...") + sys.exit(1) + + +def create_company_finder_agent() -> Agent: + exa_tools = ExaTools(category="company") + memory = Memory() + return Agent( + model=OpenAIChat(id="gpt-5"), + tools=[exa_tools], + memory=memory, + add_history_to_messages=True, + num_history_responses=6, + session_id="gtm_outreach_company_finder", + show_tool_calls=True, + instructions=[ + "You are CompanyFinderAgent. Use ExaTools to search the web for companies that match the targeting criteria.", + "Return ONLY valid JSON with key 'companies' as a list; respect the requested limit provided in the user prompt.", + "Each item must have: name, website, why_fit (1-2 lines).", + ], + ) + + +def create_contact_finder_agent() -> Agent: + exa_tools = ExaTools() + memory = Memory() + return Agent( + model=OpenAIChat(id="gpt-4o"), + tools=[exa_tools], + memory=memory, + add_history_to_messages=True, + num_history_responses=6, + session_id="gtm_outreach_contact_finder", + show_tool_calls=True, + instructions=[ + "You are ContactFinderAgent. Use ExaTools to find 1-2 relevant decision makers per company and their emails if available.", + "Prioritize roles from Founder's Office, GTM (Marketing/Growth), Sales leadership, Partnerships/Business Development, and Product Marketing.", + "Search queries can include patterns like ' email format', 'contact', 'team', 'leadership', and role titles.", + "If direct emails are not found, infer likely email using common formats (e.g., first.last@domain), but mark inferred=true.", + "Return ONLY valid JSON with key 'companies' as a list; each has: name, contacts: [{full_name, title, email, inferred}]", + ], + ) + + +def get_email_style_instruction(style_key: str) -> str: + styles = { + "Professional": "Style: Professional. Clear, respectful, and businesslike. Short paragraphs; no slang.", + "Casual": "Style: Casual. Friendly, approachable, first-name basis. No slang or emojis; keep it human.", + "Cold": "Style: Cold email. Strong hook in opening 2 lines, tight value proposition, minimal fluff, strong CTA.", + "Consultative": "Style: Consultative. Insight-led, frames observed problems and tailored solution hypotheses; soft CTA.", + } + return styles.get(style_key, styles["Professional"]) + + +def create_email_writer_agent(style_key: str = "Professional") -> Agent: + memory = Memory() + style_instruction = get_email_style_instruction(style_key) + return Agent( + model=OpenAIChat(id="gpt-5"), + tools=[], + memory=memory, + add_history_to_messages=True, + num_history_responses=6, + session_id="gtm_outreach_email_writer", + show_tool_calls=False, + instructions=[ + "You are EmailWriterAgent. Write concise, personalized B2B outreach emails.", + style_instruction, + "Return ONLY valid JSON with key 'emails' as a list of items: {company, contact, subject, body}.", + "Length: 120-160 words. Include 1-2 lines of strong personalization referencing research insights (company website and Reddit findings).", + "CTA: suggest a short intro call; include sender company name and calendar link if provided.", + ], + ) + + +def create_research_agent() -> Agent: + """Agent to gather interesting insights from company websites and Reddit.""" + exa_tools = ExaTools() + memory = Memory() + return Agent( + model=OpenAIChat(id="gpt-5"), + tools=[exa_tools], + memory=memory, + add_history_to_messages=True, + num_history_responses=6, + session_id="gtm_outreach_researcher", + show_tool_calls=True, + instructions=[ + "You are ResearchAgent. For each company, collect concise, valuable insights from:", + "1) Their official website (about, blog, product pages)", + "2) Reddit discussions (site:reddit.com mentions)", + "Summarize 2-4 interesting, non-generic points per company that a human would bring up in an email to show genuine effort.", + "Return ONLY valid JSON with key 'companies' as a list; each has: name, insights: [strings].", + ], + ) + + +def extract_json_or_raise(text: str) -> Dict[str, Any]: + """Extract JSON from a model response. Assumes the response is pure JSON.""" + try: + return json.loads(text) + except Exception as e: + # Try to locate a JSON block if extra text snuck in + start = text.find("{") + end = text.rfind("}") + if start != -1 and end != -1 and end > start: + candidate = text[start : end + 1] + return json.loads(candidate) + raise ValueError(f"Failed to parse JSON: {e}\nResponse was:\n{text}") + + +def run_company_finder(agent: Agent, target_desc: str, offering_desc: str, max_companies: int) -> List[Dict[str, str]]: + prompt = ( + f"Find exactly {max_companies} companies that are a strong B2B fit given the user inputs.\n" + f"Targeting: {target_desc}\n" + f"Offering: {offering_desc}\n" + "For each, provide: name, website, why_fit (1-2 lines)." + ) + resp = agent.run(prompt) + data = extract_json_or_raise(str(resp.content)) + companies = data.get("companies", []) + return companies[: max(1, min(max_companies, 10))] + + +def run_contact_finder(agent: Agent, companies: List[Dict[str, str]], target_desc: str, offering_desc: str) -> List[Dict[str, Any]]: + prompt = ( + "For each company below, find 2-3 relevant decision makers and emails (if available). Ensure at least 2 per company when possible, and cap at 3.\n" + "If not available, infer likely email and mark inferred=true.\n" + f"Targeting: {target_desc}\nOffering: {offering_desc}\n" + f"Companies JSON: {json.dumps(companies, ensure_ascii=False)}\n" + "Return JSON: {companies: [{name, contacts: [{full_name, title, email, inferred}]}]}" + ) + resp = agent.run(prompt) + data = extract_json_or_raise(str(resp.content)) + return data.get("companies", []) + + +def run_research(agent: Agent, companies: List[Dict[str, str]]) -> List[Dict[str, Any]]: + prompt = ( + "For each company, gather 2-4 interesting insights from their website and Reddit that would help personalize outreach.\n" + f"Companies JSON: {json.dumps(companies, ensure_ascii=False)}\n" + "Return JSON: {companies: [{name, insights: [string, ...]}]}" + ) + resp = agent.run(prompt) + data = extract_json_or_raise(str(resp.content)) + return data.get("companies", []) + + +def run_email_writer(agent: Agent, contacts_data: List[Dict[str, Any]], research_data: List[Dict[str, Any]], offering_desc: str, sender_name: str, sender_company: str, calendar_link: Optional[str]) -> List[Dict[str, str]]: + prompt = ( + "Write personalized outreach emails for the following contacts.\n" + f"Sender: {sender_name} at {sender_company}.\n" + f"Offering: {offering_desc}.\n" + f"Calendar link: {calendar_link or 'N/A'}.\n" + f"Contacts JSON: {json.dumps(contacts_data, ensure_ascii=False)}\n" + f"Research JSON: {json.dumps(research_data, ensure_ascii=False)}\n" + "Return JSON with key 'emails' as a list of {company, contact, subject, body}." + ) + resp = agent.run(prompt) + data = extract_json_or_raise(str(resp.content)) + return data.get("emails", []) + + +def run_pipeline(target_desc: str, offering_desc: str, sender_name: str, sender_company: str, calendar_link: Optional[str], num_companies: int): + company_agent = create_company_finder_agent() + contact_agent = create_contact_finder_agent() + research_agent = create_research_agent() + + companies = run_company_finder(company_agent, target_desc, offering_desc, max_companies=num_companies) + contacts_data = run_contact_finder(contact_agent, companies, target_desc, offering_desc) if companies else [] + research_data = run_research(research_agent, companies) if companies else [] + return { + "companies": companies, + "contacts": contacts_data, + "research": research_data, + "emails": [], + } + + +def main() -> None: + st.set_page_config(page_title="GTM B2B Outreach", layout="wide") + + # Sidebar: API keys + st.sidebar.header("API Configuration") + openai_key = st.sidebar.text_input("OpenAI API Key", type="password", value=os.getenv("OPENAI_API_KEY", "")) + exa_key = st.sidebar.text_input("Exa API Key", type="password", value=os.getenv("EXA_API_KEY", "")) + if openai_key: + os.environ["OPENAI_API_KEY"] = openai_key + if exa_key: + os.environ["EXA_API_KEY"] = exa_key + + if not openai_key or not exa_key: + st.sidebar.warning("Enter both API keys to enable the app") + + # Inputs + st.title("GTM B2B Outreach Multi Agent Team") + st.info( + "GTM teams often need to reach out for demos and discovery calls, but manual research and personalization is slow. " + "This app uses GPT-5 with a multi-agent workflow to find target companies, identify contacts, research genuine insights (website + Reddit), " + "and generate tailored outreach emails in your chosen style." + ) + col1, col2 = st.columns(2) + with col1: + target_desc = st.text_area("Target companies (industry, size, region, tech, etc.)", height=100) + offering_desc = st.text_area("Your product/service offering (1-3 sentences)", height=100) + with col2: + sender_name = st.text_input("Your name", value="Sales Team") + sender_company = st.text_input("Your company", value="Our Company") + calendar_link = st.text_input("Calendar link (optional)", value="") + num_companies = st.number_input("Number of companies", min_value=1, max_value=10, value=5) + email_style = st.selectbox( + "Email style", + options=["Professional", "Casual", "Cold", "Consultative"], + index=0, + help="Choose the tone/format for the generated emails", + ) + + if st.button("Start Outreach", type="primary"): + # Validate + if not openai_key or not exa_key: + st.error("Please provide API keys in the sidebar") + elif not target_desc or not offering_desc: + st.error("Please fill in target companies and offering") + else: + # Stage-by-stage progress UI + progress = st.progress(0) + stage_msg = st.empty() + details = st.empty() + try: + # Prepare agents + company_agent = create_company_finder_agent() + contact_agent = create_contact_finder_agent() + research_agent = create_research_agent() + email_agent = create_email_writer_agent(email_style) + + # 1. Companies + stage_msg.info("1/4 Finding companies...") + companies = run_company_finder( + company_agent, + target_desc.strip(), + offering_desc.strip(), + max_companies=int(num_companies), + ) + progress.progress(25) + details.write(f"Found {len(companies)} companies") + + # 2. Contacts + stage_msg.info("2/4 Finding contacts (2–3 per company)...") + contacts_data = run_contact_finder( + contact_agent, + companies, + target_desc.strip(), + offering_desc.strip(), + ) if companies else [] + progress.progress(50) + details.write(f"Collected contacts for {len(contacts_data)} companies") + + # 3. Research + stage_msg.info("3/4 Researching insights (website + Reddit)...") + research_data = run_research(research_agent, companies) if companies else [] + progress.progress(75) + details.write(f"Compiled research for {len(research_data)} companies") + + # 4. Emails + stage_msg.info("4/4 Writing personalized emails...") + emails = run_email_writer( + email_agent, + contacts_data, + research_data, + offering_desc.strip(), + sender_name.strip() or "Sales Team", + sender_company.strip() or "Our Company", + calendar_link.strip() or None, + ) if contacts_data else [] + progress.progress(100) + details.write(f"Generated {len(emails)} emails") + + st.session_state["gtm_results"] = { + "companies": companies, + "contacts": contacts_data, + "research": research_data, + "emails": emails, + } + stage_msg.success("Completed") + except Exception as e: + stage_msg.error("Pipeline failed") + st.error(f"{e}") + + # Show results if present + results = st.session_state.get("gtm_results") + if results: + companies = results.get("companies", []) + contacts = results.get("contacts", []) + research = results.get("research", []) + emails = results.get("emails", []) + + st.subheader("Top target companies") + if companies: + for idx, c in enumerate(companies, 1): + st.markdown(f"**{idx}. {c.get('name','')}** ") + st.write(c.get("website", "")) + st.write(c.get("why_fit", "")) + else: + st.info("No companies found") + st.divider() + + st.subheader("Contacts found") + if contacts: + for c in contacts: + st.markdown(f"**{c.get('name','')}**") + for p in c.get("contacts", [])[:3]: + inferred = " (inferred)" if p.get("inferred") else "" + st.write(f"- {p.get('full_name','')} | {p.get('title','')} | {p.get('email','')}{inferred}") + else: + st.info("No contacts found") + st.divider() + + st.subheader("Research insights") + if research: + for r in research: + st.markdown(f"**{r.get('name','')}**") + for insight in r.get("insights", [])[:4]: + st.write(f"- {insight}") + else: + st.info("No research insights") + st.divider() + + st.subheader("Suggested Outreach Emails") + if emails: + for i, e in enumerate(emails, 1): + with st.expander(f"{i}. {e.get('company','')} → {e.get('contact','')}"): + st.write(f"Subject: {e.get('subject','')}") + st.text(e.get("body", "")) + else: + st.info("No emails generated") + + +if __name__ == "__main__": + main() + + diff --git a/advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/requirements.txt b/advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/requirements.txt new file mode 100644 index 0000000..08fd7e2 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_email_gtm_outreach_agent/requirements.txt @@ -0,0 +1,5 @@ +agno>=0.4.2 +streamlit>=1.33.0 +pydantic>=2.7.0 +openai>=1.30.0 +exa_py>=1.0.7 \ No newline at end of file diff --git a/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_1_sequential_agent/agent.py b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_1_sequential_agent/agent.py index 25cfeec..95aff12 100644 --- a/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_1_sequential_agent/agent.py +++ b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_1_sequential_agent/agent.py @@ -1,5 +1,6 @@ import os import asyncio +import inspect from dotenv import load_dotenv from google.adk.agents import LlmAgent, SequentialAgent from google.adk.tools import google_search @@ -112,19 +113,22 @@ async def analyze_business_intelligence(user_id: str, business_topic: str) -> st """Process business intelligence through the sequential pipeline""" session_id = f"bi_session_{user_id}" - # Create or get session - session = await session_service.get_session( + # Support both sync and async session service + async def _maybe_await(value): + return await value if inspect.isawaitable(value) else value + + session = await _maybe_await(session_service.get_session( app_name="business_intelligence", user_id=user_id, session_id=session_id - ) + )) if not session: - session = await session_service.create_session( + session = await _maybe_await(session_service.create_session( app_name="business_intelligence", user_id=user_id, session_id=session_id, state={"business_topic": business_topic, "conversation_history": []} - ) + )) # Create user content user_content = types.Content( @@ -132,15 +136,22 @@ async def analyze_business_intelligence(user_id: str, business_topic: str) -> st parts=[types.Part(text=f"Please analyze this business topic: {business_topic}")] ) - # Run the sequential pipeline + # Run the sequential pipeline (support async or sync stream) response_text = "" - async for event in runner.run_async( + stream = runner.run_async( user_id=user_id, session_id=session_id, new_message=user_content - ): - if event.is_final_response(): - if event.content and event.content.parts: - response_text = event.content.parts[0].text + ) + if inspect.isasyncgen(stream): + async for event in stream: + if event.is_final_response(): + if event.content and event.content.parts: + response_text = event.content.parts[0].text + else: + for event in stream: + if getattr(event, "is_final_response", lambda: False)(): + if event.content and event.content.parts: + response_text = event.content.parts[0].text return response_text diff --git a/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/.env.example b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/.env.example new file mode 100644 index 0000000..f5cfcfb --- /dev/null +++ b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/.env.example @@ -0,0 +1,3 @@ +# If using Gemini via Google AI Studio +GOOGLE_GENAI_USE_VERTEXAI=False +GOOGLE_API_KEY="your-api-key" \ No newline at end of file diff --git a/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/README.md b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/README.md new file mode 100644 index 0000000..5e2e76f --- /dev/null +++ b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/README.md @@ -0,0 +1,87 @@ +# 🔁 Tutorial 9.2: Loop Agents - Iterative Plan Refiner + +## 🎯 What You'll Learn + +- **Loop Agent Composition**: Execute sub-agents sequentially in a loop +- **Stateful Iterations**: Persist counters and flags across iterations +- **Termination Conditions**: Stop by reaching a max or when a sub-agent escalates +- **Streamlit Web Interface**: Interactive UI to run iterative refinements + +## 🧠 Core Concept: LoopAgent with Condition + +According to the ADK workflow agents documentation, **LoopAgent** repeats a set of sub-agents while sharing the same context/state across iterations. This tutorial demonstrates an **Iterative Plan Refiner** that improves a plan over multiple iterations and stops when a condition is met. + +``` +Topic → LoopAgent → [Refine Plan] → [Increment Iteration] → [Check Completion] + ↑ │ + └──────────────────────────── Repeat until stop ─────────┘ +``` + +**Termination**: The loop stops if the optional `max_iterations` is reached, or if any sub-agent returns an `Event` with `escalate=True` in its `EventActions`. + +**Context & State**: The same `InvocationContext` and `session.state` are used across iterations, allowing values like `iteration`, `target_iterations`, and `accepted` to persist and control the loop. + +## 📁 Project Structure + +``` +9_2_loop agent/ +├── agent.py # LoopAgent with 3 sub-agents and session-state control +├── app.py # Streamlit UI to run the loop refinement +└── README.md # This documentation +``` + +## 🚀 Getting Started + +### 1. Install Dependencies +```bash +cd "9_2_loop agent" +pip install -r ../9_1_sequential_agent/requirements.txt +``` + +### 2. Set Up Environment +Create a `.env` file with your Google API key (or reuse from the sequential example): +```bash +echo "GOOGLE_API_KEY=your_ai_studio_key_here" > .env +``` + +### 3. Run the Streamlit App +```bash +streamlit run app.py +``` + +## 🧪 How It Works + +- **plan_refiner (LlmAgent)**: Produces a concise, improved plan each iteration. +- **increment_iteration (BaseAgent)**: Increments `session.state['iteration']`. +- **check_completion (BaseAgent)**: Escalates (to stop) if `accepted=True` or `iteration >= target_iterations`. + +The `LoopAgent` sequences these sub-agents on every iteration, persisting and updating state until a stop condition is met. + +### Session State Keys +- **topic**: The subject being refined. +- **iteration**: Current iteration counter. +- **target_iterations**: Loop budget before stopping. +- **accepted**: When set to `True`, the loop stops immediately. + +## 🧪 Try It +- Enter a topic (e.g., "AI-powered customer support platform launch plan"). +- Set `Target iterations` to 3–5. +- Run and observe the final refined plan and run metadata. + +## 🔧 ADK Concepts Demonstrated +- **LoopAgent pattern** with sequential sub-agents. +- **Session state persistence** across iterations. +- **Escalation-based termination** with `EventActions(escalate=True)`. +- **Runner + SessionService** execution pattern. + +## 🔎 Troubleshooting +- Ensure `GOOGLE_API_KEY` is set in `.env`. +- Run from the directory containing `app.py`. +- If you previously ran the app, the same session id is reused; changing the topic or target updates state accordingly. + +## 📚 Key Takeaways +- **LoopAgent** enables iterative refinement workflows. +- **Shared state** allows complex control signals to accumulate across iterations. +- **Clean, modular sub-agents** keep the loop logic clear and maintainable. + + diff --git a/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/agent.py b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/agent.py new file mode 100644 index 0000000..647d836 --- /dev/null +++ b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/agent.py @@ -0,0 +1,222 @@ +import os +import asyncio +import inspect +from typing import AsyncGenerator, Dict, Any + +from dotenv import load_dotenv +from google.adk.agents import LlmAgent, LoopAgent +from google.adk.agents.base_agent import BaseAgent +from google.adk.agents.invocation_context import InvocationContext +from google.adk.sessions import InMemorySessionService +from google.adk.runners import Runner +from google.adk.events import Event, EventActions +from google.genai import types + + +# Load environment variables +load_dotenv() + + +# ------------------------------------------------------------ +# Sub-agent 1: LLM refiner that improves the plan each iteration +# ------------------------------------------------------------ +plan_refiner = LlmAgent( + name="plan_refiner", + model="gemini-2.5-flash", + description="Iteratively refines a brief product/launch plan given topic and prior context", + instruction=( + "You are an iterative planner. On each turn:\n" + "- Improve and tighten the current plan for the topic in session state\n" + "- Keep it concise (5-8 bullets) and avoid repeating prior text verbatim\n" + "- Incorporate clarity, feasibility, and crisp sequencing\n" + "- Assume this output will be refined again in subsequent iterations\n\n" + "Output format:\n" + "Title line\n" + "- Bullet 1\n- Bullet 2\n- Bullet 3 ..." + ), +) + + +# ------------------------------------------------------------ +# Sub-agent 2: Progress tracker increments iteration counter +# ------------------------------------------------------------ +class IncrementIteration(BaseAgent): + async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + current_iteration = int(ctx.session.state.get("iteration", 0)) + 1 + ctx.session.state["iteration"] = current_iteration + yield Event( + author=self.name, + content=types.Content( + role="model", + parts=[ + types.Part( + text=f"Iteration advanced to {current_iteration}" + ) + ], + ), + ) + + +# ------------------------------------------------------------ +# Sub-agent 3: Completion check with optional early stop +# - Stops if iteration >= target_iterations OR session flag 'accepted' is True +# ------------------------------------------------------------ +class CheckCompletion(BaseAgent): + async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + target_iterations = int(ctx.session.state.get("target_iterations", 3)) + current_iteration = int(ctx.session.state.get("iteration", 0)) + accepted = bool(ctx.session.state.get("accepted", False)) + + reached_limit = current_iteration >= target_iterations + should_stop = accepted or reached_limit + + yield Event( + author=self.name, + actions=EventActions(escalate=should_stop), + content=types.Content( + role="model", + parts=[ + types.Part( + text=( + "Stopping criteria met" + if should_stop + else "Continuing loop" + ) + ) + ], + ), + ) + + +increment_iteration = IncrementIteration(name="increment_iteration") +check_completion = CheckCompletion(name="check_completion") + + +# ------------------------------------------------------------ +# LoopAgent: Executes sub-agents sequentially in a loop +# - Termination: max_iterations, or CheckCompletion escalates +# - Context & State: Same InvocationContext across iterations +# ------------------------------------------------------------ +spec_refinement_loop = LoopAgent( + name="spec_refinement_loop", + description=( + "Iteratively refines a plan using LLM, tracks iterations, and stops when target iterations " + "are reached or an 'accepted' flag is set in session state." + ), + max_iterations=10, + sub_agents=[ + plan_refiner, + increment_iteration, + check_completion, + ], +) + + +# ------------------------------------------------------------ +# Runner setup +# ------------------------------------------------------------ +session_service = InMemorySessionService() +runner = Runner( + agent=spec_refinement_loop, + app_name="loop_refinement_app", + session_service=session_service, +) + + +# ------------------------------------------------------------ +# Public API: run the loop refinement for a topic +# ------------------------------------------------------------ +async def iterate_spec_until_acceptance( + user_id: str, topic: str, target_iterations: int = 3 +) -> Dict[str, Any]: + """Run the LoopAgent to iteratively refine a plan. + + Returns a dictionary with final plan text and iteration metadata. + """ + session_id = f"loop_refinement_{user_id}" + + async def _maybe_await(value): + return await value if inspect.isawaitable(value) else value + + # Create or get session (support both sync/async services) + session = await _maybe_await(session_service.get_session( + app_name="loop_refinement_app", + user_id=user_id, + session_id=session_id, + )) + if not session: + session = await _maybe_await(session_service.create_session( + app_name="loop_refinement_app", + user_id=user_id, + session_id=session_id, + state={ + "topic": topic, + "iteration": 0, + "target_iterations": int(target_iterations), + # Optionally, an external process or UI could set this to True to stop early + "accepted": False, + }, + )) + else: + # Refresh topic/target if user re-runs on UI + if hasattr(session, "state") and isinstance(session.state, dict): + session.state["topic"] = topic + session.state["target_iterations"] = int(target_iterations) + + # Seed message for LLM + user_content = types.Content( + role="user", + parts=[ + types.Part( + text=( + "Topic: " + + topic + + "\nPlease produce or refine a concise plan." + ) + ) + ], + ) + + final_text = "" + last_plan_text = "" + stream = runner.run_async(user_id=user_id, session_id=session_id, new_message=user_content) + # Support both async generators and plain iterables + if inspect.isasyncgen(stream): + async for event in stream: + if event.content and getattr(event.content, "parts", None): + for part in event.content.parts: + if hasattr(part, "text") and part.text: + # Keep last text from plan_refiner preferentially + if getattr(event, "author", "") == plan_refiner.name: + last_plan_text = part.text + if event.is_final_response(): + final_text = part.text + else: + for event in stream: + if event.content and getattr(event.content, "parts", None): + for part in event.content.parts: + if hasattr(part, "text") and part.text: + if getattr(event, "author", "") == plan_refiner.name: + last_plan_text = part.text + # final events in sync mode + final_text = part.text + if event.content and getattr(event.content, "parts", None): + for part in event.content.parts: + if hasattr(part, "text") and part.text: + # Keep last text from plan_refiner preferentially + if getattr(event, "author", "") == plan_refiner.name: + last_plan_text = part.text + if event.is_final_response(): + final_text = part.text + + current_iteration = int(session.state.get("iteration", 0)) + reached = current_iteration >= int(session.state.get("target_iterations", 0)) + accepted = bool(session.state.get("accepted", False)) + + return { + "final_plan": last_plan_text or final_text, + "iterations": current_iteration, + "stopped_reason": "accepted" if accepted else ("target_iterations" if reached else "max_iterations_or_other"), + } + + diff --git a/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/app.py b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/app.py new file mode 100644 index 0000000..aaa272d --- /dev/null +++ b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_2_loop_agent/app.py @@ -0,0 +1,76 @@ +import streamlit as st +import asyncio +from agent import iterate_spec_until_acceptance + + +st.set_page_config(page_title="Loop Agent Demo", page_icon=":repeat:", layout="wide") + +st.title("🔁 Iterative Plan Refiner (Loop Agent)") +st.markdown( + """ +This demo runs a LoopAgent that repeatedly executes sub-agents to iteratively refine a plan. + +Loop characteristics: +- Executes its sub-agents sequentially in a loop +- Terminates when the session's `accepted` flag is set or after the target iterations +- Shares the same session state across iterations, so counters/flags persist + """ +) + +user_id = "demo_loop_user" + +st.header("Run an iterative refinement") +topic = st.text_area( + "Topic", + value="AI-powered customer support platform launch plan", + height=100, + placeholder="What plan/topic should be refined iteratively?", +) + +col_a, col_b = st.columns([1, 1]) +with col_a: + target_iterations = st.number_input( + "Target iterations (early stop possible)", min_value=1, max_value=20, value=3, step=1 + ) +with col_b: + st.caption( + "Set a reasonable number of iterations. The loop may stop earlier if the session state flag `accepted` becomes True." + ) + +if st.button("Run Loop Refinement", type="primary"): + if topic.strip(): + st.info("Refining plan in a loop…") + with st.spinner("Working…"): + try: + results = asyncio.run( + iterate_spec_until_acceptance(user_id, topic, int(target_iterations)) + ) + st.success("Loop finished") + + st.subheader("Final Refined Plan") + st.write(results.get("final_plan", "")) + + st.subheader("Run Metadata") + st.write({ + "iterations": results.get("iterations"), + "stopped_reason": results.get("stopped_reason"), + }) + except Exception as e: + st.error(f"Error: {e}") + else: + st.error("Please enter a topic") + +with st.sidebar: + st.header("How it works") + st.markdown( + """ + - Uses `LoopAgent` with 3 sub-agents: + 1) `plan_refiner` (LLM) refines the plan + 2) `increment_iteration` updates the iteration counter in session state + 3) `check_completion` escalates when done (accepted flag or target reached) + - The same `InvocationContext` and session state are reused every iteration + - The loop stops if `accepted` is True or the `target_iterations` is reached. + """ + ) + + diff --git a/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/.env.example b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/.env.example new file mode 100644 index 0000000..f5cfcfb --- /dev/null +++ b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/.env.example @@ -0,0 +1,3 @@ +# If using Gemini via Google AI Studio +GOOGLE_GENAI_USE_VERTEXAI=False +GOOGLE_API_KEY="your-api-key" \ No newline at end of file diff --git a/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/README.md b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/README.md new file mode 100644 index 0000000..a4c270c --- /dev/null +++ b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/README.md @@ -0,0 +1,75 @@ +# ⚡ Tutorial 9.3: Parallel Agents - Market Snapshot Team + +## 🎯 What You'll Learn + +- **Parallel Agent Composition**: How to orchestrate multiple specialized agents concurrently +- **Shared State**: How parallel children write to a common `session.state` safely +- **Branching Context**: Invocation branches for clean, isolated tool/memory context +- **Streamlit Interface**: A simple UI to run and visualize parallel results + +## 🧠 Core Concept: ParallelAgent with Shared State + +According to the ADK docs, **Parallel Agents** execute their sub-agents concurrently. Each child runs on its own invocation branch but shares the same `session.state`. + +``` +Topic → ParallelAgent → 3 Sub-agents (Concurrent Execution) + ↓ + [Market Trends] + [Competitors] + [Funding News] + ↓ + Snapshot in state +``` + +Each child agent writes results to a distinct key in shared state to avoid overwrites: `market_trends`, `competitors`, `funding_news`. + +## 📁 Project Structure + +``` +9_3_parallel agent/ +├── agent.py # Parallel workflow (3 research agents + ParallelAgent) +├── app.py # Streamlit UI to run and view snapshot +├── requirements.txt # Python dependencies +├── README.md # This documentation +└── .env.example # Example environment variables +``` + +## 🚀 Getting Started + +### 1. Install Dependencies +```bash +cd "9_3_parallel agent" +pip install -r requirements.txt +``` + +### 2. Set Up Environment +Create a `.env` file with your Google API key: +```bash +echo "GOOGLE_API_KEY=your_ai_studio_key_here" > .env +``` + +> Get your key from Google AI Studio. + +### 3. Run the Streamlit App +```bash +streamlit run app.py +``` + +## 🧪 How It Works + +- `ParallelAgent` executes `market_trends_agent`, `competitor_intel_agent`, and `funding_news_agent` concurrently. +- Each child uses web search and writes to a unique `output_key` in `session.state`. +- The UI reads `session.state` and displays a 3-column snapshot. + +## 🔧 ADK Concepts Demonstrated + +- ParallelAgent pattern and event interleaving +- Shared `session.state` with distinct keys per child +- Invocation branches for contextual separation +- Runner + Session services for execution + +## 📚 Key Takeaways + +- Parallel fan-out is ideal for independent data gathering +- Keep output keys distinct to avoid overwrites in shared state +- Combine with a downstream synthesizer agent if you need a single report + + diff --git a/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/agent.py b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/agent.py new file mode 100644 index 0000000..4d89d32 --- /dev/null +++ b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/agent.py @@ -0,0 +1,116 @@ +from typing import Dict, Any +import inspect +from dotenv import load_dotenv +from google.adk.agents import LlmAgent, ParallelAgent +from google.adk.sessions import InMemorySessionService +from google.adk.runners import Runner +from google.genai import types + +load_dotenv() + +# Child agents write to distinct keys in session.state for UI consumption +market_trends_agent = LlmAgent( + name="market_trends_agent", + model="gemini-2.5-flash", + description="Summarizes recent market trends for the topic", + instruction=( + "Summarize 3-5 recent market trends for the topic in session.state['topic'].\n" + "Output a concise markdown list." + ), +) + +competitor_intel_agent = LlmAgent( + name="competitor_intel_agent", + model="gemini-2.5-flash", + description="Identifies key competitors and positioning", + instruction=( + "List 3-5 notable competitors for session.state['topic'] and describe their positioning briefly." + ), +) + +funding_news_agent = LlmAgent( + name="funding_news_agent", + model="gemini-2.5-flash", + description="Reports funding/partnership news", + instruction=( + "Provide a short digest (bulleted) of recent funding or partnership news related to session.state['topic']." + ), +) + +# Parallel orchestrator +market_snapshot_team = ParallelAgent( + name="market_snapshot_team", + description="Runs multiple research agents concurrently to produce a market snapshot", + sub_agents=[ + market_trends_agent, + competitor_intel_agent, + funding_news_agent, + ], +) + +# Runner and session service +session_service = InMemorySessionService() +runner = Runner(agent=market_snapshot_team, app_name="parallel_snapshot_app", session_service=session_service) + + +async def gather_market_snapshot(user_id: str, topic: str) -> Dict[str, Any]: + """Execute the parallel agents and return combined snapshot text blocks. + + Returns keys: 'market_trends', 'competitors', 'funding_news'. + """ + session_id = f"parallel_snapshot_{user_id}" + + async def _maybe_await(v): + return await v if inspect.isawaitable(v) else v + + session = await _maybe_await( + session_service.get_session( + app_name="parallel_snapshot_app", user_id=user_id, session_id=session_id + ) + ) + if not session: + session = await _maybe_await( + session_service.create_session( + app_name="parallel_snapshot_app", + user_id=user_id, + session_id=session_id, + state={"topic": topic}, + ) + ) + else: + if hasattr(session, "state") and isinstance(session.state, dict): + session.state["topic"] = topic + + user_content = types.Content( + role="user", + parts=[types.Part(text=f"Topic: {topic}. Provide a concise snapshot per agent focus.")], + ) + + # Collect last text emitted per agent + last_text_by_agent: Dict[str, str] = {} + + stream = runner.run_async(user_id=user_id, session_id=session_id, new_message=user_content) + if inspect.isasyncgen(stream): + async for event in stream: + if getattr(event, "content", None) and getattr(event.content, "parts", None): + for part in event.content.parts: + if hasattr(part, "text") and part.text: + author = getattr(event, "author", "") + if author: + last_text_by_agent[author] = part.text + else: + for event in stream: + if getattr(event, "content", None) and getattr(event.content, "parts", None): + for part in event.content.parts: + if hasattr(part, "text") and part.text: + author = getattr(event, "author", "") + if author: + last_text_by_agent[author] = part.text + + return { + "market_trends": last_text_by_agent.get(market_trends_agent.name, ""), + "competitors": last_text_by_agent.get(competitor_intel_agent.name, ""), + "funding_news": last_text_by_agent.get(funding_news_agent.name, ""), + } + + diff --git a/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/app.py b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/app.py new file mode 100644 index 0000000..7b33f8d --- /dev/null +++ b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/app.py @@ -0,0 +1,62 @@ +import streamlit as st +import asyncio +from agent import market_snapshot_team, gather_market_snapshot + +st.set_page_config(page_title="Parallel Agent Demo", page_icon=":fast_forward:", layout="wide") + +st.title("⚡ Market Snapshot (Parallel Agents)") +st.markdown( + """ +This demo runs multiple research agents in parallel using a ParallelAgent: + +- Market trends analysis +- Competitor intelligence +- Funding and partnerships news + +Each sub-agent writes its results into a shared session.state under distinct keys. A subsequent step (or this UI) can read the combined snapshot. +""" +) + +user_id = "demo_parallel_user" + +st.header("Run a market snapshot") +topic = st.text_input( + "Research topic", + value="AI-powered customer support platforms", + placeholder="What market/topic do you want a quick parallel snapshot on?", +) + +if st.button("Run Parallel Research", type="primary"): + if topic.strip(): + st.info("Running parallel agents… market trends, competitors, and funding news") + with st.spinner("Gathering snapshot…"): + try: + results = asyncio.run(gather_market_snapshot(user_id, topic)) + st.success("Snapshot ready") + + col1, col2, col3 = st.columns(3) + with col1: + st.subheader("Market Trends") + st.write(results.get("market_trends", "")) + with col2: + st.subheader("Competitors") + st.write(results.get("competitors", "")) + with col3: + st.subheader("Funding News") + st.write(results.get("funding_news", "")) + except Exception as e: + st.error(f"Error: {e}") + else: + st.error("Please enter a topic") + +with st.sidebar: + st.header("How it works") + st.markdown( + """ + - Uses `ParallelAgent` to execute sub-agents concurrently + - Each child runs on its own invocation branch, but shares the same session.state + - Distinct `output_key`s prevent overwrites in the shared state + - This pattern is ideal for fan-out data gathering before synthesis + """ + ) + diff --git a/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/requirements.txt b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/requirements.txt new file mode 100644 index 0000000..f543388 --- /dev/null +++ b/ai_agent_framework_crash_course/google_adk_crash_course/9_multi_agent_patterns/9_3_parallel_agent/requirements.txt @@ -0,0 +1,5 @@ +google-adk>=1.9.0 +streamlit>=1.28.0 +python-dotenv>=1.1.1 +pydantic>=2.0.0 +