diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 36fe970cdf..1cd8629db5 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -317,7 +317,7 @@ export class AgentRegistry { * it will be overwritten, respecting the precedence established by the * initialization order. */ - protected async registerAgent( + async registerAgent( definition: AgentDefinition, ): Promise { if (definition.kind === 'local') { diff --git a/packages/core/src/agents/teamLoader.test.ts b/packages/core/src/agents/teamLoader.test.ts new file mode 100644 index 0000000000..3408d6f731 --- /dev/null +++ b/packages/core/src/agents/teamLoader.test.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { loadTeamsFromDirectory } from './teamLoader.js'; + +describe('teamLoader', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + async function createTeamStructure( + teamName: string, + teamMdContent: string, + agents: Record = {}, + ) { + const teamPath = path.join(tempDir, teamName); + await fs.mkdir(teamPath, { recursive: true }); + await fs.writeFile(path.join(teamPath, 'TEAM.md'), teamMdContent); + + if (Object.keys(agents).length > 0) { + const agentsPath = path.join(teamPath, 'agents'); + await fs.mkdir(agentsPath, { recursive: true }); + for (const [name, content] of Object.entries(agents)) { + await fs.writeFile(path.join(agentsPath, `${name}.md`), content); + } + } + return teamPath; + } + + it('should load a valid team with agents', async () => { + await createTeamStructure( + 'my-team', + `--- +name: my-team +display_name: My Team +description: A great team +--- +Team instructions here.`, + { + 'agent-1': `--- +name: agent-1 +description: Agent 1 +--- +Agent 1 prompt`, + }, + ); + + const result = await loadTeamsFromDirectory(tempDir); + expect(result.teams).toHaveLength(1); + expect(result.errors).toHaveLength(0); + + const team = result.teams[0]; + expect(team.name).toBe('my-team'); + expect(team.displayName).toBe('My Team'); + expect(team.description).toBe('A great team'); + expect(team.instructions).toBe('Team instructions here.'); + expect(team.agents).toHaveLength(1); + expect(team.agents[0].name).toBe('agent-1'); + expect(team.metadata?.filePath).toContain('my-team/TEAM.md'); + }); + + it('should skip directories without TEAM.md', async () => { + await fs.mkdir(path.join(tempDir, 'not-a-team'), { recursive: true }); + await fs.writeFile(path.join(tempDir, 'not-a-team', 'README.md'), 'test'); + + const result = await loadTeamsFromDirectory(tempDir); + expect(result.teams).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); + + it('should handle multiple teams', async () => { + await createTeamStructure( + 'team-1', + `--- +name: team-1 +display_name: Team One +description: First team +--- +Instructions 1`, + ); + await createTeamStructure( + 'team-2', + `--- +name: team-2 +display_name: Team Two +description: Second team +--- +Instructions 2`, + ); + + const result = await loadTeamsFromDirectory(tempDir); + expect(result.teams).toHaveLength(2); + const names = result.teams.map((t) => t.name).sort(); + expect(names).toEqual(['team-1', 'team-2']); + }); + + it('should capture errors for malformed TEAM.md', async () => { + const teamPath = path.join(tempDir, 'bad-team'); + await fs.mkdir(teamPath, { recursive: true }); + await fs.writeFile(path.join(teamPath, 'TEAM.md'), 'invalid content'); + + const result = await loadTeamsFromDirectory(tempDir); + expect(result.teams).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain( + 'Missing mandatory YAML frontmatter', + ); + }); + + it('should capture validation errors for TEAM.md', async () => { + await createTeamStructure( + 'invalid-team', + `--- +name: invalid-team +# missing display_name and description +--- +Instructions`, + ); + + const result = await loadTeamsFromDirectory(tempDir); + expect(result.teams).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('Validation failed'); + }); + + it('should load team even if agents subfolder is missing', async () => { + await createTeamStructure( + 'no-agents-team', + `--- +name: no-agents-team +display_name: No Agents +description: No agents here +--- +Instructions`, + ); + + const result = await loadTeamsFromDirectory(tempDir); + expect(result.teams).toHaveLength(1); + expect(result.teams[0].agents).toHaveLength(0); + }); + + it('should capture errors from agents subfolder', async () => { + await createTeamStructure( + 'bad-agents-team', + `--- +name: bad-agents-team +display_name: Bad Agents +description: Team with bad agents +--- +Instructions`, + { + 'bad-agent': 'invalid agent content', + }, + ); + + const result = await loadTeamsFromDirectory(tempDir); + expect(result.teams).toHaveLength(1); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].filePath).toContain('agents/bad-agent.md'); + }); + + it('should return empty result if directory does not exist', async () => { + const nonExistentDir = path.join(tempDir, 'void'); + const result = await loadTeamsFromDirectory(nonExistentDir); + expect(result.teams).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); +}); diff --git a/packages/core/src/agents/teamLoader.ts b/packages/core/src/agents/teamLoader.ts new file mode 100644 index 0000000000..dfedb720e4 --- /dev/null +++ b/packages/core/src/agents/teamLoader.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { load } from 'js-yaml'; +import * as fs from 'node:fs/promises'; +import { type Dirent } from 'node:fs'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import { z } from 'zod'; +import { type TeamDefinition } from './types.js'; +import { AgentLoadError, loadAgentsFromDirectory } from './agentLoader.js'; +import { FRONTMATTER_REGEX } from '../skills/skillLoader.js'; +import { getErrorMessage } from '../utils/errors.js'; + +/** + * Result of loading teams from a directory. + */ +export interface TeamLoadResult { + teams: TeamDefinition[]; + errors: AgentLoadError[]; +} + +const nameSchema = z + .string() + .regex(/^[a-z0-9-_]+$/, 'Name must be a valid slug'); + +const teamSchema = z + .object({ + name: nameSchema, + display_name: z.string().min(1), + description: z.string().min(1), + }) + .strict(); + +/** + * Loads all teams from a specific directory. + * Each subdirectory is treated as a potential team. + * + * @param dir Directory path to scan (e.g., .gemini/teams/). + * @returns Object containing successfully loaded teams and any errors. + */ +export async function loadTeamsFromDirectory( + dir: string, +): Promise { + const result: TeamLoadResult = { + teams: [], + errors: [], + }; + + let dirEntries: Dirent[]; + try { + dirEntries = await fs.readdir(dir, { withFileTypes: true }); + } catch (error) { + // If directory doesn't exist, just return empty + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return result; + } + result.errors.push( + new AgentLoadError( + dir, + `Could not list directory: ${getErrorMessage(error)}`, + ), + ); + return result; + } + + const teamDirs = dirEntries.filter((entry) => entry.isDirectory()); + + for (const entry of teamDirs) { + const teamPath = path.join(dir, entry.name); + const teamMdPath = path.join(teamPath, 'TEAM.md'); + const agentsDirPath = path.join(teamPath, 'agents'); + + try { + // Check if TEAM.md exists + try { + await fs.access(teamMdPath); + } catch { + // Not a team directory (missing TEAM.md), just skip it + continue; + } + + const teamMdContent = await fs.readFile(teamMdPath, 'utf-8'); + const hash = crypto + .createHash('sha256') + .update(teamMdContent) + .digest('hex'); + + // Parse TEAM.md + const match = teamMdContent.match(FRONTMATTER_REGEX); + if (!match) { + throw new AgentLoadError( + teamMdPath, + 'Invalid team definition: Missing mandatory YAML frontmatter.', + ); + } + + const frontmatterStr = match[1]; + const instructions = (match[2] || '').trim(); + + let rawFrontmatter: unknown; + try { + rawFrontmatter = load(frontmatterStr); + } catch (error) { + throw new AgentLoadError( + teamMdPath, + `YAML frontmatter parsing failed: ${getErrorMessage(error)}`, + ); + } + + const parsedFrontmatter = teamSchema.safeParse(rawFrontmatter); + if (!parsedFrontmatter.success) { + throw new AgentLoadError( + teamMdPath, + `Validation failed:\n${parsedFrontmatter.error.issues + .map((i) => `${i.path.join('.')}: ${i.message}`) + .join('\n')}`, + ); + } + + const { name, display_name, description } = parsedFrontmatter.data; + + // Load agents from agents/ subfolder + const agentsResult = await loadAgentsFromDirectory(agentsDirPath); + result.errors.push(...agentsResult.errors); + + result.teams.push({ + name, + displayName: display_name, + description, + instructions, + agents: agentsResult.agents, + metadata: { + hash, + filePath: teamMdPath, + }, + }); + } catch (error) { + if (error instanceof AgentLoadError) { + result.errors.push(error); + } else { + result.errors.push( + new AgentLoadError( + teamMdPath, + `Unexpected error: ${getErrorMessage(error)}`, + ), + ); + } + } + } + + return result; +} diff --git a/packages/core/src/agents/teamRegistry.test.ts b/packages/core/src/agents/teamRegistry.test.ts new file mode 100644 index 0000000000..24b614d007 --- /dev/null +++ b/packages/core/src/agents/teamRegistry.test.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TeamRegistry } from './teamRegistry.js'; +import { type Config } from '../config/config.js'; +import { type AgentRegistry } from './registry.js'; +import { loadTeamsFromDirectory } from './teamLoader.js'; +import { type TeamDefinition, type AgentDefinition } from './types.js'; + +vi.mock('./teamLoader.js', () => ({ + loadTeamsFromDirectory: vi.fn(), +})); + +describe('TeamRegistry', () => { + let mockConfig: Config; + let mockAgentRegistry: AgentRegistry; + let registry: TeamRegistry; + + beforeEach(() => { + mockConfig = { + isAgentsEnabled: vi.fn().mockReturnValue(true), + getFolderTrust: vi.fn().mockReturnValue(false), + isTrustedFolder: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/mock/project'), + getDebugMode: vi.fn().mockReturnValue(false), + storage: { + getProjectTeamsDir: vi + .fn() + .mockReturnValue('/mock/project/.gemini/teams'), + }, + } as unknown as Config; + + mockAgentRegistry = { + registerAgent: vi.fn().mockResolvedValue(undefined), + } as unknown as AgentRegistry; + + registry = new TeamRegistry(mockConfig, mockAgentRegistry); + vi.mocked(loadTeamsFromDirectory).mockReset(); + }); + + it('should load teams and register agents on initialize', async () => { + const mockAgent: AgentDefinition = { + kind: 'local', + name: 'team-agent', + description: 'Agent in a team', + inputConfig: { inputSchema: { type: 'object' } }, + } as unknown as AgentDefinition; + + const mockTeam: TeamDefinition = { + name: 'test-team', + displayName: 'Test Team', + description: 'A team for testing', + instructions: 'Do tests.', + agents: [mockAgent], + }; + + vi.mocked(loadTeamsFromDirectory).mockResolvedValue({ + teams: [mockTeam], + errors: [], + }); + + await registry.initialize(); + + expect(registry.getAllTeams()).toHaveLength(1); + expect(registry.getTeam('test-team')).toEqual(mockTeam); + expect(mockAgentRegistry.registerAgent).toHaveBeenCalledWith(mockAgent); + }); + + it('should not load teams if agents are disabled', async () => { + mockConfig.isAgentsEnabled.mockReturnValue(false); + + await registry.initialize(); + + expect(loadTeamsFromDirectory).not.toHaveBeenCalled(); + expect(registry.getAllTeams()).toHaveLength(0); + }); + + it('should skip project teams in untrusted folder', async () => { + mockConfig.getFolderTrust.mockReturnValue(true); + mockConfig.isTrustedFolder.mockReturnValue(false); + + await registry.initialize(); + + expect(loadTeamsFromDirectory).not.toHaveBeenCalled(); + }); + + it('should manage active team', async () => { + const mockTeam: TeamDefinition = { + name: 'active-team', + displayName: 'Active Team', + description: 'The active one', + instructions: 'Lead.', + agents: [], + }; + + vi.mocked(loadTeamsFromDirectory).mockResolvedValue({ + teams: [mockTeam], + errors: [], + }); + + await registry.initialize(); + + expect(registry.getActiveTeam()).toBeUndefined(); + + registry.setActiveTeam('active-team'); + expect(registry.getActiveTeam()).toEqual(mockTeam); + + expect(() => registry.setActiveTeam('non-existent')).toThrow( + 'Team not found', + ); + }); + + it('should reload teams', async () => { + vi.mocked(loadTeamsFromDirectory).mockResolvedValue({ + teams: [], + errors: [], + }); + + await registry.initialize(); + expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(1); + + await registry.reload(); + expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/src/agents/teamRegistry.ts b/packages/core/src/agents/teamRegistry.ts new file mode 100644 index 0000000000..4d885ef412 --- /dev/null +++ b/packages/core/src/agents/teamRegistry.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type TeamDefinition } from './types.js'; +import { type Config } from '../config/config.js'; +import { type AgentRegistry } from './registry.js'; +import { loadTeamsFromDirectory } from './teamLoader.js'; +import { coreEvents } from '../utils/events.js'; +import { debugLogger } from '../utils/debugLogger.js'; + +/** + * Manages the discovery, loading, and active state of Agent Teams. + */ +export class TeamRegistry { + private readonly teams = new Map(); + private activeTeamName?: string; + + constructor( + private readonly config: Config, + private readonly agentRegistry: AgentRegistry, + ) {} + + /** + * Discovers and loads teams from the project's .gemini/teams/ directory. + */ + async initialize(): Promise { + await this.loadTeams(); + } + + /** + * Clears the current registry and re-scans for teams. + */ + async reload(): Promise { + await this.loadTeams(); + } + + private async loadTeams(): Promise { + this.teams.clear(); + + if (!this.config.isAgentsEnabled()) { + return; + } + + const folderTrustEnabled = this.config.getFolderTrust(); + const isTrustedFolder = this.config.isTrustedFolder(); + + if (folderTrustEnabled && !isTrustedFolder) { + debugLogger.log( + '[TeamRegistry] Skipping project teams due to untrusted folder.', + ); + coreEvents.emitFeedback( + 'info', + 'Skipping project teams due to untrusted folder. To enable, ensure that the project root is trusted.', + ); + return; + } + + const projectTeamsDir = this.config.storage.getProjectTeamsDir(); + const loadResult = await loadTeamsFromDirectory(projectTeamsDir); + + for (const error of loadResult.errors) { + debugLogger.warn(`[TeamRegistry] Error loading team: ${error.message}`); + coreEvents.emitFeedback('error', `Team loading error: ${error.message}`); + } + + for (const team of loadResult.teams) { + this.teams.set(team.name, team); + + // Register team agents in the global AgentRegistry so they are available as SubagentTools + for (const agent of team.agents) { + try { + await this.agentRegistry.registerAgent(agent); + } catch (e) { + debugLogger.warn( + `[TeamRegistry] Error registering agent "${agent.name}" from team "${team.name}":`, + e, + ); + coreEvents.emitFeedback( + 'error', + `Error registering agent "${agent.name}" from team "${team.name}": ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + } + + if (this.config.getDebugMode()) { + debugLogger.log(`[TeamRegistry] Loaded with ${this.teams.size} teams.`); + } + } + + /** + * Returns all loaded teams. + */ + getAllTeams(): TeamDefinition[] { + return Array.from(this.teams.values()); + } + + /** + * Sets the current active team. + * @param name The slug (name) of the team to activate. + * @throws Error if the team is not found. + */ + setActiveTeam(name: string): void { + if (this.teams.has(name)) { + this.activeTeamName = name; + } else { + throw new Error(`Team not found: ${name}`); + } + } + + /** + * Returns the currently active team definition, if any. + */ + getActiveTeam(): TeamDefinition | undefined { + return this.activeTeamName + ? this.teams.get(this.activeTeamName) + : undefined; + } + + /** + * Returns a team definition by name. + */ + getTeam(name: string): TeamDefinition | undefined { + return this.teams.get(name); + } +} diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index a7d921453b..77ba551d3d 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -340,3 +340,26 @@ export interface RunConfig { */ maxTurns?: number; } + +/** + * Represents a team of agents orchestrated by a set of instructions. + */ +export interface TeamDefinition { + /** The directory name (slug) of the team. */ + name: string; + /** The human-readable name of the team. */ + displayName: string; + /** A short description of the team's purpose. */ + description: string; + /** Orchestration instructions for the top-level agent. */ + instructions: string; + /** The list of agents that comprise the team. */ + agents: AgentDefinition[]; + /** Optional metadata for the team definition. */ + metadata?: { + /** SHA-256 hash of the TEAM.md content. */ + hash?: string; + /** Absolute path to the TEAM.md file. */ + filePath?: string; + }; +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 34a19f01d5..9eb5adff4b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -150,6 +150,7 @@ import { type Experiments, } from '../code_assist/experiments/experiments.js'; import { AgentRegistry } from '../agents/registry.js'; +import { TeamRegistry } from '../agents/teamRegistry.js'; import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js'; import { setGlobalProxy } from '../utils/fetch.js'; import { SubagentTool } from '../agents/subagent-tool.js'; @@ -752,6 +753,7 @@ export class Config implements McpContext, AgentLoopContext { private _promptRegistry!: PromptRegistry; private _resourceRegistry!: ResourceRegistry; private agentRegistry!: AgentRegistry; + private teamRegistry!: TeamRegistry; private readonly acknowledgedAgentsService: AcknowledgedAgentsService; private skillManager!: SkillManager; private _sessionId: string; @@ -1431,6 +1433,9 @@ export class Config implements McpContext, AgentLoopContext { this.agentRegistry = new AgentRegistry(this); await this.agentRegistry.initialize(); + this.teamRegistry = new TeamRegistry(this, this.agentRegistry); + await this.teamRegistry.initialize(); + coreEvents.on(CoreEvent.AgentsRefreshed, this.onAgentsRefreshed); this._toolRegistry = await this.createToolRegistry(); @@ -2026,6 +2031,10 @@ export class Config implements McpContext, AgentLoopContext { return this.agentRegistry; } + getTeamRegistry(): TeamRegistry { + return this.teamRegistry; + } + getAcknowledgedAgentsService(): AcknowledgedAgentsService { return this.acknowledgedAgentsService; } diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index cfbe6cf945..76711261f1 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -291,6 +291,10 @@ export class Storage { return path.join(this.getGeminiDir(), 'agents'); } + getProjectTeamsDir(): string { + return path.join(this.getGeminiDir(), 'teams'); + } + getProjectTempCheckpointsDir(): string { return path.join(this.getProjectTempDir(), 'checkpoints'); }