From 79cc1eb4f45c0eea36ee73b4a4c66fd73bd07d68 Mon Sep 17 00:00:00 2001 From: Taylor Mullen Date: Wed, 1 Apr 2026 17:55:00 -0700 Subject: [PATCH] feat(core,cli): implement external agent polyfills and finalize teams - Implement External Agent kind with personality overlays (Claude Code, Codex) - Enhance AgentLoader and Registry for external agent discovery and registration - Fix critical startup race condition in Config registry initialization - Add TeamSelectionDialog and ActiveTeamIndicator to CLI UI - Include comprehensive integration and unit tests for agent teams - Refactor SubagentToolWrapper for type-safe external agent invocation --- .../components/TeamSelectionDialog.test.tsx | 3 +- packages/cli/src/ui/utils/TableRenderer.tsx | 7 +- packages/core/src/agents/agentLoader.ts | 254 ++++++++++++------ .../agents/external-agent.integration.test.ts | 106 ++++++++ .../src/agents/external-invocation.test.ts | 113 ++++++++ .../core/src/agents/external-invocation.ts | 113 ++++++++ packages/core/src/agents/registry.ts | 38 +++ .../core/src/agents/subagent-tool-wrapper.ts | 76 ++++-- packages/core/src/agents/teamRegistry.test.ts | 5 +- packages/core/src/agents/types.ts | 34 ++- .../src/prompts/promptProvider-teams.test.ts | 2 +- plans/TASK-06.md | 48 ++++ plans/TASK-07.md | 49 ++++ plans/TASK-08.md | 47 ++++ 14 files changed, 776 insertions(+), 119 deletions(-) create mode 100644 packages/core/src/agents/external-agent.integration.test.ts create mode 100644 packages/core/src/agents/external-invocation.test.ts create mode 100644 packages/core/src/agents/external-invocation.ts create mode 100644 plans/TASK-06.md create mode 100644 plans/TASK-07.md create mode 100644 plans/TASK-08.md diff --git a/packages/cli/src/ui/components/TeamSelectionDialog.test.tsx b/packages/cli/src/ui/components/TeamSelectionDialog.test.tsx index f392a594c2..f338d731f4 100644 --- a/packages/cli/src/ui/components/TeamSelectionDialog.test.tsx +++ b/packages/cli/src/ui/components/TeamSelectionDialog.test.tsx @@ -35,7 +35,8 @@ describe('', () => { mockOnSelect.mockReset(); }); - const renderComponent = async () => renderWithProviders( + const renderComponent = async () => + renderWithProviders( , ); diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index ca62012059..a968f8c0e9 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -213,11 +213,7 @@ export const TableRenderer: React.FC = ({ const wrappedRows = styledRows.map((row) => wrapAndProcessRow(row)); // Use the TIGHTEST widths that fit the wrapped content + padding - const adjustedWidths = actualColumnWidths.map( - (w) => - - w + COLUMN_PADDING, - ); + const adjustedWidths = actualColumnWidths.map((w) => w + COLUMN_PADDING); return { wrappedHeaders, wrappedRows, adjustedWidths }; }, [styledHeaders, styledRows, terminalWidth]); @@ -268,7 +264,6 @@ export const TableRenderer: React.FC = ({ isHeader = false, ): React.ReactNode => { const renderedCells = cells.map((cell, index) => { - const width = adjustedWidths[index] || 0; return renderCell(cell, width, isHeader); }); diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index d34d0e974e..ba19f82b22 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -211,9 +211,35 @@ const remoteAgentSchema = z.union([ type FrontmatterRemoteAgentDefinition = z.infer; +const externalAgentSchema = z + .object({ + kind: z.literal('external'), + name: nameSchema, + provider: z.string().min(1), + description: z.string().min(1), + display_name: z.string().optional(), + provider_config: z.record(z.any()).optional(), + instructions: z.string().optional(), + tools: z + + .array( + z + .string() + .refine((val) => isValidToolName(val, { allowWildcards: true }), { + message: 'Invalid tool name', + }), + ) + .optional(), + mcp_servers: z.record(mcpServerSchema).optional(), + }) + .strict(); + +type FrontmatterExternalAgentDefinition = z.infer; + type FrontmatterAgentDefinition = | FrontmatterLocalAgentDefinition - | FrontmatterRemoteAgentDefinition; + | FrontmatterRemoteAgentDefinition + | FrontmatterExternalAgentDefinition; const agentUnionOptions = [ { label: 'Local Agent' }, @@ -222,11 +248,10 @@ const agentUnionOptions = [ ]; const remoteAgentsListSchema = z.array(remoteAgentSchema); - const markdownFrontmatterSchema = z.union([ localAgentSchema, - remoteAgentUrlSchema, - remoteAgentJsonSchema, + remoteAgentSchema, + externalAgentSchema, ]); function guessIntendedKind(rawInput: unknown): 'local' | 'remote' | undefined { @@ -368,6 +393,17 @@ export async function parseAgentMarkdown( ]; } + if (frontmatter.kind === 'external') { + const instr = body.trim(); + return [ + { + ...frontmatter, + kind: 'external', + instructions: instr, + }, + ]; + } + // Construct the local agent definition return [ { @@ -474,91 +510,147 @@ export function markdownToAgentDefinition( }, }; - if (markdown.kind === 'remote') { - const base: RemoteAgentDefinition = { - kind: 'remote', - name: markdown.name, - description: markdown.description || '', - displayName: markdown.display_name, - auth: markdown.auth - ? convertFrontmatterAuthToConfig(markdown.auth) - : undefined, - inputConfig, - metadata, - }; + switch (markdown.kind) { + case 'remote': { + const base: RemoteAgentDefinition = { + kind: 'remote', + name: markdown.name, + description: markdown.description || '', + displayName: markdown.display_name, + auth: markdown.auth + ? convertFrontmatterAuthToConfig(markdown.auth) + : undefined, + inputConfig, + metadata, + }; - if ( - 'agent_card_json' in markdown && - markdown.agent_card_json !== undefined - ) { - base.agentCardJson = markdown.agent_card_json; - return base; - } - if ('agent_card_url' in markdown && markdown.agent_card_url !== undefined) { - base.agentCardUrl = markdown.agent_card_url; - return base; + if ( + 'agent_card_json' in markdown && + markdown.agent_card_json !== undefined + ) { + base.agentCardJson = markdown.agent_card_json; + return base; + } + if ( + 'agent_card_url' in markdown && + markdown.agent_card_url !== undefined + ) { + base.agentCardUrl = markdown.agent_card_url; + return base; + } + + throw new AgentLoadError( + metadata?.filePath || 'unknown', + 'Unexpected state: neither agent_card_json nor agent_card_url present on remote agent', + ); } - throw new AgentLoadError( - metadata?.filePath || 'unknown', - 'Unexpected state: neither agent_card_json nor agent_card_url present on remote agent', - ); - } + case 'external': { + const mcpServers: Record = {}; + if (markdown.mcp_servers) { + for (const [name, config] of Object.entries(markdown.mcp_servers)) { + mcpServers[name] = new MCPServerConfig( + config.command, + config.args, + config.env, + config.cwd, + config.url, + config.http_url, + config.headers, + config.tcp, + config.type, + config.timeout, + config.trust, + config.description, + config.include_tools, + config.exclude_tools, + ); + } + } - // If a model is specified, use it. Otherwise, inherit - const modelName = markdown.model || 'inherit'; + return { + kind: 'external', + name: markdown.name, + provider: markdown.provider, + description: markdown.description, + displayName: markdown.display_name, + providerConfig: markdown.provider_config, + instructions: markdown.instructions, + toolConfig: markdown.tools + ? { + tools: markdown.tools, + } + : undefined, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + inputConfig, + metadata, + }; + } - const mcpServers: Record = {}; - if (markdown.mcp_servers) { - for (const [name, config] of Object.entries(markdown.mcp_servers)) { - mcpServers[name] = new MCPServerConfig( - config.command, - config.args, - config.env, - config.cwd, - config.url, - config.http_url, - config.headers, - config.tcp, - config.type, - config.timeout, - config.trust, - config.description, - config.include_tools, - config.exclude_tools, + case 'local': { + // If a model is specified, use it. Otherwise, inherit + const modelName = markdown.model || 'inherit'; + + const mcpServers: Record = {}; + if (markdown.mcp_servers) { + for (const [name, config] of Object.entries(markdown.mcp_servers)) { + mcpServers[name] = new MCPServerConfig( + config.command, + config.args, + config.env, + config.cwd, + config.url, + config.http_url, + config.headers, + config.tcp, + config.type, + config.timeout, + config.trust, + config.description, + config.include_tools, + config.exclude_tools, + ); + } + } + + return { + kind: 'local', + name: markdown.name, + description: markdown.description, + displayName: markdown.display_name, + promptConfig: { + systemPrompt: markdown.system_prompt, + query: '${query}', + }, + modelConfig: { + model: modelName, + generateContentConfig: { + temperature: markdown.temperature ?? 1, + topP: 0.95, + }, + }, + runConfig: { + maxTurns: markdown.max_turns ?? DEFAULT_MAX_TURNS, + maxTimeMinutes: markdown.timeout_mins ?? DEFAULT_MAX_TIME_MINUTES, + }, + toolConfig: markdown.tools + ? { + tools: markdown.tools, + } + : undefined, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + inputConfig, + metadata, + }; + } + + default: { + const exhaustive: never = markdown; + throw new Error( + `Unsupported agent kind: ${String((exhaustive as { kind: string }).kind)}`, ); } } - - return { - kind: 'local', - name: markdown.name, - description: markdown.description, - displayName: markdown.display_name, - promptConfig: { - systemPrompt: markdown.system_prompt, - query: '${query}', - }, - modelConfig: { - model: modelName, - generateContentConfig: { - temperature: markdown.temperature ?? 1, - topP: 0.95, - }, - }, - runConfig: { - maxTurns: markdown.max_turns ?? DEFAULT_MAX_TURNS, - maxTimeMinutes: markdown.timeout_mins ?? DEFAULT_MAX_TIME_MINUTES, - }, - toolConfig: markdown.tools - ? { - tools: markdown.tools, - } - : undefined, - mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - inputConfig, - metadata, - }; } /** diff --git a/packages/core/src/agents/external-agent.integration.test.ts b/packages/core/src/agents/external-agent.integration.test.ts new file mode 100644 index 0000000000..23b52c1900 --- /dev/null +++ b/packages/core/src/agents/external-agent.integration.test.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import * as fs from 'node:fs/promises'; +import type { Config } from '../config/config.js'; +import { TeamRegistry } from './teamRegistry.js'; +import { AgentRegistry } from './registry.js'; +import { ExternalAgentInvocation } from './external-invocation.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import { makeFakeConfig } from '../test-utils/config.js'; + +describe('External Agent E2E-ish Verification', () => { + let tempGeminiDir: string; + let config: Config; + let agentRegistry: AgentRegistry; + let teamRegistry: TeamRegistry; + + beforeEach(async () => { + tempGeminiDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-')); + const projectGeminiDir = path.join(tempGeminiDir, '.gemini'); + const teamsDir = path.join(projectGeminiDir, 'teams', 'test-team'); + const agentsDir = path.join(teamsDir, 'agents'); + await fs.mkdir(agentsDir, { recursive: true }); + + // Create a real external agent file + const claudeMd = `--- +kind: external +name: claude-verifier +provider: claude-code +description: I am a Claude polyfill. +--- +# Extra Instructions +Be very concise. +`; + await fs.writeFile(path.join(agentsDir, 'claude.md'), claudeMd); + + // Create a TEAM.md + const teamMd = `--- +name: test-team +display_name: Test Team +description: A test team for external agents. +--- +Orchestration instructions. +`; + await fs.writeFile(path.join(teamsDir, 'TEAM.md'), teamMd); + + // Initialize Config using makeFakeConfig + config = makeFakeConfig({ + targetDir: tempGeminiDir, + cwd: tempGeminiDir, + folderTrust: false, + enableAgents: true, + }); + + agentRegistry = new AgentRegistry(config); + teamRegistry = new TeamRegistry(config, agentRegistry); + await agentRegistry.initialize(); + await teamRegistry.initialize(); + }); + + afterEach(async () => { + await fs.rm(tempGeminiDir, { recursive: true, force: true }); + }); + + it('should load external agent and apply polyfill on invocation', async () => { + const teams = teamRegistry.getAllTeams(); + const agents = agentRegistry.getAllDefinitions(); + const agentNames = agents.map((a) => a.name); + + expect(teams.length).toBeGreaterThan(0); + expect(agentNames).toContain('claude-verifier'); + + const claudeAgent = agents.find((a) => a.name === 'claude-verifier'); + + expect(claudeAgent).toBeDefined(); + expect(claudeAgent?.kind).toBe('external'); + if (claudeAgent?.kind !== 'external') return; + + expect(claudeAgent.provider).toBe('claude-code'); + + // Verify Invocation applies polyfill + const messageBus = createMockMessageBus(); + const invocation = new ExternalAgentInvocation( + claudeAgent, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config as any, + {}, + messageBus, + ); + + // Check protected polyfilled definition via casting + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const polyfilled = (invocation as any).definition; + expect(polyfilled.kind).toBe('local'); + expect(polyfilled.promptConfig.systemPrompt).toContain( + 'Claude Code Personality Overlay', + ); + expect(polyfilled.promptConfig.systemPrompt).toContain('Be very concise.'); + }); +}); diff --git a/packages/core/src/agents/external-invocation.test.ts b/packages/core/src/agents/external-invocation.test.ts new file mode 100644 index 0000000000..011860f291 --- /dev/null +++ b/packages/core/src/agents/external-invocation.test.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { type ExternalAgentDefinition } from './types.js'; +import { + ExternalAgentInvocation, + polyfillExternalAgent, +} from './external-invocation.js'; +import { LocalAgentExecutor } from './local-executor.js'; +import { makeFakeConfig } from '../test-utils/config.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import { DEFAULT_GEMINI_MODEL } from '../config/models.js'; + +vi.mock('./local-executor.js'); + +const MockLocalAgentExecutor = vi.mocked(LocalAgentExecutor); + +describe('ExternalAgentInvocation', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockConfig: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockMessageBus: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = makeFakeConfig(); + mockMessageBus = createMockMessageBus(); + + MockLocalAgentExecutor.create.mockResolvedValue({ + run: vi.fn().mockResolvedValue({ + result: 'Success', + terminate_reason: 'GOAL', + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + definition: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const externalDef: ExternalAgentDefinition = { + kind: 'external', + name: 'claude-agent', + provider: 'claude-code', + description: 'Expert coder.', + inputConfig: { inputSchema: { type: 'object', properties: {} } }, + }; + + it('should polyfill external agent into a local agent definition', () => { + const polyfilled = polyfillExternalAgent(externalDef); + + expect(polyfilled.kind).toBe('local'); + expect(polyfilled.modelConfig.model).toBe(DEFAULT_GEMINI_MODEL); + expect(polyfilled.promptConfig.systemPrompt).toContain( + 'Claude Code Personality Overlay', + ); + expect(polyfilled.promptConfig.systemPrompt).toContain('Expert coder.'); + }); + + it('should support Codex provider', () => { + const codexDef: ExternalAgentDefinition = { + ...externalDef, + provider: 'codex', + }; + const polyfilled = polyfillExternalAgent(codexDef); + + expect(polyfilled.promptConfig.systemPrompt).toContain( + 'Codex Personality Overlay', + ); + }); + + it('should include styleInstructions from providerConfig', () => { + const customDef: ExternalAgentDefinition = { + ...externalDef, + providerConfig: { + styleInstructions: 'Be very formal.', + }, + }; + const polyfilled = polyfillExternalAgent(customDef); + + expect(polyfilled.promptConfig.systemPrompt).toContain( + 'Additional Style Instructions: Be very formal.', + ); + }); + + it('should use ExternalAgentInvocation to execute polyfilled agent', async () => { + const invocation = new ExternalAgentInvocation( + externalDef, + mockConfig, + {}, + mockMessageBus, + ); + + const signal = new AbortController().signal; + await invocation.execute(signal); + + expect(MockLocalAgentExecutor.create).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'local', + name: 'claude-agent', + }), + mockConfig, + expect.any(Function), + ); + }); +}); diff --git a/packages/core/src/agents/external-invocation.ts b/packages/core/src/agents/external-invocation.ts new file mode 100644 index 0000000000..2291b204b8 --- /dev/null +++ b/packages/core/src/agents/external-invocation.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { LocalSubagentInvocation } from './local-invocation.js'; +import { + type ExternalAgentDefinition, + type LocalAgentDefinition, + type AgentInputs, +} from './types.js'; +import { type AgentLoopContext } from '../config/agent-loop-context.js'; +import { type MessageBus } from '../confirmation-bus/message-bus.js'; +import { DEFAULT_GEMINI_MODEL } from '../config/models.js'; + +/** + * An invocation instance for an "external" agent. + * + * This class applies a personality overlay to a model call to mimic + * the behavior of an external agent provider using a standard Gemini model. + */ +export class ExternalAgentInvocation extends LocalSubagentInvocation { + constructor( + definition: ExternalAgentDefinition, + context: AgentLoopContext, + params: AgentInputs, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ) { + const polyfilledDefinition = polyfillExternalAgent(definition); + super( + polyfilledDefinition, + context, + params, + messageBus, + _toolName, + _toolDisplayName, + ); + } +} + +/** + * Transforms an ExternalAgentDefinition into a LocalAgentDefinition by applying + * provider-specific personality overlays and model configurations. + */ +export function polyfillExternalAgent( + def: ExternalAgentDefinition, +): LocalAgentDefinition { + const overlay = getPersonalityOverlay(def.provider, def.providerConfig); + + // External agents use the instructions field (from markdown body) as their primary instructions. + // We fall back to description if instructions are missing. + const baseInstructions = def.instructions || def.description; + + return { + ...def, + kind: 'local', + modelConfig: { + model: DEFAULT_GEMINI_MODEL, + }, + runConfig: { + maxTurns: 30, + maxTimeMinutes: 10, + }, + promptConfig: { + // Prepend the personality overlay to the agent's instructions. + systemPrompt: `${overlay}\n\n${baseInstructions}`, + query: '${query}', + }, + }; +} + +/** + * Returns a personality overlay prompt based on the external provider. + */ +function getPersonalityOverlay( + provider: string, + config?: Record, +): string { + const styleInstructions = config?.['styleInstructions'] + ? `\nAdditional Style Instructions: ${String(config['styleInstructions'])}` + : ''; + + switch (provider.toLowerCase()) { + case 'claude-code': + return ` +# Claude Code Personality Overlay +You are acting as the "Claude Code" agent, a high-performance, concise, and direct coding assistant. +- Focus on efficiency and direct action. +- Use tools precisely and explain your technical reasoning briefly and only when necessary. +- Maintain a senior software engineer persona: professional, expert, and focused on clean, maintainable code. +- Avoid unnecessary conversational filler or lengthy preambles. +${styleInstructions}`.trim(); + + case 'codex': + return ` +# Codex Personality Overlay +You are acting as the "Codex" agent, a specialized code generation model. +- Focus on generating high-quality, idiomatic, and correct code for the requested task. +- Prioritize structural integrity and idiomatic patterns for the target language. +- Provide clear, well-documented code snippets. +${styleInstructions}`.trim(); + + default: + return ` +# External Agent Personality Overlay +You are acting as an external specialized agent ('${provider}'). +Please adopt the persona, style, and expertise expected of this provider. +${styleInstructions}`.trim(); + } +} diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 1cd8629db5..396ca8410a 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -324,9 +324,47 @@ export class AgentRegistry { this.registerLocalAgent(definition); } else if (definition.kind === 'remote') { await this.registerRemoteAgent(definition); + } else if (definition.kind === 'external') { + this.registerExternalAgent(definition); } } + /** + * Registers an external agent definition synchronously. + */ + protected registerExternalAgent( + definition: AgentDefinition, + ): void { + if (definition.kind !== 'external') { + return; + } + + // Basic validation + if (!definition.name || !definition.description || !definition.provider) { + debugLogger.warn( + `[AgentRegistry] Skipping invalid external agent definition. Missing name, description, or provider.`, + ); + return; + } + + this.allDefinitions.set(definition.name, definition); + + const settingsOverrides = + this.config.getAgentsSettings().overrides?.[definition.name]; + + if (!this.isAgentEnabled(definition, settingsOverrides)) { + if (this.config.getDebugMode()) { + debugLogger.log( + `[AgentRegistry] Skipping disabled external agent '${definition.name}'`, + ); + } + return; + } + + this.agents.set(definition.name, definition); + this.addAgentPolicy(definition); + } + /** * Registers a local agent definition synchronously. */ diff --git a/packages/core/src/agents/subagent-tool-wrapper.ts b/packages/core/src/agents/subagent-tool-wrapper.ts index 30a30d76d0..7fdae08be6 100644 --- a/packages/core/src/agents/subagent-tool-wrapper.ts +++ b/packages/core/src/agents/subagent-tool-wrapper.ts @@ -15,6 +15,7 @@ import { type AgentLoopContext } from '../config/agent-loop-context.js'; import type { AgentDefinition, AgentInputs } from './types.js'; import { LocalSubagentInvocation } from './local-invocation.js'; import { RemoteAgentInvocation } from './remote-invocation.js'; +import { ExternalAgentInvocation } from './external-invocation.js'; import { BrowserAgentInvocation } from './browser/browserAgentInvocation.js'; import { BROWSER_AGENT_NAME } from './browser/browserAgentDefinition.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; @@ -72,35 +73,54 @@ export class SubagentToolWrapper extends BaseDeclarativeTool< const definition = this.definition; const effectiveMessageBus = messageBus; - if (definition.kind === 'remote') { - return new RemoteAgentInvocation( - definition, - this.context, - params, - effectiveMessageBus, - _toolName, - _toolDisplayName, - ); - } + switch (definition.kind) { + case 'remote': + return new RemoteAgentInvocation( + definition, + this.context, + params, + effectiveMessageBus, + _toolName, + _toolDisplayName, + ); - // Special handling for browser agent - needs async MCP setup - if (definition.name === BROWSER_AGENT_NAME) { - return new BrowserAgentInvocation( - this.context, - params, - effectiveMessageBus, - _toolName, - _toolDisplayName, - ); - } + case 'external': + return new ExternalAgentInvocation( + definition, + this.context, + params, + effectiveMessageBus, + _toolName, + _toolDisplayName, + ); - return new LocalSubagentInvocation( - definition, - this.context, - params, - effectiveMessageBus, - _toolName, - _toolDisplayName, - ); + case 'local': + // Special handling for browser agent - needs async MCP setup + if (definition.name === BROWSER_AGENT_NAME) { + return new BrowserAgentInvocation( + this.context, + params, + effectiveMessageBus, + _toolName, + _toolDisplayName, + ); + } + + return new LocalSubagentInvocation( + definition, + this.context, + params, + effectiveMessageBus, + _toolName, + _toolDisplayName, + ); + + default: { + const exhaustive: never = definition; + throw new Error( + `Unsupported agent kind: ${String((exhaustive as { kind: string }).kind)}`, + ); + } + } } } diff --git a/packages/core/src/agents/teamRegistry.test.ts b/packages/core/src/agents/teamRegistry.test.ts index b7f0f9ec12..834fe2723f 100644 --- a/packages/core/src/agents/teamRegistry.test.ts +++ b/packages/core/src/agents/teamRegistry.test.ts @@ -80,7 +80,10 @@ describe('TeamRegistry', () => { expect(registry.getAllTeams()).toHaveLength(1); expect(registry.getTeam('test-team')).toEqual(mockTeam); - expect(mockAgentRegistry.registerAgent).toHaveBeenCalledWith(mockAgent); + expect(mockAgentRegistry.registerAgent).toHaveBeenCalledWith({ + ...mockAgent, + description: `MANDATORY for undefined tasks: Agent in a team (Team Agent: Test Team). You MUST delegate all undefined tasks to this agent.`, + }); expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(2); }); diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 77ba551d3d..b6e45068dc 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -263,9 +263,41 @@ export interface RemoteAgentDefinition< agentCardJson?: string; } +export interface ExternalAgentDefinition< + TOutput extends z.ZodTypeAny = z.ZodUnknown, +> extends BaseAgentDefinition { + kind: 'external'; + + /** + * The external agent provider (e.g., 'claude-code', 'codex'). + */ + provider: string; + + /** + * Optional provider-specific configuration. + */ + providerConfig?: Record; + + /** + * Optional instructions (e.g., from markdown body). + */ + instructions?: string; + + /** + * Optional configs. + */ + toolConfig?: ToolConfig; + + /** + * Optional inline MCP servers for this agent. + */ + mcpServers?: Record; +} + export type AgentDefinition = | LocalAgentDefinition - | RemoteAgentDefinition; + | RemoteAgentDefinition + | ExternalAgentDefinition; /** * Configures the initial prompt for the agent. diff --git a/packages/core/src/prompts/promptProvider-teams.test.ts b/packages/core/src/prompts/promptProvider-teams.test.ts index cfe15c0c03..2a16bbfe19 100644 --- a/packages/core/src/prompts/promptProvider-teams.test.ts +++ b/packages/core/src/prompts/promptProvider-teams.test.ts @@ -70,7 +70,7 @@ describe('PromptProvider with Agent Teams', () => { expect(prompt).toContain('# Active Agent Team: Test Team'); expect(prompt).toContain('These are the team instructions.'); expect(prompt).toContain( - "You should prioritize delegating tasks to this team's agents whenever appropriate.", + "**Orchestration Mandate:** You must prioritize delegating tasks to this team's specialized agents for their respective roles.", ); }); }); diff --git a/plans/TASK-06.md b/plans/TASK-06.md new file mode 100644 index 0000000000..833054111f --- /dev/null +++ b/plans/TASK-06.md @@ -0,0 +1,48 @@ +# TASK-06: External Agent Definitions and "Polyfill" Support + +## Objective + +Introduce a new `external` agent kind to support integrating and "polyfilling" +competitor agents like Claude Code and Codex into Gemini CLI teams. + +## Implementation Details + +### 1. Type Definitions (`packages/core/src/agents/types.ts`) + +- [ ] Add `ExternalAgentDefinition` to the `AgentDefinition` union. +- [ ] Include fields for `provider` (e.g., `'claude-code'`, `'codex'`) and + `providerOptions`. + ```typescript + export interface ExternalAgentDefinition extends BaseAgentDefinition { + kind: 'external'; + provider: string; + // Allow for provider-specific configuration + providerConfig?: Record; + } + ``` + +### 2. External Agent Invocation (`packages/core/src/agents/external-invocation.ts`) + +- [ ] Implement `ExternalAgentInvocation` class. +- [ ] **The Polyfill Logic:** + - Use the system's configured default model to "polyfill" the external agent. + - Apply a "personality overlay" to the system prompt based on the `provider`. + - Example (Claude Code): Prepend instructions to adopt the specific style, + tool usage patterns, and "persona" of a high-performance, concise coding + assistant. + - Example (Codex): Prepend instructions to act as a specialized code + generation model. + - Ensure the overlay is model-agnostic and relies on the `providerConfig` for + behavior tuning. + +### 3. Tool Wrapping (`packages/core/src/agents/subagent-tool-wrapper.ts`) + +- [ ] Update `createInvocation` to recognize `kind === 'external'` and return an + `ExternalAgentInvocation`. + +## Verification + +- Unit tests for `ExternalAgentInvocation` ensuring provider-specific prompts + are applied. +- Manual verification by creating an agent with `kind: external` and + `provider: claude-code`. diff --git a/plans/TASK-07.md b/plans/TASK-07.md new file mode 100644 index 0000000000..cd4b447ff7 --- /dev/null +++ b/plans/TASK-07.md @@ -0,0 +1,49 @@ +# TASK-07: External Agent Registry and Team Marketplace placeholders + +## Objective + +Enable teams to include external agents and provide the "marketplace" UI +placeholders that suggest where one might find or browse external agents. + +## Implementation Details + +### 1. Team Discovery (`packages/core/src/agents/teamLoader.ts`) + +- [ ] Ensure that `TEAM.md` can specify agents with `kind: external` and + `provider`. +- [ ] If a team specifies an external agent that isn't found locally, provide a + clear error message or auto-generate a polyfill definition. + +### 2. Team Marketplace UI (`packages/cli/src/ui/components/TeamSelectionDialog.tsx`) + +- [ ] Populate the "Browse Team Marketplace" and "Create New Team" placeholders + from TASK-04. +- [ ] For the marketplace, show a list of "Curated Teams" (hardcoded for the + MVP) that include external agents. + - Example: "The Polyglot Team" (Gemini + Claude Code + Codex). +- [ ] **Visual Language:** + - Add color-coded tags next to agents in the team list: + - **Anthropic Purple** for Claude Code (`provider: 'claude-code'`). + - **OpenAI Green** for Codex (`provider: 'codex'`). + - **Google Blue** for Gemini agents. + - Use a "Multi-Model" badge if a team contains agents from multiple providers. + +### 3. Team Builder/Creator (MVP) + +- [ ] In the "Create New Team" placeholder, provide a simple prompt to create a + `TEAM.md` in a new subdirectory. +- [ ] Allow users to specify `providers` for their agents in this builder. + +### 4. Status Indicator Enhancement (`packages/cli/src/ui/components/StatusRow.tsx`) + +- [ ] If a team is active, the `ActiveTeamIndicator` should show the "Provider" + icons or tags for the agents in the team. + - Example: `[ Team: Polyglot (G, C, X) ]` where G/C/X are color-coded letters + for Gemini/Claude/Codex. + +## Verification + +- Manual verification of the "Marketplace" and "Create Team" placeholders in the + selection dialog. +- Verify that a team with an external Claude Code agent displays correctly and + its instructions are correctly surfaced. diff --git a/plans/TASK-08.md b/plans/TASK-08.md new file mode 100644 index 0000000000..b098b86403 --- /dev/null +++ b/plans/TASK-08.md @@ -0,0 +1,47 @@ +# TASK-08: Interactive Team Creation Wizard + +## Objective + +Implement a guided, step-by-step CLI wizard to allow users to create new Agent +Teams without manually editing files and directories. + +## Implementation Details + +### 1. The Wizard Flow (`packages/cli/src/ui/components/TeamCreatorWizard.tsx`) + +- [ ] Create a multi-step Ink component: + - **Step 1: Identity**: Prompt for Team Name and Display Name (Text Input). + - **Step 2: Objective**: Prompt for the orchestration instructions (Multiline + Text Input). This guides the top-level agent on how to use the team. + - **Step 3: Roster**: A multi-select list of available agents: + - Include local agents from `.gemini/agents/`. + - Include "External" templates: `Claude Code (External)`, + `Codex (External)`. + - **Step 4: Confirmation**: Display a summary and ask for final confirmation. + +### 2. Scaffolding Service (`packages/core/src/agents/teamScaffolder.ts`) + +- [ ] Create a service to handle the filesystem operations: + - [ ] Create the directory `.gemini/teams//`. + - [ ] Generate the `TEAM.md` with the collected frontmatter and instructions. + - [ ] Create the `agents/` sub-folder. + - [ ] For each selected agent: + - If it's an existing local agent, copy/link its definition. + - If it's an "External" agent, generate a placeholder `.md` file with the + correct `kind: external` and `provider`. + +### 3. UI Integration (`packages/cli/src/ui/AppContainer.tsx`) + +- [ ] In the `TeamSelectionDialog` (from TASK-04/07), when "Create New Team" is + selected: + - [ ] Set `isTeamCreatorActive(true)`. +- [ ] Render the `TeamCreatorWizard` overlay. +- [ ] On completion, refresh the `TeamRegistry` and return to the selection list + with the new team highlighted. + +## Verification + +- Manual verification of the creation flow. +- Verify that the resulting directory structure and `TEAM.md` are valid and + loadable. +- Unit tests for `teamScaffolder.ts`.