Building AI Agents for DevOps Part 11: Adding Planning Mode with ADK Planners
In Part 10 I closed the last big gap in the destructive-action path — the Approve handshake now survives the round-trip across AgentTools and sub-sessions. The platform routes, delegates, confirms,...
Updated for Orrery 0.6.0 (October 2026). Planning mode still works as described, opt-in. Of the two follow-ups at the end, neither shipped as written: triage still checks all five systems. Visibility came from somewhere else: since 0.6.0, every specialist and tool call streams live to the console, Slack and Google Chat (Part 16), and planning text is still never shown in a reply.
In Part 10 I closed the last big gap in the destructive-action path — the Approve handshake now survives the round-trip across AgentTools and sub-sessions. The platform routes, delegates, confirms, remediates, and audits.
One thing it never did: think before acting. The orchestrator picked tools in a single pass — no explicit plan, no reasoning trace, no breadcrumb you could replay in a postmortem. For an agent that flips switches in production, that felt thin.
This post adds opt-in planning mode. One env var attaches an ADK planner to the three reasoning-heavy agents in the system, leaves the tool-leaf agents alone, and ships behind a default-off flag so existing deployments don't change.
The Two Planners ADK Gives You
ADK's google.adk.planners package exposes two implementations:
Planner | What it does | Works with |
|---|---|---|
| Hands a | Gemini only |
| Forces the LLM output into four explicit phases: | Any provider — Gemini, Claude, OpenAI, Ollama via LiteLLM |
Because resolve_model() already supports four providers, PlanReActPlanner is the right default. BuiltInPlanner would silently no-op the moment a user switched MODEL_PROVIDER off Gemini.
The Opt-In API
The whole feature lives in two places. First, a planner resolver in orrery_core:
def resolve_planner() -> BasePlanner | None:
choice = os.getenv("ORRERY_PLANNER", "none").lower().strip()
if choice in ("", "none"):
return None
if choice == "plan_react":
from google.adk.planners import PlanReActPlanner
return PlanReActPlanner()
if choice == "builtin":
provider = os.getenv("MODEL_PROVIDER", "gemini").lower()
if provider != "gemini":
logger.warning(
"ORRERY_PLANNER=builtin requires MODEL_PROVIDER=gemini "
"(current: %s); falling back to no planner.", provider,
)
return None
from google.adk.planners import BuiltInPlanner
from google.genai import types
cfg = {"include_thoughts": True}
if budget := os.getenv("ORRERY_PLANNER_THINKING_BUDGET"):
cfg["thinking_budget"] = int(budget)
return BuiltInPlanner(thinking_config=types.ThinkingConfig(**cfg))
logger.warning("Unknown ORRERY_PLANNER value %r; using no planner.", choice)
return NoneAnd second, a new planner= kwarg on create_agent() — the same factory every agent in the workspace already uses. Existing call sites don't change; opting in is one line.
Where to Attach a Planner — and Where Not To
This is the decision the docs gloss over. Attaching a planner everywhere doubles your token cost without proportional gains. The right shape is "planner on reasoning, no planner on plumbing":
| Agent | Planner | What it does |
|---|---|---|
orrery_assistant (root) | yes | Routes across 6 specialists |
triage_summarizer | yes | Synthesizes 5 status blobs |
remediation_actor | yes | Picks restart / scale / rollback |
kafka_health_checker | — | One tool sequence per turn |
k8s_health_checker | — | One tool sequence per turn |
docker_health_checker | — | One tool sequence per turn |
observability_health_checker | — | One tool sequence per turn |
elasticsearch_health_checker | — | One tool sequence per turn |
journal_writer | — | One tool sequence per turn |
remediation_verifier | — | One tool sequence per turn |
The leaves run a fixed micro-script: call this tool, write to output_key, return. An extra reasoning round-trip there only adds latency. The three reasoning agents — the orchestrator, the synthesizer, the destructive-action picker — are the ones whose output quality depends on multi-step thought.
What You See When Planning Is On
With ORRERY_PLANNER=plan_react, the orchestrator's response stops being a flat answer and starts looking like this:
/*PLANNING*/
1. The user asked specifically about Kafka. Don't run a full triage.
2. Delegate to kafka_health_agent for cluster + topic status.
3. Summarize and answer.
/*ACTION*/
tool_call: kafka_health_agent({"request": "check cluster health"})
/*REASONING*/
2 brokers online, no under-replicated partitions, all topics
reporting normally. No follow-up needed.
/*FINAL_ANSWER*/
The Kafka cluster is healthy: 2 brokers online and all topics
are reporting normally.The phases aren't decoration — the Google Chat progress card in Part 9 already keys off state_delta writes, so plan steps surface in the UI as breadcrumbs without further work. For audit, the /*REASONING*/ block is exactly what you want next to the destructive tool call in the Loki trace.
The Env-Var Matrix
Variable | Default | Effect |
|---|---|---|
|
| Selects |
| unset | Integer token budget — only consulted by |
|
| Surface Gemini's thoughts in the response stream — |
Default is none, which means the binary you ship today behaves identically tomorrow. Planning is something you turn on per environment, not something users opt out of.
The Tradeoff to Be Aware Of
PlanReAct adds an extra reasoning round-trip per turn. On Gemini Flash that's ~500-800 ms; on slower models it can be a full second. Two implications:
Latency-sensitive surfaces should A/B-test before flipping it on. The CLI feels chattier; the Slack and Google Chat progress cards actually benefit because the plan steps render as live UI.
Strict eval thresholds need re-tuning. The
/*FINAL_ANSWER*/marker in the response stream tanks ROUGE-styleresponse_match_scoreagainst pre-planning fixtures (in our scenarios it dropped from 0.58 to 0.19). Either strip the marker before scoring or accept smoke-test thresholds while you iterate.
Tests
Two layers, both deterministic in the part that matters:
Layer | Where | What it asserts |
|---|---|---|
Unit |
| 10 cases — env-var parsing, |
Wiring |
| 5 cases — reload the agent module across env permutations, assert which 3 agents pick up the planner and which 7 leaves stay free. |
Agent eval |
| Real LLM run against 2 narrow-routing scenarios with mocked clients. Gated behind |
The wiring test is the most useful thing I added. It caught a regression in seconds — when ORRERY_PLANNER=plan_react the planner attaches to root + summarizer + actor, and to nothing else. Add a planner to a leaf agent by accident and the test fails before the LLM ever runs.
What's Next
The planner now sits on the orchestrator's tool-routing path. Two follow-ups become visible from here:
Dynamic triage. The current
incident_triage_agentalways fans out to all five health checks. A planner-driven dispatcher could pick the relevant subset based on the question — "is Kafka healthy?" should never spin up the K8s and Elasticsearch branches.Plan-as-state. Persist the orchestrator's
/*PLANNING*/output as a session-state key so a long-running run becomes resumable across crashes — and so the Google Chat progress card can render the plan as checklist items, not just status chips.
Source code on GitHub. Planner resolver: core/orrery_core/agent/base.py. Wiring tests: agents/orrery-assistant/tests/test_planner_wiring.py. ADK planners reference: adk.dev/agents/llm-agents.