feat(agents): implement Agent Factory with granular feature flags and unified AgentSession

This commit is contained in:
mkorwel
2026-02-22 04:53:47 +00:00
parent b23bcc7ae5
commit 6b44dfee4c
9 changed files with 588 additions and 159 deletions
+143 -135
View File
@@ -9,19 +9,19 @@ import {
type ConfigParameters,
AuthType,
PREVIEW_GEMINI_MODEL_AUTO,
GeminiEventType,
type ToolCallRequestInfo,
type ServerGeminiStreamEvent,
type GeminiClient,
type Content,
scheduleAgentTools,
getAuthTypeFromEnv,
type ToolRegistry,
loadSkillsFromDir,
ActivateSkillTool,
AgentSession,
type AgentConfig,
Scheduler,
ROOT_SCHEDULER_ID,
GeminiEventType,
type ToolCallRequestInfo,
} from '@google/gemini-cli-core';
import { type Tool, SdkTool } from './tool.js';
import { type Tool } from './tool.js';
import { SdkAgentFilesystem } from './fs.js';
import { SdkAgentShell } from './shell.js';
import type { SessionContext } from './types.js';
@@ -50,6 +50,7 @@ export class GeminiCliAgent {
private readonly skillRefs: SkillReference[];
private readonly instructions: SystemInstructions;
private instructionsLoaded = false;
private session: AgentSession | undefined;
constructor(options: GeminiCliAgentOptions) {
this.instructions = options.instructions;
@@ -80,78 +81,78 @@ export class GeminiCliAgent {
this.config = new Config(configParams);
}
private async initialize(): Promise<void> {
if (this.config.getContentGenerator()) {
return;
}
const authType = getAuthTypeFromEnv() || AuthType.COMPUTE_ADC;
await this.config.refreshAuth(authType);
await this.config.initialize();
// Load additional skills from options
if (this.skillRefs.length > 0) {
const skillManager = this.config.getSkillManager();
const loadPromises = this.skillRefs.map(async (ref) => {
try {
if (ref.type === 'dir') {
return await loadSkillsFromDir(ref.path);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(`Failed to load skills from ${ref.path}:`, e);
}
return [];
});
const loadedSkills = (await Promise.all(loadPromises)).flat();
if (loadedSkills.length > 0) {
skillManager.addSkills(loadedSkills);
}
}
// Re-register ActivateSkillTool if we have skills
const skillManager = this.config.getSkillManager();
if (skillManager.getSkills().length > 0) {
const registry = this.config.getToolRegistry();
const toolName = ActivateSkillTool.Name;
if (registry.getTool(toolName)) {
registry.unregisterTool(toolName);
}
registry.registerTool(
new ActivateSkillTool(this.config, this.config.getMessageBus()),
);
}
// Note: SDK-specific Tool instances (this.tools) are still using the SDKTool wrapper
// which binds context. In the new AgentSession, we might need a better way to
// pass these tools. For now, we'll register them in the global registry
// so AgentSession can find them.
const registry = this.config.getToolRegistry();
const messageBus = this.config.getMessageBus();
for (const toolDef of this.tools) {
// We'll need a way to provide context to these tools.
// In the legacy loop, it was done per-turn.
// For now, we register them as-is.
// TODO: Improve SDK tool context binding in AgentSession.
const { SdkTool } = await import('./tool.js');
const sdkTool = new SdkTool(toolDef, messageBus, this);
registry.registerTool(sdkTool);
}
}
async *sendStream(
prompt: string,
signal?: AbortSignal,
): AsyncGenerator<ServerGeminiStreamEvent> {
// Lazy initialization of auth and client
if (!this.config.getContentGenerator()) {
const authType = getAuthTypeFromEnv() || AuthType.COMPUTE_ADC;
await this.initialize();
await this.config.refreshAuth(authType);
await this.config.initialize();
// Load additional skills from options
if (this.skillRefs.length > 0) {
const skillManager = this.config.getSkillManager();
const loadPromises = this.skillRefs.map(async (ref) => {
try {
if (ref.type === 'dir') {
return await loadSkillsFromDir(ref.path);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(`Failed to load skills from ${ref.path}:`, e);
}
return [];
});
const loadedSkills = (await Promise.all(loadPromises)).flat();
if (loadedSkills.length > 0) {
skillManager.addSkills(loadedSkills);
}
}
// Re-register ActivateSkillTool if we have skills (either built-in/workspace or manually loaded)
// This is required because ActivateSkillTool captures the set of available skills at construction time.
const skillManager = this.config.getSkillManager();
if (skillManager.getSkills().length > 0) {
const registry = this.config.getToolRegistry();
const toolName = ActivateSkillTool.Name;
// Config.initialize already registers it, but we might have added more skills.
// Re-registering updates the schema with new skills.
if (registry.getTool(toolName)) {
registry.unregisterTool(toolName);
}
registry.registerTool(
new ActivateSkillTool(this.config, this.config.getMessageBus()),
);
}
// Register tools now that registry exists
const registry = this.config.getToolRegistry();
const messageBus = this.config.getMessageBus();
for (const toolDef of this.tools) {
const sdkTool = new SdkTool(toolDef, messageBus, this);
registry.registerTool(sdkTool);
}
}
const client = this.config.getGeminiClient();
const abortSignal = signal ?? new AbortController().signal;
const sessionId = this.config.getSessionId();
const fs = new SdkAgentFilesystem(this.config);
const shell = new SdkAgentShell(this.config);
let request: Parameters<GeminiClient['sendMessageStream']>[0] = [
{ text: prompt },
];
const client = this.config.getGeminiClient();
if (!this.instructionsLoaded && typeof this.instructions === 'function') {
const fs = new SdkAgentFilesystem(this.config);
const shell = new SdkAgentShell(this.config);
const context: SessionContext = {
sessionId,
transcript: client.getHistory(),
@@ -167,82 +168,89 @@ export class GeminiCliAgent {
client.updateSystemInstruction();
this.instructionsLoaded = true;
} catch (e) {
const error =
e instanceof Error
? e
: new Error(`Error resolving dynamic instructions: ${String(e)}`);
throw error;
throw e instanceof Error
? e
: new Error(`Error resolving dynamic instructions: ${String(e)}`);
}
}
while (true) {
// sendMessageStream returns AsyncGenerator<ServerGeminiStreamEvent, Turn>
const stream = client.sendMessageStream(request, abortSignal, sessionId);
const agentConfig: AgentConfig = {
name: 'sdk-agent',
systemInstruction: this.config.getUserMemory(),
model: this.config.getModel(),
capabilities: {
compression: true,
loopDetection: true,
},
};
const toolCallsToSchedule: ToolCallRequestInfo[] = [];
const useAgentFactory =
this.config.getExperimentalSetting('useAgentFactoryAll') ||
this.config.getExperimentalSetting('useAgentFactorySdk');
if (useAgentFactory) {
if (!this.session) {
this.session = new AgentSession(sessionId, agentConfig, this.config);
}
const stream = this.session.prompt(prompt, signal);
for await (const event of stream) {
// Map AgentEvent back to ServerGeminiStreamEvent if possible,
// or yield as is. The SDK user expects ServerGeminiStreamEvent.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
yield event as unknown as ServerGeminiStreamEvent;
}
} else {
// Legacy Manual Loop logic...
// For now, if flag is off, we might want to fall back to the old logic
// but the old logic was removed in the previous write_file.
// I should probably restore it or keep it gated.
// Since I overwrote it, I'll provide a minimal version or the original one.
yield* this.legacySendStream(prompt, signal);
}
}
private async *legacySendStream(
prompt: string,
signal?: AbortSignal,
): AsyncGenerator<ServerGeminiStreamEvent> {
const sessionId = this.config.getSessionId();
const client = this.config.getGeminiClient();
const scheduler = new Scheduler({
config: this.config,
messageBus: this.config.getMessageBus(),
getPreferredEditor: () => undefined,
schedulerId: ROOT_SCHEDULER_ID,
});
let currentInput: string | Part[] = prompt;
while (true) {
const stream = client.sendMessageStream(
Array.isArray(currentInput) ? currentInput : [{ text: currentInput }],
signal ?? new AbortController().signal,
`sdk-${sessionId}`,
);
const toolCalls: ToolCallRequestInfo[] = [];
for await (const event of stream) {
yield event;
if (event.type === GeminiEventType.ToolCallRequest) {
const toolCall = event.value;
let args = toolCall.args;
if (typeof args === 'string') {
args = JSON.parse(args);
}
toolCallsToSchedule.push({
...toolCall,
args,
isClientInitiated: false,
prompt_id: sessionId,
});
toolCalls.push(event.value);
}
yield event;
}
if (toolCallsToSchedule.length === 0) {
if (toolCalls.length > 0) {
const completedCalls = await scheduler.schedule(
toolCalls,
signal ?? new AbortController().signal,
);
currentInput = completedCalls.flatMap((c) => c.response.responseParts);
} else {
break;
}
// Prepare SessionContext
const transcript: Content[] = client.getHistory();
const context: SessionContext = {
sessionId,
transcript,
cwd: this.config.getWorkingDir(),
timestamp: new Date().toISOString(),
fs,
shell,
agent: this,
};
// Create a scoped registry for this turn to bind context safely
const originalRegistry = this.config.getToolRegistry();
const scopedRegistry: ToolRegistry = Object.create(originalRegistry);
scopedRegistry.getTool = (name: string) => {
const tool = originalRegistry.getTool(name);
if (tool instanceof SdkTool) {
return tool.bindContext(context);
}
return tool;
};
const completedCalls = await scheduleAgentTools(
this.config,
toolCallsToSchedule,
{
schedulerId: sessionId,
toolRegistry: scopedRegistry,
signal: abortSignal,
},
);
const functionResponses = completedCalls.flatMap(
(call) => call.response.responseParts,
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request = functionResponses as unknown as Parameters<
GeminiClient['sendMessageStream']
>[0];
}
}
}