---
title: "Building AI Agents for DevOps Part 11: Adding Planning Mode with ADK Planners"
url: "https://tirraflow.com/posts/building-ai-agents-for-devops-part-11-adding-planning-mode-with-adk-planners"
author: "taoufiq"
published: "2026-05-02T22:10:59Z"
updated: "2026-10-15T21:16:04Z"
category: "AI Agents"
difficulty: "intermediate"
series: "Building AI Agents for DevOps"
series_part: 11
series_url: "https://tirraflow.com/series/building-ai-agents-for-devops"
tags: ["Agents", "ADK"]
---

# Building AI Agents for DevOps Part 11: Adding Planning Mode with ADK Planners

> **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](https://tirraflow.com/posts/building-ai-agents-for-devops-part-16-streaming-what-the-agent-is-doing)), and planning text is still never shown in a reply.

In [Part 10](https://tirraflow.com/posts/building-ai-agents-for-devops-part-10-fixing-the-approve-handshake-across-sub) 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`:

```python
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":

| 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](https://tirraflow.com/posts/building-ai-agents-for-devops-part-9-google-chat-integration) 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:

- **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-style `response_match_score` against 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 | `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:

- **Dynamic triage.** The current `incident_triage_agent` always 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_](https://github.com/BAHALLA/orrery)_. Planner resolver:_ [_core/orrery\_core/agent/base.py_](https://github.com/BAHALLA/orrery/blob/main/core/orrery_core/agent/base.py)_. Wiring tests:_ [_agents/orrery-assistant/tests/test\_planner\_wiring.py_](https://github.com/BAHALLA/orrery/blob/main/agents/orrery-assistant/tests/test_planner_wiring.py)_. ADK planners reference:_ [_adk.dev/agents/llm-agents_](https://adk.dev/agents/llm-agents/)_._
