Skip to content

AgentTool({ skipSummarization: true }) terminates the parent LlmAgent before outputSchema output is produced #288

@federicobassetto

Description

@federicobassetto

Describe the bug

Passing skipSummarization: true to an AgentTool that is used mid-turn (i.e. not as the very last thing the parent agent does) terminates the parent LlmAgent's run loop the moment the sub-agent's functionResponse is yielded. The parent never gets another generation pass, so — when it has an outputSchema — it never emits its schema-constrained JSON. The runner sees a "final response" event that contains only the sub-agent's functionResponse part and zero text.

The behavior is counter-intuitive given the property's public description ("Whether to skip summarization of the agent output"): a reasonable reading is "hand the sub-agent's text back to the parent verbatim instead of re-summarising it", not "mark this tool-response event as the parent's final response and abort the parent's turn".

This is particularly painful for anyone pairing AgentTool with outputSchema — a very common pattern for structured-output pipelines — because the symptom is simply "agent produced no final-response text", with no log line pointing at skipSummarization as the culprit.

To Reproduce

Minimal reproducer (TypeScript, @google/adk@1.0.0, any Gemini model that supports function calling):

import { AgentTool, InMemoryRunner, LlmAgent } from "@google/adk";
import { z } from "zod";

const child = new LlmAgent({
  name: "quick_fact",
  model: "gemini-2.5-flash",
  description: "Answer a short factual question.",
  instruction: "Answer in one short sentence.",
});

const parent = new LlmAgent({
  name: "parent",
  model: "gemini-2.5-pro",
  description: "Produce a structured JSON summary.",
  instruction:
    'Call quick_fact once to gather context, then respond with JSON: {\"summary\": \"...\"}',
  tools: [
    new AgentTool({
      agent: child,
      skipSummarization: true, // flip to false (or omit) to see it work
    }),
  ],
  outputSchema: z.object({ summary: z.string() }),
});

const runner = new InMemoryRunner({ agent: parent, appName: "repro" });
const session = await runner.sessionService.createSession({
  appName: "repro",
  userId: "u1",
});

for await (const ev of runner.runAsync({
  userId: session.userId,
  sessionId: session.id,
  newMessage: {
    role: "user",
    parts: [{ text: "Summarise: what is TypeScript?" }],
  },
})) {
  console.log(ev.author, {
    parts: ev.content?.parts?.map((p) =>
      p.functionCall
        ? \`fcall:\${p.functionCall.name}\`
        : p.functionResponse
          ? \`fresp:\${p.functionResponse.name}\`
          : \`text(\${(p.text ?? \"\").length})\`,
    ),
    skipSummarization: ev.actions?.skipSummarization,
  });
}

Observed (with skipSummarization: true):

  • Last event: author=parent, parts [\"fresp:quick_fact\"], skipSummarization: true.
  • No subsequent parent event, no text parts, no schema-validated JSON.
  • Downstream `AgentTool`/runner consumers see an empty final response.

Expected (and what actually happens when skipSummarization is removed/false):

  • Parent yields fcall:quick_fact, then fresp:quick_fact, then runs one more generation pass.
  • Final parent event contains a text(...) part whose JSON parses against the schema.

Expected behavior

skipSummarization on AgentTool should only affect whether the sub-agent's textual output is re-summarised / rewrapped before being handed to the parent. It should NOT terminate the parent LlmAgent's run loop. The parent must still be allowed to continue generating after receiving the tool result — especially when it has an outputSchema.

Desktop (please complete the following information):

  • OS: macOS 15 (also reproduced on Linux Cloud Run)
  • TS version/environment: TypeScript 5.x, Node 20.x
  • ADK version: @google/adk@1.0.0 (also reproduced on 0.4.0)
  • Model backend: Vertex AI (GOOGLE_GENAI_USE_VERTEXAI=1); also Gemini API

Additional context — root cause

Chain of causation in current main:

  1. core/src/tools/agent_tool.tsAgentTool.runAsync propagates the config flag into the shared EventActions, so it leaks onto the tool-response event yielded back to the parent:
    override async runAsync({ args, toolContext }: RunAsyncToolRequest): Promise {
      if (this.skipSummarization) {
        toolContext.actions.skipSummarization = true;
      }
      // ...
    }
  2. core/src/events/event.tsisFinalResponse treats skipSummarization as an unconditional terminal marker, regardless of whether the event is merely a tool response or an actual model reply:
    export function isFinalResponse(event: Event) {
      if (
        event.actions.skipSummarization ||
        (event.longRunningToolIds && event.longRunningToolIds.length > 0)
      ) {
        return true;
      }
      return (
        getFunctionCalls(event).length === 0 &&
        getFunctionResponses(event).length === 0 &&
        !event.partial &&
        !hasTrailingCodeExecutionResult(event)
      );
    }
  3. core/src/agents/llm_agent.ts — the parent's run loop breaks as soon as isFinalResponse(lastEvent) is true:
    if (!lastEvent || isFinalResponse(lastEvent)) {
      break;
    }

Net effect: the parent's very first view of the sub-agent's functionResponse is instantly classified as "the parent is done", and the parent never gets to generate the text that outputSchema is waiting for.

Suggested fix (happy to PR whichever direction you prefer)

  1. Minimal: drop event.actions.skipSummarization from the isFinalResponse check. skipSummarization is semantically about whether ADK should re-summarise sub-agent output before returning it, not about whether the parent agent has finished. The remaining functionCalls.length === 0 && functionResponses.length === 0 && !partial && !hasTrailingCodeExecutionResult(event) branch already correctly identifies truly-terminal events.
  2. Scope-the-flag: have AgentTool.runAsync consume skipSummarization locally and clear it before the tool-response event is yielded into the parent's stream (or author/stamp the resulting event so the parent's own isFinalResponse ignores it). That preserves the "skip re-summarisation" intent without leaking into the parent's loop.
  3. Docs-only (short-term): explicitly document that AgentTool({ skipSummarization: true }) hard-terminates the parent turn on return, and is therefore incompatible with mid-workflow AgentTool calls and with parents that declare an outputSchema. (Not a substitute for 1 or 2 — this is a sharp, surprising footgun.)

Until this is fixed, a safe workaround is to simply omit skipSummarization from AgentTool configs unless the wrapped sub-agent is known to be the last step of the parent's turn.

Metadata

Metadata

Labels

bugSomething isn't workingneeds review[Status] The PR/issue is awaiting review from the maintainer

Type

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions