Email GTM B2B Agent and 9_2, 9_3 of google adk

This commit is contained in:
Madhu 2025-08-13 03:22:35 +05:30
parent d1a248088a
commit 3b6abb675c
13 changed files with 1090 additions and 11 deletions

View file

@ -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 23 relevant decision makers per company and emails (marks inferred emails clearly if needed).
- **Researcher**: Pulls 24 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 (110)
- **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.

View file

@ -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 '<Company> 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 (23 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()

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,3 @@
# If using Gemini via Google AI Studio
GOOGLE_GENAI_USE_VERTEXAI=False
GOOGLE_API_KEY="your-api-key"

View file

@ -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 35.
- 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.

View file

@ -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"),
}

View file

@ -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.
"""
)

View file

@ -0,0 +1,3 @@
# If using Gemini via Google AI Studio
GOOGLE_GENAI_USE_VERTEXAI=False
GOOGLE_API_KEY="your-api-key"

View file

@ -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

View file

@ -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, ""),
}

View file

@ -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
"""
)

View file

@ -0,0 +1,5 @@
google-adk>=1.9.0
streamlit>=1.28.0
python-dotenv>=1.1.1
pydantic>=2.0.0