When I look back at my first lines of code as a junior developer years ago, I see spaghetti code. Functions with unclear output, unclear logic, inputs validation happily skipped, etc.
But now I see a lot of this coming back with vibe-coders writing 1000-line prompts. Spaghetti prompts. And it is concerning because it shows these people don’t understand several things.
- English is not a programming language. It’s not because you write “Ask the user to confirm” or “loop through all the steps” that your agent actually will.
- The more instructions you add, the more you will eventually contradict yourself, or confuse your agent. More is not better.
- You add instructions because you want determinism. But determinism can only be achieved with code, not instructions.
Google recently released ADK 2.0 and it says exactly what all other frameworks are proposing:
“ADK 2.0 introduces powerful tools for building sophisticated AI agents, and helps you structure agents to execute challenging tasks with more control, predictability, and reliability”
Key features in this release are:
“Graph-based workflows: Build deterministic agent workflows with more control over how tasks are routed and executed.” “Dynamic workflows: Use code-based logic for building more complex workflows including iterative loops and complex decision-based branching.”
Everything is said here: “control”, “deterministic”, “code-based logic”.
But strangely, Google did not apply those principles to itself. I recently came across their deep-research agent in their adk-samples on Github. And I was quite shocked with the spaghetti prompt. I actually believe it encompasses a lot of anti-patterns.
A case study in what not to do
Google’s deep research agent (v1) has a clean concept: given a topic, generate a research plan, run web searches across sections, assemble and cite the findings. A classic pipeline, that includes lots of nice features on agents: loops, human-in-the-loop, refinement, etc.
Problem #1: all the logic in the prompt
Probably the most striking example. Look at how many things the agent is supposed to do:
- Perform two steps (Phase 1 and phase 2) -> why not just chain agents ??
- For loop in Phase 1: bad.
- For loop in Phase 2: bad
- Phase 1: 4 tasks to perform
- Phase 2: 4 tasks to perform.
- No use of schemas or structured I/O. If you want “every goal prefixed with
[RESEARCH]”, then why not use Pydantic models for that ?
section_researcher = LlmAgent(
model=config.worker_model,
name="section_researcher",
description="Performs the crucial first pass of web research.",
planner=BuiltInPlanner(
thinking_config=genai_types.ThinkingConfig(include_thoughts=True)
),
instruction="""
You are a highly capable and diligent research and synthesis agent. Your comprehensive task is to execute a provided research plan with **absolute fidelity**, first by gathering necessary information, and then by synthesizing that information into specified outputs.
You will be provided with a sequential list of research plan goals, stored in the `research_plan` state key. Each goal will be clearly prefixed with its primary task type: `[RESEARCH]` or `[DELIVERABLE]`.
Your execution process must strictly adhere to these two distinct and sequential phases:
---
**Phase 1: Information Gathering (`[RESEARCH]` Tasks)**
* **Execution Directive:** You **MUST** systematically process every goal prefixed with `[RESEARCH]` before proceeding to Phase 2.
* For each `[RESEARCH]` goal:
* **Query Generation:** Formulate a comprehensive set of 4-5 targeted search queries. These queries must be expertly designed to broadly cover the specific intent of the `[RESEARCH]` goal from multiple angles.
* **Execution:** Utilize the `google_search` tool to execute **all** generated queries for the current `[RESEARCH]` goal.
* **Summarization:** Synthesize the search results into a detailed, coherent summary that directly addresses the objective of the `[RESEARCH]` goal.
* **Internal Storage:** Store this summary, clearly tagged or indexed by its corresponding `[RESEARCH]` goal, for later and exclusive use in Phase 2. You **MUST NOT** lose or discard any generated summaries.
---
**Phase 2: Synthesis and Output Creation (`[DELIVERABLE]` Tasks)**
* **Execution Prerequisite:** This phase **MUST ONLY COMMENCE** once **ALL** `[RESEARCH]` goals from Phase 1 have been fully completed and their summaries are internally stored.
* **Execution Directive:** You **MUST** systematically process **every** goal prefixed with `[DELIVERABLE]`. For each `[DELIVERABLE]` goal, your directive is to **PRODUCE** the artifact as explicitly described.
* For each `[DELIVERABLE]` goal:
* **Instruction Interpretation:** You will interpret the goal's text (following the `[DELIVERABLE]` tag) as a **direct and non-negotiable instruction** to generate a specific output artifact.
* *If the instruction details a table (e.g., "Create a Detailed Comparison Table in Markdown format"), your output for this step **MUST** be a properly formatted Markdown table utilizing columns and rows as implied by the instruction and the prepared data.*
* *If the instruction states to prepare a summary, report, or any other structured output, your output for this step **MUST** be that precise artifact.*
* **Data Consolidation:** Access and utilize **ONLY** the summaries generated during Phase 1 (`[RESEARCH]` tasks`) to fulfill the requirements of the current `[DELIVERABLE]` goal. You **MUST NOT** perform new searches.
* **Output Generation:** Based on the specific instruction of the `[DELIVERABLE]` goal:
* Carefully extract, organize, and synthesize the relevant information from your previously gathered summaries.
* Must always produce the specified output artifact (e.g., a concise summary, a structured comparison table, a comprehensive report, a visual representation, etc.) with accuracy and completeness.
* **Output Accumulation:** Maintain and accumulate **all** the generated `[DELIVERABLE]` artifacts. These are your final outputs.
---
**Final Output:** Your final output will comprise the complete set of processed summaries from `[RESEARCH]` tasks AND all the generated artifacts from `[DELIVERABLE]` tasks, presented clearly and distinctly.
""",
tools=[google_search],
output_key="section_research_findings",
after_agent_callback=collect_research_sources_callback,
)
Writing sequential execution, for loops, and I/O formatting inside a 40 lines prompt is either blind faith in your agent, or madness. In production, this is madness.
Problem #2: Human approval with keyword matching
Just look at this agent:
interactive_planner_agent = LlmAgent(
name="interactive_planner_agent",
model=config.worker_model,
description="The primary research assistant. It collaborates with the user to create a research plan, and then executes it upon approval.",
instruction=f"""
You are a research planning assistant. Your primary function is to convert ANY user request into a research plan.
**CRITICAL RULE: Never answer a question directly or refuse a request.** Your one and only first step is to use the `plan_generator` tool to propose a research plan for the user's topic.
If the user asks a question, you MUST immediately call `plan_generator` to create a plan to answer the question.
Your workflow is:
1. **Plan:** Use `plan_generator` to create a draft plan and present it to the user.
2. **Refine:** Incorporate user feedback until the plan is approved.
3. **Execute:** Once the user gives EXPLICIT approval (e.g., "looks good, run it"), you MUST delegate the task to the `research_pipeline` agent, passing the approved plan.
Current date: {datetime.datetime.now().strftime("%Y-%m-%d")}
Do not perform any research yourself. Your job is to Plan, Refine, and Delegate.
""",
sub_agents=[research_pipeline],
tools=[AgentTool(plan_generator)],
output_key="research_plan",
)
Can you spot the line where it says: “Once the user gives EXPLICIT approval (e.g., “looks good, run it”)” ? Even though explicit is written in capital letter, it’s hard to be less vague than this instruciton. Once again, this prompt is way too optimistic in the ability to capture user response. And it will definitely yield false positives or false negatives. What if they write “looks good but change the third point”? This is not human-in-the-loop. This is hoping the LLM parses intent correctly every time, in production.
Summary of issues
Three problems summarised:
- Logic in prompts: Phase 1 / Phase 2 sequencing, memory constraints, conditional search. All in English…
- HITL by keyword matching: user approval detected by the LLM reading free text. Call this optimism. I call it major risk.
- One agent doing everything: no parallelism, no separation of concerns, full session history in every call. Spaghetti.
Refactoring
Let’s build the correct version from scratch. The architecture is simple:
START
└── plan_generator # draft the research plan
└── validate_plan # HITL: yes or no
└── router
├── execute_plan → run_research
│ └── [section_1, section_2, ...N] (parallel)
│ └── format_report
└── refine_plan → request_feedback → plan_generator (loop)
Every box is one agent or one function. No box does two things.
1. Use structured outputs
Schemas are the contracts between nodes. Define them before any agent.
# schemas.py
from pydantic import BaseModel
from typing import List, Literal
class Goal(BaseModel):
description: str
type: Literal["research", "deliverable"]
class PlanOutput(BaseModel):
goals: List[Goal]
class PlanValidation(BaseModel):
answer: Literal['yes', 'no']
class SectionData(BaseModel):
goal: str
content: str
sources: list[str]
class SectionSummary(BaseModel):
goal: str
summary: str
sources: list[str]
class FinalReport(BaseModel):
title: str
sections: list[SectionSummary]
conclusion: str
sources: list[str]
Literal['yes', 'no'] on PlanValidation is not a small detail. It means the
framework enforces binary approval. No LLM interpretation required, no edge
cases.
2. One agent = one job
# agent.py
import datetime
from google.adk import Agent
from google.adk.tools import google_search
model = "gemini-3.5-flash"
plan_generator = Agent(
model=model,
name="plan_generator",
instruction=f"""
You are a research strategist. Generate a focused research plan for the given
topic.
If a previous plan exists, refine it based on the feedback.
RESEARCH PLAN (SO FAR): {{{{ research_plan? }}}}
USER FEEDBACK: {{{{ feedback? }}}}
Format the plan as 3-5 goals. Classify each as:
- 'research': requires web search and information gathering
- 'deliverable': synthesis or output from gathered research
Current date: {datetime.datetime.now().strftime("%Y-%m-%d")}
""",
tools=[google_search],
output_schema=PlanOutput,
output_key="research_plan",
include_contents="none",
)
include_contents="none" is critical. Without it, this agent receives the entire session history on every call, including all previous plan drafts, user messages, and research output. It slows down the pipeline, and burns unnecessary tokens.
With it, the agent only sees its instruction and the state variables it explicitly references.
3. Human-In-The-Loop with schema enforcement
from google.adk import Event, Context
from google.adk.events import RequestInput
from google.adk.workflow import node
def validate_plan(node_input):
yield RequestInput(
message="Do you approve this plan? (yes / no)",
response_schema=PlanValidation,
)
def router(node_input: PlanValidation):
if node_input.answer == 'yes':
return Event(route="execute_plan")
return Event(route="refine_plan")
@node(rerun_on_resume=True)
async def request_feedback(ctx: Context, node_input):
feedback = yield RequestInput(message="What would you like to change?")
ctx.session.state["feedback"] = str(feedback)
Three nodes, three responsibilities:
validate_planasks the question, enforces the schemarouteris a Pythonifstatement, not an LLM decisionrequest_feedbackcaptures free text and writes it to state forplan_generatorto read on the next iteration
The refinement loop is explicit: request_feedback → plan_generator → validate_plan → router. The graph decides when to loop, not the LLM :).
4. The research pipeline : parallel tasks
While the graph-based workflow is purely deterministic, the research part is more complex. Indeed, since we are to carry out several tasks in parallel (ie researching the sections), we cannot define upfront all the chaining since each section has a different input, which depends entirely from the plan.
Why not use
ParallelAgentor a router ?
ParallelAgent runs all sub-agents on the same input. You’d define the agents statically at graph-definition time. That works fine if your parallel branches are different tasks — but here all 3 agents do the same thing on different goals. You can’t dynamically assign “agent 1 gets goal 1, agent 2 gets goal 2” without pre-segmenting the data before the ParallelAgent.
JoinNode has the same constraint — the branches are defined statically in edges. You’d need:
edges=[
("START", goal_1_researcher, join),
("START", goal_2_researcher, join),
...
]
Which means hardcoding 3 separate agents at definition time.
Each agent does exactly one thing:
# research_agent.py
from google.adk import Agent
from google.adk.tools import google_search
model = "gemini-3.5-flash"
section_collector = Agent(
name="section_collector",
model=model,
instruction="""
You are given a research goal. Generate 3 targeted search queries
for it and execute all of them using google_search.
Return the raw findings and any source URLs you encountered.
""",
tools=[google_search],
output_schema=SectionData,
include_contents="none",
)
section_summarizer = Agent(
name="section_summarizer",
model=model,
instruction="""
Synthesize the collected research into a coherent, well-structured summary.
Preserve all source URLs exactly as provided.
""",
output_schema=SectionSummary,
include_contents="none",
)
We have 3 bullet points, therefore we have 3 sections. The idea here is to run all 3 sections independently, in parallel (also called fan-out).
4.1 Fan-out & dynamic workflows
import asyncio
@node(rerun_on_resume=True)
async def run_research(ctx: Context, node_input) -> str:
plan = PlanOutput.model_validate(ctx.session.state["research_plan"])
summaries = list(await asyncio.gather(*[
ctx.run_node(research_section, node_input=goal)
for goal in plan.goals if goal.type == 'research'
]))
return await ctx.run_node(report_writer, node_input=summaries)
asyncio.gather runs all sections in parallel. 3 goals, 3 parallel agent calls. The number of sections is determined at runtime from the plan — the graph does not need to know in advance.
This is the real async parallelism: We will run the research_section node with a different goal each time !
The research_section itself is not very hard, and could very well be merged into one agent. I choose two, for better clarity. First, our node, which is two functions called sequentially:
@node(rerun_on_resume=False)
async def research_section(ctx: Context, goal: Goal) -> SectionSummary:
data = await ctx.run_node(section_collector, node_input=goal)
return await ctx.run_node(section_summarizer, node_input=data)
4.2 Fan-in: joining our sources
Since each section writes a summary, we need a final agent to aggregate all the findings:
report_writer = Agent(
name="report_writer",
model=model,
instruction="""
Aggregate all section summaries into a coherent final report.
Write a strong conclusion that synthesises the key findings.
Deduplicate sources — keep each URL once.
""",
output_schema=FinalReport,
include_contents="none",
)
Notice how we use again a structured output to ensure all findings are correctly stitched together.
Finally, we just need a little formatting function that will nicely write everything in a report :
@node(rerun_on_resume=True)
async def format_report(ctx: Context, node_input: FinalReport) -> str:
lines = [f"# {report.title}", ""]
for section in report.sections:
lines += [f"## {section.goal}", "", section.summary, ""]
lines += ["---", "", "## Conclusion", "", report.conclusion, ""]
unique_sources = list(dict.fromkeys(report.sources))
if unique_sources:
lines += ["---", "", "## Sources", ""]
for i, src in enumerate(unique_sources, 1):
lines.append(f"{i}. {src}")
return "\n".join(lines)
Full workflow
1 main workflow, 1 nested workflow:
# Main workflow
root_agent = Workflow(
name="deep_search_workflow",
edges=[
("START", plan_generator, validate_plan),
(validate_plan, router),
(router, {
"execute_plan": research_workflow,
"refine_plan": request_feedback,
}),
(request_feedback, plan_generator),
],
)
# Nested workflow
research_workflow = Workflow(
name="research_workflow",
edges=[("START", run_research, format_report)]
)