Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions core/src/agents/llm_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,21 +667,30 @@ export class LlmAgent extends BaseAgent {
): AsyncGenerator<Event, void, void> {
while (true) {
let lastEvent: Event | undefined = undefined;
const stepEvents: Event[] = [];
for await (const event of this.runOneStepAsync(context)) {
if (context.abortSignal?.aborted) {
return;
}

lastEvent = event;
this.maybeSaveOutputToState(event);
stepEvents.push(event);
}

const isFinal = !lastEvent || isFinalResponse(lastEvent);
for (const event of stepEvents) {
if (!isFinal) {
event.intermediate = true;
}
yield event;
}

if (!lastEvent || isFinalResponse(lastEvent)) {
if (isFinal) {
break;
}

if (lastEvent.partial) {
if (lastEvent?.partial) {
logger.warn('The last event is partial, which is not expected.');
break;
}
Expand Down
11 changes: 11 additions & 0 deletions core/src/events/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ export interface Event extends LlmResponse {
* The timestamp of the event.
*/
timestamp: number;

/**
* Whether this event is from an intermediate agent step that precedes a
* function call. When `true`, the event was produced by the model during a
* reasoning step that was followed by tool invocations and is not the final
* response to the user.
*
* Streaming consumers can use this flag to suppress intermediate model text
* (e.g. "thinking" output) from the user-visible response.
*/
intermediate?: boolean;
}

/**
Expand Down
159 changes: 159 additions & 0 deletions core/test/agents/event_intermediate_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {
BaseLlm,
BaseLlmConnection,
createEvent,
Event,
FunctionTool,
InMemorySessionService,
LlmAgent,
LlmRequest,
LlmResponse,
Runner,
} from '@google/adk';
import {beforeEach, describe, expect, it} from 'vitest';

// ---------------------------------------------------------------------------
// Unit tests — Event.intermediate field
// ---------------------------------------------------------------------------

describe('Event.intermediate field', () => {
it('is undefined by default in createEvent', () => {
const event = createEvent();
expect(event.intermediate).toBeUndefined();
});

it('is preserved when set via createEvent', () => {
const event = createEvent({intermediate: true});
expect(event.intermediate).toBe(true);
});

it('can be set to false', () => {
const event = createEvent({intermediate: false});
expect(event.intermediate).toBe(false);
});
});

// ---------------------------------------------------------------------------
// Integration tests — LlmAgent marks intermediate events
// ---------------------------------------------------------------------------

/**
* A mock LLM that returns responses from a queue in order.
*/
class QueuedMockLlm extends BaseLlm {
private readonly queue: LlmResponse[];

constructor(responses: LlmResponse[]) {
super({model: 'mock-llm'});
this.queue = [...responses];
}

async *generateContentAsync(
_request: LlmRequest,
): AsyncGenerator<LlmResponse, void, void> {
const response = this.queue.shift();
if (response) {
yield response;
}
}

async connect(_llmRequest: LlmRequest): Promise<BaseLlmConnection> {
throw new Error('connect() not supported in QueuedMockLlm');
}
}

const APP_NAME = 'test-app';
const USER_ID = 'test-user';

describe('LlmAgent.runAsyncImpl intermediate flag', () => {
let sessionService: InMemorySessionService;

beforeEach(() => {
sessionService = new InMemorySessionService();
});

async function runAgent(agent: LlmAgent): Promise<Event[]> {
const runner = new Runner({appName: APP_NAME, agent, sessionService});
const session = await sessionService.createSession({
appName: APP_NAME,
userId: USER_ID,
});

const events: Event[] = [];
for await (const event of runner.runAsync({
userId: session.userId,
sessionId: session.id,
newMessage: {role: 'user', parts: [{text: 'go'}]},
})) {
events.push(event);
}
return events;
}

it('does not mark events as intermediate when agent responds in one step', async () => {
const agent = new LlmAgent({
name: 'single-step-agent',
model: new QueuedMockLlm([
{content: {role: 'model', parts: [{text: 'Hello!'}]}},
]),
});

const events = await runAgent(agent);

const modelEvents = events.filter((e) => e.author === 'single-step-agent');
expect(modelEvents.length).toBeGreaterThan(0);
for (const event of modelEvents) {
expect(event.intermediate).toBeUndefined();
}
});

it('marks events as intermediate when the step contains a function call', async () => {
const noopTool = new FunctionTool({
name: 'noop',
description: 'Does nothing.',
execute: async () => 'ok',
});

const agent = new LlmAgent({
name: 'multi-step-agent',
model: new QueuedMockLlm([
// Step 1: function call → not a final response → intermediate
{
content: {
role: 'model',
parts: [{functionCall: {name: 'noop', args: {}}}],
},
},
// Step 2: text → final response → not intermediate
{content: {role: 'model', parts: [{text: 'Done!'}]}},
]),
tools: [noopTool],
});

const events = await runAgent(agent);

// Events from step 1 (function call + function response) are intermediate
const step1Events = events.filter(
(e) =>
e.content?.parts?.some((p) => p.functionCall) ||
e.content?.parts?.some((p) => p.functionResponse),
);
expect(step1Events.length).toBeGreaterThan(0);
for (const event of step1Events) {
expect(event.intermediate).toBe(true);
}

// The final text response is not intermediate
const finalEvent = events.find((e) =>
e.content?.parts?.some((p) => p.text === 'Done!'),
);
expect(finalEvent).toBeDefined();
expect(finalEvent!.intermediate).toBeUndefined();
});
});