diff --git a/core/src/agents/processors/context_compactor_request_processor.ts b/core/src/agents/processors/context_compactor_request_processor.ts index 3ff95bd3..3025b5d8 100644 --- a/core/src/agents/processors/context_compactor_request_processor.ts +++ b/core/src/agents/processors/context_compactor_request_processor.ts @@ -46,9 +46,10 @@ export class ContextCompactorRequestProcessor implements BaseLlmRequestProcessor compactor.shouldCompact(invocationContext), ); if (shouldCompact) { + const trigger = compactor.trigger ?? ContextCompactionTrigger.Auto; await invocationContext.pluginManager.runBeforeContextCompaction({ invocationContext, - trigger: ContextCompactionTrigger.Auto, + trigger, }); const oldEvents = new Set(invocationContext.session.events); @@ -56,7 +57,7 @@ export class ContextCompactorRequestProcessor implements BaseLlmRequestProcessor await invocationContext.pluginManager.runAfterContextCompaction({ invocationContext, - trigger: ContextCompactionTrigger.Auto, + trigger, }); const newEvents = invocationContext.session.events.filter( diff --git a/core/src/common.ts b/core/src/common.ts index 409ef94f..bc5a54f5 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -110,6 +110,7 @@ export { type CodeExecutionResult, type File, } from './code_executors/code_execution_utils.js'; +export {AgentControlledContextCompactor} from './context/agent_controlled_context_compactor.js'; export type {BaseContextCompactor} from './context/base_context_compactor.js'; export type {BaseSummarizer} from './context/summarizers/base_summarizer.js'; export {LlmSummarizer} from './context/summarizers/llm_summarizer.js'; @@ -215,6 +216,7 @@ export type { } from './tools/base_tool.js'; export {BaseToolset, isBaseToolset} from './tools/base_toolset.js'; export type {ToolPredicate} from './tools/base_toolset.js'; +export {ConsolidateContextTool} from './tools/consolidate_context_tool.js'; export {EXIT_LOOP, ExitLoopTool} from './tools/exit_loop_tool.js'; export {FunctionTool, isFunctionTool} from './tools/function_tool.js'; export type { diff --git a/core/src/context/agent_controlled_context_compactor.ts b/core/src/context/agent_controlled_context_compactor.ts new file mode 100644 index 00000000..5d86ee42 --- /dev/null +++ b/core/src/context/agent_controlled_context_compactor.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {InvocationContext} from '../agents/invocation_context.js'; +import {CompactedEvent, isCompactedEvent} from '../events/compacted_event.js'; +import {createEvent, Event} from '../events/event.js'; +import {ContextCompactionTrigger} from '../plugins/base_plugin.js'; +import {BaseContextCompactor} from './base_context_compactor.js'; +import {BaseSummarizer} from './summarizers/base_summarizer.js'; + +/** + * A context compactor that triggers compaction when the agent explicitly + * requests it via the `ConsolidateContextTool`. + */ +export class AgentControlledContextCompactor implements BaseContextCompactor { + readonly trigger = ContextCompactionTrigger.AgentControlled; + private readonly summarizer: BaseSummarizer; + + constructor(options: {summarizer: BaseSummarizer}) { + this.summarizer = options.summarizer; + } + + shouldCompact(invocationContext: InvocationContext): boolean { + return invocationContext.session.state['temp:consolidate_context'] === true; + } + + async compact(invocationContext: InvocationContext): Promise { + const events = invocationContext.session.events; + const activeEvents = this.getActiveEvents(events); + + // Find the consolidate_context tool call. + const consolidateToolCallIndex = activeEvents.reduce( + (acc, e, idx) => + e.content?.parts?.some( + (p) => p.functionCall?.name === 'consolidate_context', + ) + ? idx + : acc, + -1, + ); + + if (consolidateToolCallIndex <= 0) { + // If not found (index -1) or tool call is the first event (index 0), + // there is nothing to compact before it. Clear flags and return. + this.clearFlags(invocationContext); + return; + } + + // We compact everything BEFORE the consolidate_context tool call. + const eventsToCompact = activeEvents.slice(0, consolidateToolCallIndex); + + const detail = invocationContext.session.state[ + 'temp:consolidate_context_detail' + ] as string | undefined; + + if (detail) { + eventsToCompact.push( + createEvent({ + author: 'system', + timestamp: eventsToCompact[eventsToCompact.length - 1].timestamp, + content: { + role: 'user', + parts: [ + { + text: `CRITICAL INSTRUCTION FOR SUMMARY: Please summarize the history. Focus especially on: ${detail}`, + }, + ], + }, + }), + ); + } + + try { + const compactedEvent = await this.summarizer.summarize(eventsToCompact); + invocationContext.session.events.push(compactedEvent); + } catch (error) { + // If the summarizer fails, log the error, clear the flags, and proceed without compaction. + // (do not block the agent run) + console.error('Compaction failed:', error); + } finally { + this.clearFlags(invocationContext); + } + } + + private clearFlags(invocationContext: InvocationContext) { + delete invocationContext.session.state['temp:consolidate_context']; + delete invocationContext.session.state['temp:consolidate_context_detail']; + } + + private getActiveEvents(events: Event[]): Event[] { + const latest = events.filter(isCompactedEvent).pop() as + | CompactedEvent + | undefined; + return latest + ? [ + latest, + ...events.filter( + (e) => !isCompactedEvent(e) && e.timestamp > latest.endTime, + ), + ] + : events; + } +} diff --git a/core/src/context/base_context_compactor.ts b/core/src/context/base_context_compactor.ts index d13b89d9..0e5aae59 100644 --- a/core/src/context/base_context_compactor.ts +++ b/core/src/context/base_context_compactor.ts @@ -5,11 +5,15 @@ */ import {InvocationContext} from '../agents/invocation_context.js'; +import {ContextCompactionTrigger} from '../plugins/base_plugin.js'; /** * Interface for compacting the context history in an agent session. */ export interface BaseContextCompactor { + /** The trigger associated with this compactor. */ + readonly trigger?: ContextCompactionTrigger; + /** * Determines whether the context should be compacted. * diff --git a/core/src/plugins/base_plugin.ts b/core/src/plugins/base_plugin.ts index 3dbea1fc..e667de7c 100644 --- a/core/src/plugins/base_plugin.ts +++ b/core/src/plugins/base_plugin.ts @@ -21,6 +21,7 @@ import {experimental} from '../utils/experimental.js'; export enum ContextCompactionTrigger { Manual = 'Manual', Auto = 'Auto', + AgentControlled = 'AgentControlled', } /** diff --git a/core/src/tools/consolidate_context_tool.ts b/core/src/tools/consolidate_context_tool.ts new file mode 100644 index 00000000..b841f762 --- /dev/null +++ b/core/src/tools/consolidate_context_tool.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FunctionDeclaration, Type} from '@google/genai'; + +import {BaseTool, RunAsyncToolRequest} from './base_tool.js'; + +/** + * Tool for requesting context consolidation (compaction). + * + * When called by an LLM agent, this tool sets the state flags + * `temp:consolidate_context` and optionally `temp:consolidate_context_detail`, + * signalling the context compactor to trigger compaction. + */ +export class ConsolidateContextTool extends BaseTool { + constructor() { + super({ + name: 'consolidate_context', + description: + 'Requests context consolidation (compaction) to manage history size. ' + + 'Use this when a subtask is complete and you want to summarize progress ' + + 'and clear detailed history.', + }); + } + + override _getDeclaration(): FunctionDeclaration { + return { + name: this.name, + description: this.description, + parameters: { + type: Type.OBJECT, + properties: { + detail: { + type: Type.STRING, + description: + 'Optional description of what has been accomplished so far. ' + + 'This detail will be used to guide the summarization of the history.', + }, + }, + }, + }; + } + + override async runAsync({ + args, + toolContext, + }: RunAsyncToolRequest): Promise { + toolContext.state.set('temp:consolidate_context', true); + if (args['detail'] !== undefined) { + toolContext.state.set( + 'temp:consolidate_context_detail', + args['detail'] as string, + ); + } + return { + status: 'success', + message: 'Context consolidation requested.', + }; + } +} diff --git a/core/test/context/agent_controlled_context_compactor_test.ts b/core/test/context/agent_controlled_context_compactor_test.ts new file mode 100644 index 00000000..8f3c4cbe --- /dev/null +++ b/core/test/context/agent_controlled_context_compactor_test.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AgentControlledContextCompactor, + BaseAgent, + BaseSummarizer, + CompactedEvent, + Event, + InvocationContext, + PluginManager, + Session, +} from '@google/adk'; +import {describe, expect, it, vi} from 'vitest'; + +class MockSummarizer implements BaseSummarizer { + async summarize(events: Event[]): Promise { + return { + id: 'mock-id', + invocationId: '', + author: 'system', + actions: { + stateDelta: {}, + artifactDelta: {}, + requestedAuthConfigs: {}, + requestedToolConfirmations: {}, + }, + timestamp: Date.now(), + isCompacted: true, + startTime: events[0].timestamp, + endTime: events[events.length - 1].timestamp, + compactedContent: `Mock summary of ${events.length} events`, + content: { + role: 'model', + parts: [{text: `Mock summary of ${events.length} events`}], + }, + } as CompactedEvent; + } +} + +function createMockEvent( + id: string, + isToolCall?: boolean, + toolName?: string, +): Event { + const event: Event = { + id, + timestamp: Date.now(), + content: {parts: []}, + } as unknown as Event; + if (isToolCall && toolName) { + event.content!.parts!.push({functionCall: {name: toolName, args: {}}}); + } + return event; +} + +function createMockInvocationContext( + events: Event[], + state: Record = {}, +): InvocationContext { + const session = { + id: 'test-session', + events, + state, + appName: 'test-app', + userId: 'test-user', + } as unknown as Session; + return new InvocationContext({ + invocationId: 'test-invocation', + agent: {} as BaseAgent, + session, + pluginManager: new PluginManager([]), + }); +} + +describe('AgentControlledContextCompactor', () => { + it('shouldCompact returns true only when flag is set', () => { + const compactor = new AgentControlledContextCompactor({ + summarizer: new MockSummarizer(), + }); + + const contextWithoutFlag = createMockInvocationContext([]); + expect(compactor.shouldCompact(contextWithoutFlag)).toBe(false); + + const contextWithFlag = createMockInvocationContext([], { + 'temp:consolidate_context': true, + }); + expect(compactor.shouldCompact(contextWithFlag)).toBe(true); + }); + + it('compacts events before the consolidate_context tool call and clears flags', async () => { + const summarizer = new MockSummarizer(); + const summarizeSpy = vi.spyOn(summarizer, 'summarize'); + const compactor = new AgentControlledContextCompactor({summarizer}); + + const events = [ + createMockEvent('1'), + createMockEvent('2'), + createMockEvent('3', true, 'consolidate_context'), + createMockEvent('4'), // response or subsequent event + ]; + + const state = { + 'temp:consolidate_context': true, + 'temp:consolidate_context_detail': 'some detail', + }; + + const context = createMockInvocationContext(events, state); + + await compactor.compact(context); + + // Should compact events '1' and '2'. + // And inject the instruction event. + expect(summarizeSpy).toHaveBeenCalledOnce(); + const passedEvents = summarizeSpy.mock.calls[0][0]; + expect(passedEvents.length).toBe(3); // '1', '2' + instruction event + expect(passedEvents[0].id).toBe('1'); + expect(passedEvents[1].id).toBe('2'); + expect(passedEvents[2].content?.parts?.[0]?.text).toContain('some detail'); + + // Should append the compacted event. + expect(context.session.events.length).toBe(5); + expect(context.session.events[4].id).toBe('mock-id'); + + // Flags should be cleared. + expect(context.session.state['temp:consolidate_context']).toBeUndefined(); + expect( + context.session.state['temp:consolidate_context_detail'], + ).toBeUndefined(); + }); + + it('does not compact and clears flags if consolidate_context tool call is not found', async () => { + const summarizer = new MockSummarizer(); + const summarizeSpy = vi.spyOn(summarizer, 'summarize'); + const compactor = new AgentControlledContextCompactor({summarizer}); + + const events = [createMockEvent('1'), createMockEvent('2')]; + const state = {'temp:consolidate_context': true}; + const context = createMockInvocationContext(events, state); + + await compactor.compact(context); + + expect(summarizeSpy).not.toHaveBeenCalled(); + expect(context.session.events.length).toBe(2); + expect(context.session.state['temp:consolidate_context']).toBeUndefined(); + }); + + it('does not compact and clears flags if nothing to compact before tool call', async () => { + const summarizer = new MockSummarizer(); + const summarizeSpy = vi.spyOn(summarizer, 'summarize'); + const compactor = new AgentControlledContextCompactor({summarizer}); + + const events = [ + createMockEvent('1', true, 'consolidate_context'), + createMockEvent('2'), + ]; + const state = {'temp:consolidate_context': true}; + const context = createMockInvocationContext(events, state); + + await compactor.compact(context); + + expect(summarizeSpy).not.toHaveBeenCalled(); + expect(context.session.events.length).toBe(2); + expect(context.session.state['temp:consolidate_context']).toBeUndefined(); + }); + + it('clears flags even if summarizer fails', async () => { + const summarizer = { + summarize: async () => { + throw new Error('Summarizer failed'); + }, + }; + const compactor = new AgentControlledContextCompactor({summarizer}); + + const events = [ + createMockEvent('1'), + createMockEvent('2', true, 'consolidate_context'), + ]; + const state = {'temp:consolidate_context': true}; + const context = createMockInvocationContext(events, state); + + await compactor.compact(context); + + expect(context.session.events.length).toBe(2); // no compacted event appended + expect(context.session.state['temp:consolidate_context']).toBeUndefined(); + }); +}); diff --git a/core/test/tools/consolidate_context_tool_test.ts b/core/test/tools/consolidate_context_tool_test.ts new file mode 100644 index 00000000..25b171c6 --- /dev/null +++ b/core/test/tools/consolidate_context_tool_test.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseAgent, + ConsolidateContextTool, + Context, + createSession, + InvocationContext, + PluginManager, +} from '@google/adk'; +import {Type} from '@google/genai'; +import {describe, expect, it} from 'vitest'; + +describe('ConsolidateContextTool', () => { + it('computes the correct declaration', () => { + const tool = new ConsolidateContextTool(); + const declaration = tool._getDeclaration(); + + expect(declaration?.name).toEqual('consolidate_context'); + expect(declaration?.description).toContain( + 'Requests context consolidation', + ); + expect(declaration?.parameters?.type).toEqual(Type.OBJECT); + expect(declaration?.parameters?.properties?.['detail']?.type).toEqual( + Type.STRING, + ); + }); + + it('sets consolidate flags on runAsync', async () => { + const tool = new ConsolidateContextTool(); + const session = createSession({id: 'test-session', appName: 'test-app'}); + const invocationContext = new InvocationContext({ + invocationId: 'test-invocation', + agent: {} as BaseAgent, + session, + pluginManager: new PluginManager([]), + }); + const toolContext = new Context({invocationContext}); + + const result = (await tool.runAsync({ + args: {detail: 'Finished step 1'}, + toolContext, + })) as {status: string; message: string}; + + expect(result.status).toEqual('success'); + expect(toolContext.state.get('temp:consolidate_context')).toBe(true); + expect(toolContext.state.get('temp:consolidate_context_detail')).toEqual( + 'Finished step 1', + ); + }); + + it('sets consolidate flags without detail on runAsync', async () => { + const tool = new ConsolidateContextTool(); + const session = createSession({id: 'test-session', appName: 'test-app'}); + const invocationContext = new InvocationContext({ + invocationId: 'test-invocation', + agent: {} as BaseAgent, + session, + pluginManager: new PluginManager([]), + }); + const toolContext = new Context({invocationContext}); + + const result = (await tool.runAsync({ + args: {}, + toolContext, + })) as {status: string; message: string}; + + expect(result.status).toEqual('success'); + expect(toolContext.state.get('temp:consolidate_context')).toBe(true); + expect( + toolContext.state.get('temp:consolidate_context_detail'), + ).toBeUndefined(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 5dd12534..722a2131 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5194,6 +5194,7 @@ "version": "0.5.17", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0" diff --git a/tests/e2e/context_compaction/agent.ts b/tests/e2e/context_compaction/agent.ts index f66bf9a2..3e93b981 100644 --- a/tests/e2e/context_compaction/agent.ts +++ b/tests/e2e/context_compaction/agent.ts @@ -5,6 +5,8 @@ */ import { + AgentControlledContextCompactor, + ConsolidateContextTool, Gemini, LlmAgent, LlmSummarizer, @@ -31,3 +33,24 @@ export function createCompactionAgent(): LlmAgent { contextCompactors: [compactor], }); } + +export function createAgentControlledCompactionAgent(): LlmAgent { + const compactor = new AgentControlledContextCompactor({ + summarizer: new LlmSummarizer({ + llm: new Gemini({model: 'gemini-2.5-flash'}), + }), + }); + + return new LlmAgent({ + name: 'agent_controlled_compaction_agent', + description: + 'An agent configured to test live agent-controlled context compaction.', + instruction: + 'You are a helpful conversational AI. If the user asks you to consolidate context or wrap up ' + + 'what you have done so far, you MUST call the consolidate_context tool. ' + + 'Always respond concisely.', + model: 'gemini-2.5-flash', + tools: [new ConsolidateContextTool()], + contextCompactors: [compactor], + }); +} diff --git a/tests/e2e/context_compaction/agent_controlled_e2e_test.ts b/tests/e2e/context_compaction/agent_controlled_e2e_test.ts new file mode 100644 index 00000000..7d613d0c --- /dev/null +++ b/tests/e2e/context_compaction/agent_controlled_e2e_test.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BasePlugin, + ContextCompactionTrigger, + InMemoryRunner, + InvocationContext, + isCompactedEvent, +} from '@google/adk'; +import {createUserContent} from '@google/genai'; +import * as dotenv from 'dotenv'; +import * as fs from 'fs'; +import * as path from 'path'; +import {describe, expect, it} from 'vitest'; +import {createAgentControlledCompactionAgent} from './agent.js'; + +class TestCompactionPlugin extends BasePlugin { + beforeCalled = false; + afterCalled = false; + detectedTrigger?: ContextCompactionTrigger; + + constructor() { + super('TestCompactionPlugin'); + } + + override async beforeContextCompaction(params: { + invocationContext: InvocationContext; + trigger: ContextCompactionTrigger; + }) { + this.beforeCalled = true; + this.detectedTrigger = params.trigger; + } + + override async afterContextCompaction(_params: { + invocationContext: InvocationContext; + trigger: ContextCompactionTrigger; + }) { + this.afterCalled = true; + } +} + +describe('E2e Context Compaction Agent-Controlled', () => { + for (const p of [ + path.resolve(__dirname, '.env'), + path.resolve(__dirname, '../../../.env'), + ]) { + if (fs.existsSync(p)) { + dotenv.config({path: p}); + break; + } + } + + const hasAKey = + !!process.env.GEMINI_API_KEY || + !!process.env.GOOGLE_GENAI_API_KEY || + !!process.env.GOOGLE_CLOUD_PROJECT; + + it.skipIf(!hasAKey)( + 'should compact history when agent calls consolidate_context using Gemini API', + async () => { + const agent = createAgentControlledCompactionAgent(); + const plugin = new TestCompactionPlugin(); + const runner = new InMemoryRunner({ + agent, + appName: 'e2e_agent_controlled_test', + plugins: [plugin], + }); + const session = await runner.sessionService.createSession({ + appName: 'e2e_agent_controlled_test', + userId: 'test_user', + }); + + const turns = [ + 'Hi, I want to plan a trip to Tokyo. Let us start by listing 3 top attractions.', + 'Great, now please consolidate what we have planned so far and summarize it.', + ]; + + for (const prompt of turns) { + const responseGen = runner.runAsync({ + userId: 'test_user', + sessionId: session.id, + newMessage: createUserContent(prompt), + }); + + for await (const _ of responseGen) { + // Drain the generator to let the agent run and append events + } + } + + // Now retrieve the session and check its events + const updatedSession = await runner.sessionService.getSession({ + appName: 'e2e_agent_controlled_test', + userId: 'test_user', + sessionId: session.id, + }); + + // Find if there is a CompactedEvent + const compactedEvents = updatedSession!.events.filter(isCompactedEvent); + expect(compactedEvents.length).toBeGreaterThan(0); + + const latestCompacted = compactedEvents[compactedEvents.length - 1]; + expect(latestCompacted.compactedContent).toBeTruthy(); + expect(latestCompacted.compactedContent.length).toBeGreaterThan(0); + + // Verify that the plugin callbacks were called + expect(plugin.beforeCalled).toBe(true); + expect(plugin.afterCalled).toBe(true); + expect(plugin.detectedTrigger).toEqual( + ContextCompactionTrigger.AgentControlled, + ); + }, + 60000, // 60 sec timeout for e2e LLM tests (might take longer if multiple turns and tools) + ); +}); diff --git a/tests/integration/context_compaction/agent_controlled/agent.ts b/tests/integration/context_compaction/agent_controlled/agent.ts new file mode 100644 index 00000000..3a2cfc0b --- /dev/null +++ b/tests/integration/context_compaction/agent_controlled/agent.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AgentControlledContextCompactor, + ConsolidateContextTool, + LlmAgent, + LlmSummarizer, +} from '@google/adk'; +import {GeminiWithMockResponses} from '../../test_case_utils.js'; + +export const compactor = new AgentControlledContextCompactor({ + summarizer: new LlmSummarizer({ + llm: new GeminiWithMockResponses([ + { + candidates: [ + { + content: { + role: 'model', + parts: [{text: 'Compacted summary of the conversation.'}], + }, + }, + ], + }, + ]), + }), +}); + +export const rootAgent = new LlmAgent({ + name: 'agent_controlled_compaction_agent', + model: 'gemini-2.5-flash', + description: 'Agent to demonstrate agent-controlled context compaction.', + instruction: 'You are a helpful assistant.', + tools: [new ConsolidateContextTool()], + contextCompactors: [compactor], +}); diff --git a/tests/integration/context_compaction/agent_controlled/agent_test.ts b/tests/integration/context_compaction/agent_controlled/agent_test.ts new file mode 100644 index 00000000..f2b639b1 --- /dev/null +++ b/tests/integration/context_compaction/agent_controlled/agent_test.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {InMemoryRunner, isCompactedEvent} from '@google/adk'; +import {createUserContent} from '@google/genai'; +import {describe, expect, it} from 'vitest'; +import {GeminiWithMockResponses} from '../../test_case_utils.js'; +import {rootAgent} from './agent.js'; + +describe('Context Compaction Agent-Controlled', () => { + it('should compact session events when agent calls consolidate_context', async () => { + rootAgent.model = new GeminiWithMockResponses([ + // Turn 1 + { + candidates: [ + { + content: { + role: 'model', + parts: [{text: 'I am helping you with step 1.'}], + }, + }, + ], + }, + // Turn 2: LLM decides to call the tool + { + candidates: [ + { + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'consolidate_context', + args: {detail: 'Completed step 1'}, + }, + }, + ], + }, + }, + ], + }, + // Turn 2 (after tool result is fed back to LLM) + { + candidates: [ + { + content: { + role: 'model', + parts: [{text: 'I have consolidated the context.'}], + }, + }, + ], + }, + ]); + + const runner = new InMemoryRunner({ + agent: rootAgent, + appName: 'agent_controlled_compaction_agent', + }); + const session = await runner.sessionService.createSession({ + appName: 'agent_controlled_compaction_agent', + userId: 'test_user', + }); + + // Turn 1 + for await (const _ of runner.runAsync({ + userId: 'test_user', + sessionId: session.id, + newMessage: createUserContent('Help me with step 1'), + })) { + // intentionally empty + } + + // Turn 2 + for await (const _ of runner.runAsync({ + userId: 'test_user', + sessionId: session.id, + newMessage: createUserContent('Please consolidate context now'), + })) { + // intentionally empty + } + + // Assert that compaction occurred + const updatedSession = await runner.sessionService.getSession({ + sessionId: session.id, + userId: 'test_user', + appName: 'agent_controlled_compaction_agent', + }); + + const hasCompactedEvent = updatedSession!.events.some(isCompactedEvent); + expect(hasCompactedEvent).toBe(true); + + const compactedEvent = updatedSession!.events.find(isCompactedEvent); + expect(compactedEvent).toBeDefined(); + expect(compactedEvent?.compactedContent).toContain( + 'Compacted summary of the conversation.', + ); + }); +});