Skip to content

bug(google-adk-LoopAgent): ADK tools that use ToolContext get an empty dict instead of a real object on Conductor, breaking LoopAgent's exit mechanism #464

Description

@v1r3n

Describe the bug
This is about running a Google ADK agent through Conductor's ADK integration, using conductor.ai.agents.AgentRuntime to execute an agent that was originally built with google.adk.agents.

ADK has a container called LoopAgent that repeats a step (for example, write something, then critique it, then revise) until either a tool tells it to stop or it hits a hardcoded iteration limit. The way a tool signals "stop now" is by setting tool_context.actions.escalate = True inside the tool function. tool_context is an object ADK automatically passes into any tool that asks for it, giving the tool a way to affect the agent's run.

This works correctly when the agent runs locally through ADK. When the exact same agent is run through Conductor instead, the tool call that's supposed to stop the loop fails every single time with the error 'dict' object has no attribute 'actions'.

Root Cause

Conductor runs tool calls by sending them to worker processes, which execute the tool's actual Python function. For a normal tool, this works fine. But for a tool that asks for tool_context, Conductor has no way to build a real ToolContext object, so it sends an empty dictionary ({}) in its place. Since a plain dictionary has no .actions attribute, the line tool_context.actions.escalate = True throws immediately.

The tool in question is marked optional, so the workflow doesn't stop the instant this happens. Conductor retries the failing call a couple of times, and once those retries are used up, the loop ends on its own. Whatever the critic said right before that becomes the final output, even though nothing was ever actually approved and the loop never went back for a revision.

I also looked into whether Conductor's own round robin feature could be used as a workaround instead of LoopAgent. Round robin is a separate, native Conductor way of alternating between multiple agents in turns, and it doesn't rely on tool_context at all, so it seemed worth checking. It can't be used this way: the parameters that configure it (agents=, strategy=, max_turns=) aren't recognized fields on ADK's own Agent class, so trying to set them fails immediately when the agent object is created, before Conductor is even involved.

Steps to Reproduce

# agent.py
import sys
from google.adk.agents import Agent, LoopAgent
from google.adk.tools.tool_context import ToolContext
from conductor.ai.agents import AgentRuntime

def exit_loop(tool_context: ToolContext):
    """Call this function ONLY when the joke is genuinely funny and needs
    no more changes, to end the refinement process.
    """
    tool_context.actions.escalate = True
    return {}

joke_writer = Agent(
    name="joke_writer",
    model="OpenAIAdrian/gpt-4o-mini",
    description="Writes or revises a short joke based on the user's request.",
    instruction=(
        "Write a short, one-line joke based on what the user asked for.\n\n"
        "Previous critique feedback (if any): {critique?}\n\n"
        "If there is feedback above, revise your previous joke to address it. "
        "Output ONLY the joke text, nothing else -- no preamble, no quotes."
    ),
    output_key="current_joke",
)

joke_critic = Agent(
    name="joke_critic",
    model="OpenAIAdrian/gpt-4o-mini",
    description="Reviews a joke and either approves it or gives feedback.",
    instruction=(
        "You are a tough comedy critic reviewing this joke:\n\n{current_joke}\n\n"
        "If it is genuinely funny and needs no more changes, call the "
        "exit_loop function and say nothing else. Otherwise, respond with "
        "exactly ONE short, specific piece of feedback for how to improve "
        "it -- do not rewrite the joke yourself, and do not call exit_loop."
    ),
    tools=[exit_loop],
    output_key="critique",
)

root_agent = LoopAgent(
    name="joke_refinement_loop",
    description=(
        "Iteratively writes and critiques a joke until the critic approves "
        "it or a maximum number of iterations is reached."
    ),
    sub_agents=[joke_writer, joke_critic],
    max_iterations=4,
)

if __name__ == "__main__":
    prompt = " ".join(sys.argv[1:]) or "Write me a joke about cats who refuse to use the litter box."
    with AgentRuntime() as runtime:
        result = runtime.run(root_agent, prompt)
        result.print_result()

Run it with: python agent.py

What happens: joke_writer produces a joke. joke_critic reviews it and calls exit_loop to approve it. That call fails with 'dict' object has no attribute 'actions'. It fails the same way on each automatic retry. After retries run out, the workflow ends on its own, and the final output is just the critic's plain feedback text, not an approved joke, and the writer never gets a chance to revise.

Expected Behavior

The tool should receive a real ToolContext object, the same kind ADK's own local runtime builds and passes in, so that tool_context.actions.escalate = True (or anything else read from or written to ToolContext) works the same way it does when the agent runs locally.

Additional Notes

  • This isn't specific to LoopAgent's exit mechanism. Any ADK tool that reads or writes anything on ToolContext will run into this same crash, since the real object is never built. LoopAgent just happened to be the first place we ran into it, because ending a loop early is the one thing it can't do without ToolContext.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions