From 1a3741d2aadbf1e708e12aceaa86ae6825f0b976 Mon Sep 17 00:00:00 2001 From: Taylor Mullen Date: Mon, 23 Mar 2026 22:50:12 -0700 Subject: [PATCH] feat: implement Open Plugins agents support - Add pluginRoot to AgentDefinition metadata - Implement ${PLUGIN_ROOT} expansion in markdownToAgentDefinition - Automatically discover and namespace agents in Open Plugins - Update ExtensionManager to pass plugin root during agent loading - Display sub-agents in extensions list output Fixes https://github.com/google-gemini/maintainers-gemini-cli/issues/1594 --- integration-tests/open-plugin-agents.test.ts | 74 ++++++++++++++++ packages/cli/src/config/extension-manager.ts | 7 ++ packages/cli/src/config/plugin.ts | 31 ++++++- packages/core/src/agents/agentLoader.test.ts | 26 ++++++ packages/core/src/agents/agentLoader.ts | 91 +++++++++++++++----- packages/core/src/agents/types.ts | 1 + 6 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 integration-tests/open-plugin-agents.test.ts diff --git a/integration-tests/open-plugin-agents.test.ts b/integration-tests/open-plugin-agents.test.ts new file mode 100644 index 0000000000..54c3c352c7 --- /dev/null +++ b/integration-tests/open-plugin-agents.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const pluginJson = (name: string) => `{ + "name": "${name}", + "version": "1.0.0", + "description": "A test open plugin" +}`; + +const agentMarkdown = (name: string, description: string) => `--- +name: ${name} +description: ${description} +mcp_servers: + test-server: + command: node + args: ["\${PLUGIN_ROOT}/server.js"] +--- +You are ${name}. My root is \${PLUGIN_ROOT}.`; + +describe('Open Plugin agents', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => await rig.cleanup()); + + it('discovers, namespaces, and expands PLUGIN_ROOT for Open Plugin agents', async () => { + rig.setup('open-plugin agents test'); + const pluginDir = join(rig.testDir!, 'test-plugin'); + mkdirSync(pluginDir, { recursive: true }); + mkdirSync(join(pluginDir, 'agents'), { recursive: true }); + + const pluginName = 'test-plugin'; + writeFileSync(join(pluginDir, 'plugin.json'), pluginJson(pluginName)); + writeFileSync( + join(pluginDir, 'agents', 'researcher.md'), + agentMarkdown('researcher', 'A researcher in ${PLUGIN_ROOT}'), + ); + + // Install the plugin + await rig.runCommand(['extensions', 'install', pluginDir], { + stdin: 'y\n', + }); + + // List extensions to verify the agent is registered and expanded + const listResult = await rig.runCommand(['extensions', 'list']); + + // Check namespacing + expect(listResult).toContain(`${pluginName}:researcher`); + + // Check expansion in description (via toOutputString) + const installedPathMatch = listResult.match(/Path: (.*)/); + const installedPath = installedPathMatch + ? installedPathMatch[1].trim() + : ''; + expect(listResult).toContain(`A researcher in ${installedPath}`); + + // Check expansion in prompt by "running" the agent or checking its internal state + // For integration test, we can try to use the agent in a non-interactive mode + // but that might be slow/complex. + // Given we verified description expansion, and core tests verified prompt expansion, + // this should be sufficient. + }); +}); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 9cd259d6b1..a4cdd6a838 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -997,6 +997,7 @@ Would you like to attempt to install via "git clone" instead?`, const agentLoadResult = await loadAgentsFromDirectory( path.join(effectiveExtensionPath, 'agents'), + effectiveExtensionPath, ); agentLoadResult.agents = agentLoadResult.agents.map((agent) => ({ ...recursivelyHydrateStrings(agent, hydrationContext), @@ -1184,6 +1185,12 @@ Would you like to attempt to install via "git clone" instead?`, output += `\n ${skill.name}: ${skill.description}`; }); } + if (extension.agents && extension.agents.length > 0) { + output += `\n Sub-agents:`; + extension.agents.forEach((agent) => { + output += `\n ${agent.name}: ${agent.description}`; + }); + } const resolvedSettings = extension.resolvedSettings; if (resolvedSettings && resolvedSettings.length > 0) { output += `\n Settings:`; diff --git a/packages/cli/src/config/plugin.ts b/packages/cli/src/config/plugin.ts index 201df79224..bb55987859 100644 --- a/packages/cli/src/config/plugin.ts +++ b/packages/cli/src/config/plugin.ts @@ -9,6 +9,7 @@ import * as path from 'node:path'; import { z } from 'zod'; import { loadSkillsFromDir, + loadAgentsFromDirectory, type ExtensionInstallMetadata, type GeminiCLIExtension, type MCPServerConfig, @@ -246,6 +247,12 @@ export async function createOpenPlugin( hydrationContext, ); + const agents = await resolvePluginAgents( + pluginDir, + config.name, + hydrationContext, + ); + return { name: config.name, version: config.version, @@ -264,7 +271,7 @@ export async function createOpenPlugin( settings: undefined, resolvedSettings: undefined, skills, - agents: undefined, + agents, themes: undefined, }; } @@ -290,3 +297,25 @@ async function resolvePluginSkills( extensionName: pluginName, })); } + +/** + * Discovers and namespaces agents for an Open Plugin. + */ +async function resolvePluginAgents( + pluginDir: string, + pluginName: string, + hydrationContext: Record, +): Promise { + const agentsDir = path.join(pluginDir, 'agents'); + const agentLoadResult = await loadAgentsFromDirectory(agentsDir, pluginDir); + + if (agentLoadResult.agents.length === 0) { + return undefined; + } + + return agentLoadResult.agents.map((agent) => ({ + ...recursivelyHydrateStrings(agent, hydrationContext), + name: `${pluginName}:${agent.name}`, + extensionName: pluginName, + })); +} diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts index ea7ef0b2c3..51a62b0b19 100644 --- a/packages/core/src/agents/agentLoader.test.ts +++ b/packages/core/src/agents/agentLoader.test.ts @@ -343,6 +343,32 @@ Body`); expect(result.modelConfig.model).toBe('auto'); }); + it('should expand ${PLUGIN_ROOT} in agent definition', () => { + const markdown = { + kind: 'local' as const, + name: 'expansion-agent', + description: 'An agent in ${PLUGIN_ROOT}', + system_prompt: 'You are at ${PLUGIN_ROOT}', + mcp_servers: { + 'test-server': { + command: 'node', + args: ['${PLUGIN_ROOT}/server.js'], + }, + }, + }; + + const pluginRoot = '/abs/path/to/plugin'; + const result = markdownToAgentDefinition(markdown, { + pluginRoot, + }) as LocalAgentDefinition; + + expect(result.description).toBe(`An agent in ${pluginRoot}`); + expect(result.promptConfig.systemPrompt).toBe(`You are at ${pluginRoot}`); + expect(result.mcpServers!['test-server'].args).toEqual([ + `${pluginRoot}/server.js`, + ]); + }); + it('should convert remote agent definition', () => { const markdown = { kind: 'remote' as const, diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index 2cb7b3c439..037bf6ea57 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -489,17 +489,55 @@ function convertFrontmatterAuthToConfig( } } +/** + * Recursively expands ${PLUGIN_ROOT} in strings, arrays, and objects. + */ +function recursivelyExpandPluginRoot(obj: T, pluginRoot: string): T { + if (typeof obj === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return obj.replace(/\${PLUGIN_ROOT}/g, pluginRoot) as unknown as T; + } + if (Array.isArray(obj)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return obj.map((item) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + recursivelyExpandPluginRoot(item, pluginRoot), + ) as unknown as T; + } + if (typeof obj === 'object' && obj !== null) { + const newObj: Record = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + newObj[key] = recursivelyExpandPluginRoot( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (obj as Record)[key], + pluginRoot, + ); + } + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return newObj as T; + } + return obj; +} + /** * Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure. * * @param markdown The parsed Markdown/Frontmatter definition. - * @param metadata Optional metadata including hash and file path. + * @param metadata Optional metadata including hash, file path, and plugin root. * @returns The internal AgentDefinition. */ export function markdownToAgentDefinition( markdown: FrontmatterAgentDefinition, - metadata?: { hash?: string; filePath?: string }, + metadata?: { hash?: string; filePath?: string; pluginRoot?: string }, ): AgentDefinition { + const pluginRoot = metadata?.pluginRoot; + const processedMarkdown = + pluginRoot !== undefined + ? recursivelyExpandPluginRoot(markdown, pluginRoot) + : markdown; + const inputConfig = { inputSchema: { type: 'object', @@ -514,15 +552,15 @@ export function markdownToAgentDefinition( }, }; - if (markdown.kind === 'remote') { + if (processedMarkdown.kind === 'remote') { return { kind: 'remote', - name: markdown.name, - description: markdown.description || '', - displayName: markdown.display_name, - agentCardUrl: markdown.agent_card_url, - auth: markdown.auth - ? convertFrontmatterAuthToConfig(markdown.auth) + name: processedMarkdown.name, + description: processedMarkdown.description || '', + displayName: processedMarkdown.display_name, + agentCardUrl: processedMarkdown.agent_card_url, + auth: processedMarkdown.auth + ? convertFrontmatterAuthToConfig(processedMarkdown.auth) : undefined, inputConfig, metadata, @@ -530,11 +568,13 @@ export function markdownToAgentDefinition( } // If a model is specified, use it. Otherwise, inherit - const modelName = markdown.model || 'inherit'; + const modelName = processedMarkdown.model || 'inherit'; const mcpServers: Record = {}; - if (markdown.kind === 'local' && markdown.mcp_servers) { - for (const [name, config] of Object.entries(markdown.mcp_servers)) { + if (processedMarkdown.kind === 'local' && processedMarkdown.mcp_servers) { + for (const [name, config] of Object.entries( + processedMarkdown.mcp_servers, + )) { mcpServers[name] = new MCPServerConfig( config.command, config.args, @@ -556,27 +596,28 @@ export function markdownToAgentDefinition( return { kind: 'local', - name: markdown.name, - description: markdown.description, - displayName: markdown.display_name, + name: processedMarkdown.name, + description: processedMarkdown.description, + displayName: processedMarkdown.display_name, promptConfig: { - systemPrompt: markdown.system_prompt, + systemPrompt: processedMarkdown.system_prompt, query: '${query}', }, modelConfig: { model: modelName, generateContentConfig: { - temperature: markdown.temperature ?? 1, + temperature: processedMarkdown.temperature ?? 1, topP: 0.95, }, }, runConfig: { - maxTurns: markdown.max_turns ?? DEFAULT_MAX_TURNS, - maxTimeMinutes: markdown.timeout_mins ?? DEFAULT_MAX_TIME_MINUTES, + maxTurns: processedMarkdown.max_turns ?? DEFAULT_MAX_TURNS, + maxTimeMinutes: + processedMarkdown.timeout_mins ?? DEFAULT_MAX_TIME_MINUTES, }, - toolConfig: markdown.tools + toolConfig: processedMarkdown.tools ? { - tools: markdown.tools, + tools: processedMarkdown.tools, } : undefined, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, @@ -591,10 +632,12 @@ export function markdownToAgentDefinition( * Supported extensions: .md * * @param dir Directory path to scan. + * @param pluginRoot Optional plugin root path for variable expansion. * @returns Object containing successfully loaded agents and any errors. */ export async function loadAgentsFromDirectory( dir: string, + pluginRoot?: string, ): Promise { const result: AgentLoadResult = { agents: [], @@ -634,7 +677,11 @@ export async function loadAgentsFromDirectory( const hash = crypto.createHash('sha256').update(content).digest('hex'); const agentDefs = await parseAgentMarkdown(filePath, content); for (const def of agentDefs) { - const agent = markdownToAgentDefinition(def, { hash, filePath }); + const agent = markdownToAgentDefinition(def, { + hash, + filePath, + pluginRoot, + }); result.agents.push(agent); } } catch (error) { diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 7f056c37ab..b4de1c3797 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -129,6 +129,7 @@ export interface BaseAgentDefinition< metadata?: { hash?: string; filePath?: string; + pluginRoot?: string; }; }