mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-10 19:37:17 -07:00
WIP - draft for agent factory
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Agent } from './agent.js';
|
||||
import { AgentSession } from './session.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import { type AgentConfig } from './types.js';
|
||||
|
||||
vi.mock('./session.js', () => ({
|
||||
AgentSession: vi.fn().mockImplementation(() => ({
|
||||
prompt: vi.fn().mockImplementation(async function* () {
|
||||
yield { type: 'agent_start', value: { sessionId: 'test-session' } };
|
||||
yield {
|
||||
type: 'agent_finish',
|
||||
value: { sessionId: 'test-session', totalTurns: 1 },
|
||||
};
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Agent', () => {
|
||||
let mockConfig: ReturnType<typeof makeFakeConfig>;
|
||||
const agentConfig: AgentConfig = {
|
||||
name: 'TestAgent',
|
||||
systemInstruction: 'You are a test agent.',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getSessionId').mockReturnValue('global-session-id');
|
||||
});
|
||||
|
||||
it('should create an AgentSession', () => {
|
||||
const agent = new Agent(agentConfig, mockConfig);
|
||||
const session = agent.createSession('custom-session-id');
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(AgentSession).toHaveBeenCalledWith(
|
||||
'custom-session-id',
|
||||
agentConfig,
|
||||
mockConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use global session ID if none provided to createSession', () => {
|
||||
const agent = new Agent(agentConfig, mockConfig);
|
||||
agent.createSession();
|
||||
|
||||
expect(AgentSession).toHaveBeenCalledWith(
|
||||
'global-session-id',
|
||||
agentConfig,
|
||||
mockConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prompt through a new session', async () => {
|
||||
const agent = new Agent(agentConfig, mockConfig);
|
||||
const events = [];
|
||||
for await (const event of agent.prompt('Hello')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].type).toBe('agent_start');
|
||||
expect(events[1].type).toBe('agent_finish');
|
||||
expect(AgentSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Part } from '@google/genai';
|
||||
import { type Config } from '../config/config.js';
|
||||
import { type AgentEvent, type AgentConfig } from './types.js';
|
||||
import { AgentSession } from './session.js';
|
||||
|
||||
/**
|
||||
* The Agent class is a factory for creating stateful AgentSessions.
|
||||
* This represents a configured agent template.
|
||||
*/
|
||||
export class Agent {
|
||||
constructor(
|
||||
private readonly config: AgentConfig,
|
||||
private readonly runtime: Config,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a new stateful session for interacting with the agent.
|
||||
*/
|
||||
createSession(sessionId?: string): AgentSession {
|
||||
const id = sessionId ?? this.runtime.getSessionId();
|
||||
return new AgentSession(id, this.config, this.runtime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to quickly run a single prompt and get the results.
|
||||
*/
|
||||
async *prompt(
|
||||
input: string | Part[],
|
||||
sessionId?: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncIterable<AgentEvent> {
|
||||
const session = this.createSession(sessionId);
|
||||
yield* session.prompt(input, signal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AgentSession } from './session.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import { type AgentConfig } from './types.js';
|
||||
import { Scheduler } from '../scheduler/scheduler.js';
|
||||
import { GeminiEventType } from '../core/turn.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
import { CompressionStatus } from '../core/turn.js';
|
||||
import { AgentTerminateMode, type AgentEvent } from './types.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
|
||||
vi.mock('../core/client.js');
|
||||
vi.mock('../scheduler/scheduler.js');
|
||||
vi.mock('../services/chatCompressionService.js');
|
||||
|
||||
describe('AgentSession', () => {
|
||||
let mockConfig: ReturnType<typeof makeFakeConfig>;
|
||||
let mockClient: {
|
||||
sendMessageStream: ReturnType<typeof vi.fn>;
|
||||
getChat: ReturnType<typeof vi.fn>;
|
||||
getCurrentSequenceModel: ReturnType<typeof vi.fn>;
|
||||
getHistory: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockScheduler: {
|
||||
schedule: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockCompressionService: {
|
||||
compress: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let session: AgentSession;
|
||||
const agentConfig: AgentConfig = {
|
||||
name: 'TestAgent',
|
||||
capabilities: { compression: true },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = makeFakeConfig();
|
||||
|
||||
mockClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
getChat: vi.fn().mockReturnValue({
|
||||
recordCompletedToolCalls: vi.fn(),
|
||||
setHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getCurrentSequenceModel: vi.fn().mockReturnValue('test-model'),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
|
||||
mockScheduler = {
|
||||
schedule: vi.fn(),
|
||||
};
|
||||
|
||||
mockCompressionService = {
|
||||
compress: vi.fn().mockResolvedValue({
|
||||
newHistory: null,
|
||||
info: { compressionStatus: CompressionStatus.NOOP },
|
||||
}),
|
||||
};
|
||||
|
||||
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(
|
||||
mockClient as unknown as import('../core/client.js').GeminiClient,
|
||||
);
|
||||
vi.mocked(Scheduler).mockImplementation(
|
||||
() => mockScheduler as unknown as Scheduler,
|
||||
);
|
||||
vi.mocked(ChatCompressionService).mockImplementation(
|
||||
() => mockCompressionService as unknown as ChatCompressionService,
|
||||
);
|
||||
|
||||
session = new AgentSession('test-session', agentConfig, mockConfig);
|
||||
});
|
||||
|
||||
it('should emit agent_start and agent_finish', async () => {
|
||||
mockClient.sendMessageStream.mockImplementation(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Hello' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Hi')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const finishEvent = events[events.length - 1] as Extract<
|
||||
AgentEvent,
|
||||
{ type: 'agent_finish' }
|
||||
>;
|
||||
expect(events[0].type).toBe('agent_start');
|
||||
expect(finishEvent.type).toBe('agent_finish');
|
||||
expect(finishEvent.value.reason).toBe(AgentTerminateMode.GOAL);
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle tool calls and execute them', async () => {
|
||||
// Turn 1: Model calls a tool
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'call1', name: 'test_tool', args: {} },
|
||||
};
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
// Turn 2: Model finishes
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Tool executed' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
mockScheduler.schedule.mockResolvedValueOnce([
|
||||
{
|
||||
response: {
|
||||
callId: 'call1',
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'test_tool',
|
||||
response: { ok: true },
|
||||
id: 'call1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Run tool')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(mockScheduler.schedule).toHaveBeenCalledTimes(1);
|
||||
|
||||
const suiteStart = events.find((e) => e.type === 'tool_suite_start');
|
||||
const suiteFinish = events.find((e) => e.type === 'tool_suite_finish');
|
||||
expect(suiteStart).toBeDefined();
|
||||
expect(suiteFinish).toBeDefined();
|
||||
expect(suiteFinish?.value.responses[0].callId).toBe('call1');
|
||||
});
|
||||
|
||||
it('should trigger compression if enabled', async () => {
|
||||
mockClient.sendMessageStream.mockImplementation(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Done' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
for await (const _ of session.prompt('Compress me')) {
|
||||
// consume stream to trigger compression
|
||||
}
|
||||
|
||||
expect(mockCompressionService.compress).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respect abort signal', async () => {
|
||||
const controller = new AbortController();
|
||||
mockClient.sendMessageStream.mockImplementation(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Thinking...' };
|
||||
controller.abort();
|
||||
yield { type: GeminiEventType.Content, value: 'Still thinking...' };
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Long task', controller.signal)) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// Should finish early
|
||||
const finishEvent = events[events.length - 1] as Extract<
|
||||
AgentEvent,
|
||||
{ type: 'agent_finish' }
|
||||
>;
|
||||
expect(finishEvent.type).toBe('agent_finish');
|
||||
expect(finishEvent.value.reason).toBe(AgentTerminateMode.ABORTED);
|
||||
// It might still yield the first chunk before the signal is processed in the loop
|
||||
});
|
||||
|
||||
it('should emit ERROR reason when a tool requests stop', async () => {
|
||||
// Turn 1: Model calls a tool
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'call_stop', name: 'stop_tool', args: {} },
|
||||
};
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
mockScheduler.schedule.mockResolvedValueOnce([
|
||||
{
|
||||
response: {
|
||||
callId: 'call_stop',
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Deny listed command'),
|
||||
responseParts: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Run tool')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const finishEvent = events.find(
|
||||
(e) => e.type === 'agent_finish',
|
||||
) as Extract<AgentEvent, { type: 'agent_finish' }>;
|
||||
expect(finishEvent).toBeDefined();
|
||||
expect(finishEvent.value.reason).toBe(AgentTerminateMode.ERROR);
|
||||
expect(finishEvent.value.message).toBe('Deny listed command');
|
||||
});
|
||||
|
||||
it('should respect maxTurns from config', async () => {
|
||||
const customSession = new AgentSession(
|
||||
'test-session-2',
|
||||
{ ...agentConfig, maxTurns: 2 },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
// Mock an infinite loop of tool calls from the model
|
||||
mockClient.sendMessageStream.mockImplementation(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'call', name: 'test_tool', args: {} },
|
||||
};
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
mockScheduler.schedule.mockResolvedValue([
|
||||
{
|
||||
response: {
|
||||
callId: 'call',
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'test_tool',
|
||||
response: { ok: true },
|
||||
id: 'call',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const events = [];
|
||||
for await (const event of customSession.prompt('Start loop')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// It should perform exactly 2 turns, meaning mockScheduler.schedule is called twice
|
||||
expect(mockScheduler.schedule).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The last event should be agent_finish
|
||||
const finishEvent = events[events.length - 1] as Extract<
|
||||
AgentEvent,
|
||||
{ type: 'agent_finish' }
|
||||
>;
|
||||
expect(finishEvent.type).toBe('agent_finish');
|
||||
expect(finishEvent.value.totalTurns).toBe(2);
|
||||
expect(finishEvent.value.reason).toBe(AgentTerminateMode.MAX_TURNS);
|
||||
expect(finishEvent.value.message).toBe('Maximum session turns exceeded.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Part } from '@google/genai';
|
||||
import { type Config } from '../config/config.js';
|
||||
import { type GeminiClient } from '../core/client.js';
|
||||
import { type AgentEvent, type AgentConfig } from './types.js';
|
||||
import { Scheduler } from '../scheduler/scheduler.js';
|
||||
import {
|
||||
ROOT_SCHEDULER_ID,
|
||||
type ToolCallRequestInfo,
|
||||
} from '../scheduler/types.js';
|
||||
import { GeminiEventType, CompressionStatus } from '../core/turn.js';
|
||||
import { recordToolCallInteractions } from '../code_assist/telemetry.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
import { AgentTerminateMode } from './types.js';
|
||||
import type { ResumedSessionData } from '../services/chatRecordingService.js';
|
||||
import { convertSessionToClientHistory } from '../utils/sessionUtils.js';
|
||||
|
||||
/**
|
||||
* AgentSession manages the state of a conversation and orchestrates the agent
|
||||
* loop.
|
||||
*/
|
||||
export class AgentSession {
|
||||
private readonly client: GeminiClient;
|
||||
private readonly scheduler: Scheduler;
|
||||
private readonly compressionService: ChatCompressionService;
|
||||
private totalTurns = 0;
|
||||
private hasFailedCompressionAttempt = false;
|
||||
|
||||
constructor(
|
||||
private readonly sessionId: string,
|
||||
private readonly config: AgentConfig,
|
||||
private readonly runtime: Config,
|
||||
) {
|
||||
// For now, we reuse the GeminiClient from the global config.
|
||||
this.client = this.runtime.getGeminiClient();
|
||||
this.scheduler = new Scheduler({
|
||||
config: this.runtime,
|
||||
messageBus: this.runtime.getMessageBus(),
|
||||
getPreferredEditor: () => undefined,
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
});
|
||||
this.compressionService = new ChatCompressionService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the agent session from persistent storage data.
|
||||
* Hydrates the internal language model client with the previously saved trajectory.
|
||||
*
|
||||
* @param resumedSessionData The raw payload of a previously saved session.
|
||||
*/
|
||||
async resume(resumedSessionData: ResumedSessionData): Promise<void> {
|
||||
const clientHistory = convertSessionToClientHistory(
|
||||
resumedSessionData.conversation.messages,
|
||||
);
|
||||
await this.client.resumeChat(clientHistory, resumedSessionData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the ReAct loop for a given user input.
|
||||
* Returns an AsyncIterable of events occurring during the session.
|
||||
*/
|
||||
async *prompt(
|
||||
input: string | Part[],
|
||||
signal?: AbortSignal,
|
||||
): AsyncIterable<AgentEvent> {
|
||||
yield {
|
||||
type: 'agent_start',
|
||||
value: { sessionId: this.sessionId },
|
||||
};
|
||||
|
||||
let currentInput = input;
|
||||
let isContinuation = false;
|
||||
const maxTurns = this.config.maxTurns ?? -1;
|
||||
|
||||
let terminationReason = AgentTerminateMode.GOAL;
|
||||
let terminationMessage: string | undefined = undefined;
|
||||
let terminationError: unknown | undefined = undefined;
|
||||
|
||||
try {
|
||||
while (maxTurns === -1 || this.totalTurns < maxTurns) {
|
||||
if (signal?.aborted) {
|
||||
terminationReason = AgentTerminateMode.ABORTED;
|
||||
break;
|
||||
}
|
||||
|
||||
this.totalTurns++;
|
||||
const promptId = `${this.sessionId}#${this.totalTurns}`;
|
||||
|
||||
// Compression check (from LocalAgentExecutor / useGeminiStream patterns)
|
||||
if (this.config.capabilities?.compression) {
|
||||
await this.tryCompressChat(promptId);
|
||||
}
|
||||
|
||||
const { toolCalls, events } = await this.runModelTurn(
|
||||
currentInput,
|
||||
promptId,
|
||||
isContinuation ? undefined : input,
|
||||
signal,
|
||||
);
|
||||
|
||||
for await (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
terminationReason = AgentTerminateMode.ABORTED;
|
||||
break;
|
||||
}
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
const results = await this.executeTools(toolCalls, signal);
|
||||
for await (const event of results.events) {
|
||||
yield event;
|
||||
}
|
||||
|
||||
if (results.stopExecution || signal?.aborted) {
|
||||
if (signal?.aborted) {
|
||||
terminationReason = AgentTerminateMode.ABORTED;
|
||||
} else if (results.stopExecutionInfo) {
|
||||
terminationReason = AgentTerminateMode.ERROR;
|
||||
terminationMessage = results.stopExecutionInfo.error?.message;
|
||||
terminationError = results.stopExecutionInfo.error;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we hit the turn limit
|
||||
if (maxTurns !== -1 && this.totalTurns >= maxTurns) {
|
||||
terminationReason = AgentTerminateMode.MAX_TURNS;
|
||||
terminationMessage = 'Maximum session turns exceeded.';
|
||||
break;
|
||||
}
|
||||
|
||||
currentInput = results.nextParts;
|
||||
isContinuation = true;
|
||||
} else {
|
||||
// No more tool calls, turn is complete.
|
||||
// If we completed naturally but were at the limit, it's still a GOAL
|
||||
terminationReason = AgentTerminateMode.GOAL;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
yield {
|
||||
type: 'agent_finish',
|
||||
value: {
|
||||
sessionId: this.sessionId,
|
||||
totalTurns: this.totalTurns,
|
||||
reason: terminationReason,
|
||||
message: terminationMessage,
|
||||
error: terminationError,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the model and yields the event stream.
|
||||
* Collects tool call requests for the next phase.
|
||||
*/
|
||||
private async runModelTurn(
|
||||
input: string | Part[],
|
||||
promptId: string,
|
||||
displayContent?: string | Part[],
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const parts = Array.isArray(input) ? input : [{ text: input }];
|
||||
const toolCalls: ToolCallRequestInfo[] = [];
|
||||
|
||||
const stream = this.client.sendMessageStream(
|
||||
parts,
|
||||
signal ?? new AbortController().signal,
|
||||
promptId,
|
||||
undefined, // maxTurns (client handles its own)
|
||||
false, // isInvalidStreamRetry
|
||||
displayContent,
|
||||
);
|
||||
|
||||
const eventGenerator = async function* (): AsyncIterable<AgentEvent> {
|
||||
for await (const event of stream) {
|
||||
if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCalls.push(event.value);
|
||||
}
|
||||
yield event as AgentEvent;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
toolCalls,
|
||||
events: eventGenerator(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a batch of tool calls via the Scheduler.
|
||||
*/
|
||||
private async executeTools(
|
||||
toolCalls: ToolCallRequestInfo[],
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const events: AgentEvent[] = [];
|
||||
events.push({
|
||||
type: 'tool_suite_start',
|
||||
value: { count: toolCalls.length },
|
||||
});
|
||||
|
||||
const completedCalls = await this.scheduler.schedule(
|
||||
toolCalls,
|
||||
signal ?? new AbortController().signal,
|
||||
);
|
||||
|
||||
events.push({
|
||||
type: 'tool_suite_finish',
|
||||
value: { responses: completedCalls.map((c) => c.response) },
|
||||
});
|
||||
|
||||
// Record tool call info for persistence/telemetry
|
||||
try {
|
||||
const currentModel =
|
||||
this.client.getCurrentSequenceModel() ?? this.runtime.getModel();
|
||||
this.client
|
||||
.getChat()
|
||||
.recordCompletedToolCalls(currentModel, completedCalls);
|
||||
await recordToolCallInteractions(this.runtime, completedCalls);
|
||||
} catch (e) {
|
||||
debugLogger.warn(`Error recording tool call information: ${e}`);
|
||||
}
|
||||
|
||||
const nextParts = completedCalls.flatMap((c) => c.response.responseParts);
|
||||
const stopExecutionInfo = completedCalls.find(
|
||||
(c) => c.response.errorType === ToolErrorType.STOP_EXECUTION,
|
||||
)?.response;
|
||||
|
||||
const eventGenerator = async function* () {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
nextParts,
|
||||
stopExecution: !!stopExecutionInfo,
|
||||
stopExecutionInfo,
|
||||
events: eventGenerator(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to compress the chat history if thresholds are exceeded.
|
||||
*/
|
||||
private async tryCompressChat(promptId: string): Promise<void> {
|
||||
const chat = this.client.getChat();
|
||||
const model = this.config.model ?? this.runtime.getModel();
|
||||
|
||||
const { newHistory, info } = await this.compressionService.compress(
|
||||
chat,
|
||||
promptId,
|
||||
false,
|
||||
model,
|
||||
this.runtime,
|
||||
this.hasFailedCompressionAttempt,
|
||||
);
|
||||
|
||||
if (
|
||||
info.compressionStatus ===
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT
|
||||
) {
|
||||
this.hasFailedCompressionAttempt = true;
|
||||
} else if (info.compressionStatus === CompressionStatus.COMPRESSED) {
|
||||
if (newHistory) {
|
||||
chat.setHistory(newHistory);
|
||||
this.hasFailedCompressionAttempt = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current message history for this session.
|
||||
*/
|
||||
getHistory() {
|
||||
return this.client.getHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current session ID.
|
||||
*/
|
||||
getSessionId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Defines the core configuration interfaces and types for the agent architecture.
|
||||
*/
|
||||
|
||||
import type { Content, FunctionDeclaration } from '@google/genai';
|
||||
import type { AnyDeclarativeTool } from '../tools/tools.js';
|
||||
import { type z } from 'zod';
|
||||
import type { ModelConfig } from '../services/modelConfigService.js';
|
||||
import type { AnySchema } from 'ajv';
|
||||
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||
import { type ServerGeminiStreamEvent } from '../core/turn.js';
|
||||
import { type ToolCallResponseInfo } from '../scheduler/types.js';
|
||||
|
||||
/**
|
||||
* Unified event type for the Agent loop.
|
||||
* This extends the base Gemini stream events with higher-level agent lifecycle events.
|
||||
*/
|
||||
export type AgentEvent =
|
||||
| ServerGeminiStreamEvent
|
||||
| { type: 'agent_start'; value: { sessionId: string } }
|
||||
| {
|
||||
type: 'agent_finish';
|
||||
value: {
|
||||
sessionId: string;
|
||||
totalTurns: number;
|
||||
reason: AgentTerminateMode;
|
||||
message?: string;
|
||||
error?: unknown;
|
||||
};
|
||||
}
|
||||
| { type: 'tool_suite_start'; value: { count: number } }
|
||||
| { type: 'tool_suite_finish'; value: { responses: ToolCallResponseInfo[] } }
|
||||
| { type: 'thought'; value: string }
|
||||
| { type: 'loop_detected'; value: { sessionId: string } };
|
||||
|
||||
/**
|
||||
* Configuration for an Agent.
|
||||
*/
|
||||
export interface AgentConfig {
|
||||
/** The name of the agent. */
|
||||
name: string;
|
||||
/** The system instruction (personality/rules) for the agent. */
|
||||
systemInstruction?: string;
|
||||
/** Optional override for the model to use. */
|
||||
model?: string;
|
||||
/**
|
||||
* Optional maximum number of conversational turns.
|
||||
* Set to -1 for no limit, defaults to -1 if not specified.
|
||||
*/
|
||||
maxTurns?: number;
|
||||
/**
|
||||
* Optional capabilities to enable for this agent.
|
||||
*/
|
||||
capabilities?: {
|
||||
compression?: boolean;
|
||||
loopDetection?: boolean;
|
||||
ideContext?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the possible termination modes for an agent.
|
||||
|
||||
@@ -106,6 +106,7 @@ export * from './utils/secure-browser-launcher.js';
|
||||
export * from './utils/apiConversionUtils.js';
|
||||
export * from './utils/channel.js';
|
||||
export * from './utils/constants.js';
|
||||
export * from './utils/sessionUtils.js';
|
||||
|
||||
// Export services
|
||||
export * from './services/fileDiscoveryService.js';
|
||||
@@ -143,6 +144,7 @@ export * from './agents/types.js';
|
||||
export * from './agents/agentLoader.js';
|
||||
export * from './agents/local-executor.js';
|
||||
export * from './agents/agent-scheduler.js';
|
||||
export * from './agents/session.js';
|
||||
|
||||
// Export specific tool logic
|
||||
export * from './tools/read-file.js';
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { convertSessionToClientHistory } from './sessionUtils.js';
|
||||
import { type ConversationRecord } from '../services/chatRecordingService.js';
|
||||
import { CoreToolCallStatus } from '../scheduler/types.js';
|
||||
|
||||
describe('convertSessionToClientHistory', () => {
|
||||
it('should convert a simple conversation without tool calls', () => {
|
||||
const messages: ConversationRecord['messages'] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:00:00Z',
|
||||
content: 'Hello',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'gemini',
|
||||
timestamp: '2024-01-01T10:01:00Z',
|
||||
content: 'Hi there',
|
||||
},
|
||||
];
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi there' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore info, error, and slash commands', () => {
|
||||
const messages: ConversationRecord['messages'] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'info',
|
||||
timestamp: '2024-01-01T10:00:00Z',
|
||||
content: 'System info',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:01:00Z',
|
||||
content: '/clear',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:02:00Z',
|
||||
content: '?help',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:03:00Z',
|
||||
content: 'Actual query',
|
||||
},
|
||||
];
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Actual query' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should correct map tool calls and their responses', () => {
|
||||
const messages: ConversationRecord['messages'] = [
|
||||
{
|
||||
id: 'msg1',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:00:00Z',
|
||||
content: 'List files',
|
||||
},
|
||||
{
|
||||
id: 'msg2',
|
||||
type: 'gemini',
|
||||
timestamp: '2024-01-01T10:01:00Z',
|
||||
content: 'Let me check.',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call123',
|
||||
name: 'ls',
|
||||
args: { dir: '.' },
|
||||
status: CoreToolCallStatus.Success,
|
||||
timestamp: '2024-01-01T10:01:05Z',
|
||||
result: 'file.txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'List files' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Let me check.' },
|
||||
{ functionCall: { name: 'ls', args: { dir: '.' }, id: 'call123' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call123',
|
||||
name: 'ls',
|
||||
response: { output: 'file.txt' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Part } from '@google/genai';
|
||||
import { type ConversationRecord } from '../services/chatRecordingService.js';
|
||||
import { partListUnionToString } from '../core/geminiRequest.js';
|
||||
|
||||
/**
|
||||
* Converts session/conversation data into Gemini client history formats.
|
||||
*/
|
||||
export function convertSessionToClientHistory(
|
||||
messages: ConversationRecord['messages'],
|
||||
): Array<{ role: 'user' | 'model'; parts: Part[] }> {
|
||||
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.type === 'user') {
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (
|
||||
contentString.trim().startsWith('/') ||
|
||||
contentString.trim().startsWith('?')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: Array.isArray(msg.content)
|
||||
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(msg.content as Part[])
|
||||
: [{ text: contentString }],
|
||||
});
|
||||
} else if (msg.type === 'gemini') {
|
||||
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
|
||||
|
||||
if (hasToolCalls) {
|
||||
const modelParts: Part[] = [];
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
modelParts.push({ text: contentString });
|
||||
}
|
||||
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
modelParts.push({
|
||||
functionCall: {
|
||||
name: toolCall.name,
|
||||
args: toolCall.args,
|
||||
...(toolCall.id && { id: toolCall.id }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: modelParts,
|
||||
});
|
||||
|
||||
const functionResponseParts: Part[] = [];
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
if (toolCall.result) {
|
||||
let responseData: Part;
|
||||
|
||||
if (typeof toolCall.result === 'string') {
|
||||
responseData = {
|
||||
functionResponse: {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
response: {
|
||||
output: toolCall.result,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (Array.isArray(toolCall.result)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
functionResponseParts.push(...(toolCall.result as Part[]));
|
||||
continue;
|
||||
} else {
|
||||
responseData = toolCall.result;
|
||||
}
|
||||
|
||||
functionResponseParts.push(responseData);
|
||||
}
|
||||
}
|
||||
|
||||
if (functionResponseParts.length > 0) {
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: functionResponseParts,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: [{ text: contentString }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clientHistory;
|
||||
}
|
||||
Reference in New Issue
Block a user