In case you missed it, in the first post of this series, I introduced the Agent Storybook Project (ASP)—an open-source multi-agent system running on GKE that adapts classic public-domain literature into illustrated children’s books.

The goal was always to use it as a testbed for showing different lessons around running agents on GKE, and I have my first topic: Observability!

The initial implementation had some basic logging and even pretty good tracing out of the box thanks to ADK, but it wasn’t quite enough to answer a few important questions:

  • Why does this take so long (or more precisely, where was all the time being spent)?
  • How do all the different agents fit together for one giant pipeline run?
  • What about special calls like generating images (a big part of this agent’s job!)?

These are the kinds of questions that I can answer with tracing!

I instrumented the entire pipeline with OpenTelemetry (OTel) and Google Cloud Trace. Here is the PR for the tracing instrumentation and the follow-up PR with the fixes it inspired. Let’s walk through what changed, and what I did after seeing observability in action!


Instrumenting an ADK Application: The Code

Since this “agent” is really a multi-agent pipeline with several sub-agents, and each of those sub-agents might have several steps, I wanted a way to wrap this whole pipeline in a root trace, with each step in a child span. I set up a SpanContextManager to make sure each span has the appropriate context about where it is in the pipeline as well as the right metadata for GenAI-specific attributes like model used and token counts.

Tracing the whole pipeline

First, I created some helper utilities to keep the pipeline code simpler: (From src/storybook/tracing.py)

# OpenTelemetry GenAI semantic conventions
GEN_AI_SYSTEM = "gen_ai.system"
GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
GEN_AI_USAGE_PROMPT_TOKENS = "gen_ai.usage.prompt_tokens"
GEN_AI_USAGE_COMPLETION_TOKENS = "gen_ai.usage.completion_tokens"


class SpanContextManager:
    def __init__(
        self,
        name: str,
        attributes: dict[str, Any] | None = None,
        tracer: trace.Tracer | None = None,
    ) -> None:
        self.name = name
        self.attributes = attributes or {}
        self.tracer = tracer
        self._cm: Any = None
        self.span: trace.Span | None = None

    def __enter__(self) -> trace.Span:
        tracer = self.tracer or get_tracer()
        self._cm = tracer.start_as_current_span(self.name, attributes=self.attributes)
        self.span = self._cm.__enter__()
        return self.span

    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> Any:
        if self.span is not None and exc_val is None:
            if self.span.is_recording():
                status = getattr(self.span, "status", None)
                if status is not None and getattr(status, "status_code", None) == trace.StatusCode.UNSET:
                    self.span.set_status(trace.StatusCode.OK)
        if self._cm is not None:
            return self._cm.__exit__(exc_type, exc_val, exc_tb)
        return False

    async def __aenter__(self) -> trace.Span:
        return self.__enter__()

    async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> Any:
        return self.__exit__(exc_type, exc_val, exc_tb)


def trace_stage(stage_name: str, **attributes: Any) -> SpanContextManager:
    """Wrap a pipeline stage with proper span hierarchy and error recording."""
    return SpanContextManager(stage_name, attributes=attributes)


def trace_agent_call(
    agent_name: str,
    model: str | None = None,
    **attributes: Any,
) -> SpanContextManager:
    """Wrap model/agent calls with standard GenAI semantic conventions."""
    span_name = (
        agent_name
        if ("." in agent_name or agent_name.startswith("agent."))
        else f"agent.{agent_name}"
    )
    system = attributes.pop(GEN_AI_SYSTEM, "gemini")
    operation_name = attributes.pop(GEN_AI_OPERATION_NAME, "generate_content")
    span_attrs: dict[str, Any] = {
        GEN_AI_SYSTEM: system,
        GEN_AI_OPERATION_NAME: operation_name,
        "agent.name": agent_name,
    }
    if model:
        span_attrs[GEN_AI_REQUEST_MODEL] = model
    span_attrs.update(attributes)
    return SpanContextManager(span_name, attributes=span_attrs)


