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

BuiltInPlanner

Hands a ThinkingConfig to the model so Gemini's native thinking tokens fire before the response.

Gemini only

PlanReActPlanner

Forces the LLM output into four explicit phases: /*PLANNING*/, /*ACTION*/, /*REASONING*/, /*FINAL_ANSWER*/.

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 None

And 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":

AgentPlannerWhat it does
orrery_assistant (root)yesRoutes across 6 specialists
triage_summarizeryesSynthesizes 5 status blobs
remediation_actoryesPicks 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

ORRERY_PLANNER

none

Selects none, plan_react, or builtin.

ORRERY_PLANNER_THINKING_BUDGET

unset

Integer token budget — only consulted by builtin.

ORRERY_PLANNER_INCLUDE_THOUGHTS

true

Surface Gemini's thoughts in the response stream — builtin only.

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:


Tests

Two layers, both deterministic in the part that matters:

Layer

Where

What it asserts

Unit

core/tests/test_base.py::TestResolvePlanner

10 cases — env-var parsing, builtin fallback on non-Gemini, both knob env vars, case-insensitivity.

Wiring

tests/test_planner_wiring.py

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

tests/test_orrery_eval.py

Real LLM run against 2 narrow-routing scenarios with mocked clients. Gated behind make eval.

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:


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.

Read on Tirraflow