def set_span_token_usage(
    span: trace.Span | None = None,
    prompt_tokens: int | None = None,
    completion_tokens: int | None = None,
) -> None:
    """Attach token usage metrics to a span if available."""
    target_span = span or trace.get_current_span()
    if target_span is not None and target_span.is_recording():
        if prompt_tokens is not None:
            target_span.set_attribute(GEN_AI_USAGE_PROMPT_TOKENS, prompt_tokens)
        if completion_tokens is not None:
            target_span.set_attribute(GEN_AI_USAGE_COMPLETION_TOKENS, completion_tokens)

Then, I use those helpers in setting up the full agent pipeline:

        with trace_stage("stage.generate_illustrations", session_id=sid, total_spreads=total_spreads):
            log.info(
                "Starting stage.generate_illustrations for session %s (total_spreads=%d, model=%s)",
                sid,
                total_spreads,
                settings.model_image,
            )
            image_sem = asyncio.Semaphore(settings.image_concurrency)
            llm_sem = asyncio.Semaphore(settings.llm_concurrency)
            ref_ready: asyncio.Event = asyncio.Event()
            ref_image: list[bytes] = []

And so on for each of the stages.

This gives us a rich view of a full pipeline run, which I can expand into more detailed views of each stage.

A recently instrumented pipeline run
A recently instrumented pipeline run


What the Traces Revealed: Architecture & Logic Improvements

In that full trace, I uncovered a couple of spots that I didn’t realize had been slowing down runs of the full agent pipeline.

1. Fine-Grained 429 Retries at the LLM Call Level (Not the Workflow Level)

What the trace showed: LLMs are in high demand. Even (maybe especially!) for Google employees, sometimes the system is too overwhelmed with higher priority requests. I noticed that 429 RESOURCE_EXHAUSTED errors were triggering retries at the agent level instead of the model call level—aborting an entire LoopAgent pass and throwing away perfectly good work!

The fix: Rather than retrying the whole workflow stage, I wrapped ADK’s Gemini.generate_content_async with per-call exponential backoff retries. Now individual model calls self-heal in place without aborting the parent span or session.

# src/storybook/agents/pipeline.py
from google.adk.models.google_llm import Gemini

_original_generate_content_async = Gemini.generate_content_async


async def _retryable_generate_content_async(
    self: Gemini, llm_request: Any, stream: bool = False
) -> Any:
    """Wrap Gemini.generate_content_async with fine-grained 429 retry.

    Retries transient 429 RESOURCE_EXHAUSTED errors at the individual LLM call
    level with exponential backoff. This prevents an entire LoopAgent (e.g.
    craft_adapt_validate) or multi-step workflow from aborting and restarting
    from scratch when a single validation or generation call hits rate limits.
    """
    max_retries = 5
    for attempt in range(max_retries):
        yielded = False
        try:
            async for item in _original_generate_content_async(self, llm_request, stream=stream):
                yielded = True
                yield item
            return
        except Exception as exc:
            msg = str(exc)
            if not yielded and ("RESOURCE_EXHAUSTED" in msg or "429" in msg) and attempt < max_retries - 1:
                wait = 5 * (2 ** attempt)
                log.warning(
                    "429 RESOURCE_EXHAUSTED on model %s; retrying LLM call in %ds (attempt %d/%d)",
                    getattr(llm_request, "model", "model"),
                    wait,
                    attempt + 1,
                    max_retries,
                )
                await asyncio.sleep(wait)
                continue
            raise


Gemini.generate_content_async = _retryable_generate_content_async

2. Flaky Text Matching vs. Deterministic ADK Session State

What the trace showed: Another flaky error! I was using a “model-as-a-judge” technique to make sure the generated images matched the story and our “character bible” for visual consistency. Even though my validator agent had approve_image and reject_image tools that recorded the verdict in ADK session state, the pipeline orchestrator was still naively checking "approved" in result.lower() on the model’s final text response. Whenever the model approved an image via the tool call without repeating the literal word "approved" in its reply, we threw the image away and regenerated it!

The fix: I switched the orchestrator from scraping output strings to inspecting deterministic ADK session state (vsession.state["validation.passed"] and vsession.state["revised_image_prompt"]). Now, when an image is rejected, it’s for a good reason!

(From src/storybook/agents/pipeline.py)

# src/storybook/agents/pipeline.py
with check_image_validation(
    attempt=attempt,
    max_attempts=settings.image_max_retries + 1,
    spread_number=spread_number,
    image_index=image_index,
) as val_span:
    async with llm_sem:
        result = await _run_agent(
            validator_runner,
            val_sid,
            validate_input,
            subject_image=img_bytes,
            reference_image=style_ref,
            prev_spread_image=prev_img,
        )

    # Inspect deterministic ADK session state instead of scraping raw output text!
    vsession = None
    if hasattr(validator_runner, "session_service") and hasattr(validator_runner.session_service, "get_session"):
        session_coro = validator_runner.session_service.get_session(
            app_name=validator_runner.app_name,
            user_id="pipeline",
            session_id=val_sid,
        )
        vsession = await session_coro if inspect.isawaitable(session_coro) else session_coro

    if vsession and "validation.passed" in vsession.state:
        passed = bool(vsession.state["validation.passed"])
        score = float(vsession.state.get("validation.score", 1.0 if passed else 0.0))
        reasons = vsession.state.get("validation.reasons") or ([] if passed else [result.strip()])
        revised_prompt = vsession.state.get("revised_image_prompt")
        if revised_prompt and not passed:
            current_prompt = revised_prompt  # Self-healing retry with revised prompt
    else:
        passed = "approved" in result.lower()
        score = 1.0 if passed else 0.0
        reasons = [] if passed else [result.strip()]

    record_validation_result(val_span, passed=passed, score=score, attempt=attempt, reasons=reasons)

3. Different Models for Different Purposes: Heavy, Fast, and Image

What the trace showed: Using a heavy reasoning model for every single step was overkill. It dragged out latency on simple, mechanical tasks.

The fix: I right-sized the models to the actual job in src/storybook/config.py:

  • Heavy (gemini-3.1-pro-preview): Creative storytelling and the polished literary craft pass.
  • Fast (gemini-3.5-flash): Rapid, structured tasks like structural drafting, character bibles, spread layout planning, and vision validation.
  • Image (gemini-3.1-flash-image): Dedicated illustration generation.

Splitting these cut pipeline cycle times by over 40%.


4. Adapter Prompt Hardening & Gutenberg Fallbacks

Eliminating validation churn: Waterfalls of the story adaptation stage showed repeated 2nd and 3rd retry passes. The traces exposed the exact validation feedback: the adapter frequently duplicated text between facing pages (verso and recto—that’s a fun rabbit hole for another post) or failed strict sentence count constraints. Strengthening the instructions in src/storybook/agents/story_adapter.py with explicit “Zero-Tolerance Structural Compliance” rules immediately eliminated unnecessary retry cycles.

Graceful fallback when Gutenberg misses: Spans on gutenberg.search also highlighted external HTTP latency and empty results for titles missing from Gutendex. I added an automated fallback so that when a Gutenberg search comes up empty, the pipeline generates the story directly from Gemini’s parametric weights—tracked cleanly as its own span in the trace.


A faster trace on a larger project!
A faster trace on a larger project!


What’s Next: Distributed Agent Tracing Across Pods & Sandboxes

A few big takeaways from this exercise:

  • Observability is a tuning tool, not just a crash debugger: In multi-agent systems, traces show you where agents are spinning their wheels, retrying behind your back, or using too much model for the job.
  • Measure the non-deterministic stuff: Tracking token counts and validation retries per trace turns fuzzy LLM behavior into concrete engineering metrics you can actually optimize.
  • Trust tool state over text scraping: Rely on ADK session state and call-level retries rather than fragile string matching in your orchestrator.

As the Agent Storybook Project grows, I’ll be decoupling these agents from a single container into separate Kubernetes Jobs and GKE Agent Sandboxes. Because the application is already instrumented with OpenTelemetry, standard W3C traceparent headers will propagate across network boundaries and pod runtimes automatically, preserving end-to-end visibility.

Stay tuned under the asp tag for the next installment!