mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-29 22:44:45 -07:00
feat(core): add foundation for subagent tool isolation (#22708)
This commit is contained in:
@@ -22,6 +22,25 @@ describe('NewAgentsNotification', () => {
|
||||
{
|
||||
name: 'Agent B',
|
||||
description: 'Description B',
|
||||
kind: 'local' as const,
|
||||
inputConfig: { inputSchema: {} },
|
||||
promptConfig: {},
|
||||
modelConfig: {},
|
||||
runConfig: {},
|
||||
mcpServers: {
|
||||
github: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-github'],
|
||||
},
|
||||
postgres: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-postgres'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Agent C',
|
||||
description: 'Description C',
|
||||
kind: 'remote' as const,
|
||||
agentCardUrl: '',
|
||||
inputConfig: { inputSchema: {} },
|
||||
|
||||
@@ -80,16 +80,35 @@ export const NewAgentsNotification = ({
|
||||
borderStyle="single"
|
||||
padding={1}
|
||||
>
|
||||
{displayAgents.map((agent) => (
|
||||
<Box key={agent.name}>
|
||||
<Box flexShrink={0}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
- {agent.name}:{' '}
|
||||
</Text>
|
||||
{displayAgents.map((agent) => {
|
||||
const mcpServers =
|
||||
agent.kind === 'local' ? agent.mcpServers : undefined;
|
||||
const hasMcpServers =
|
||||
mcpServers && Object.keys(mcpServers).length > 0;
|
||||
return (
|
||||
<Box key={agent.name} flexDirection="column">
|
||||
<Box>
|
||||
<Box flexShrink={0}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
- {agent.name}:{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
{agent.description}
|
||||
</Text>
|
||||
</Box>
|
||||
{hasMcpServers && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
(Includes MCP servers:{' '}
|
||||
{Object.keys(mcpServers).join(', ')})
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Text color={theme.text.secondary}> {agent.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{remaining > 0 && (
|
||||
<Text color={theme.text.secondary}>
|
||||
... and {remaining} more.
|
||||
|
||||
@@ -10,6 +10,8 @@ exports[`NewAgentsNotification > renders agent list 1`] = `
|
||||
│ │ │ │
|
||||
│ │ - Agent A: Description A │ │
|
||||
│ │ - Agent B: Description B │ │
|
||||
│ │ (Includes MCP servers: github, postgres) │ │
|
||||
│ │ - Agent C: Description C │ │
|
||||
│ │ │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
|
||||
@@ -81,6 +81,33 @@ System prompt content.`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse frontmatter with mcp_servers', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
name: mcp-agent
|
||||
description: An agent with MCP servers
|
||||
mcp_servers:
|
||||
test-server:
|
||||
command: node
|
||||
args: [server.js]
|
||||
include_tools: [tool1, tool2]
|
||||
---
|
||||
System prompt content.`);
|
||||
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
name: 'mcp-agent',
|
||||
description: 'An agent with MCP servers',
|
||||
mcp_servers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
include_tools: ['tool1', 'tool2'],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw AgentLoadError if frontmatter is missing', async () => {
|
||||
const filePath = await writeAgentMarkdown(`Just some markdown content.`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
@@ -274,6 +301,33 @@ Body`);
|
||||
expect(result.modelConfig.model).toBe(GEMINI_MODEL_ALIAS_PRO);
|
||||
});
|
||||
|
||||
it('should convert mcp_servers in local agent', () => {
|
||||
const markdown = {
|
||||
kind: 'local' as const,
|
||||
name: 'mcp-agent',
|
||||
description: 'An agent with MCP servers',
|
||||
mcp_servers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
include_tools: ['tool1'],
|
||||
},
|
||||
},
|
||||
system_prompt: 'prompt',
|
||||
};
|
||||
|
||||
const result = markdownToAgentDefinition(
|
||||
markdown,
|
||||
) as LocalAgentDefinition;
|
||||
expect(result.kind).toBe('local');
|
||||
expect(result.mcpServers).toBeDefined();
|
||||
expect(result.mcpServers!['test-server']).toMatchObject({
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
includeTools: ['tool1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through unknown model names (e.g. auto)', () => {
|
||||
const markdown = {
|
||||
kind: 'local' as const,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
} from './types.js';
|
||||
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||
import { MCPServerConfig } from '../config/config.js';
|
||||
import { isValidToolName } from '../tools/tool-names.js';
|
||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
@@ -28,11 +29,29 @@ interface FrontmatterBaseAgentDefinition {
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
interface FrontmatterMCPServerConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
http_url?: string;
|
||||
headers?: Record<string, string>;
|
||||
tcp?: string;
|
||||
type?: 'sse' | 'http';
|
||||
timeout?: number;
|
||||
trust?: boolean;
|
||||
description?: string;
|
||||
include_tools?: string[];
|
||||
exclude_tools?: string[];
|
||||
}
|
||||
|
||||
interface FrontmatterLocalAgentDefinition
|
||||
extends FrontmatterBaseAgentDefinition {
|
||||
kind: 'local';
|
||||
description: string;
|
||||
tools?: string[];
|
||||
mcp_servers?: Record<string, FrontmatterMCPServerConfig>;
|
||||
system_prompt: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
@@ -100,6 +119,23 @@ const nameSchema = z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-_]+$/, 'Name must be a valid slug');
|
||||
|
||||
const mcpServerSchema = z.object({
|
||||
command: z.string().optional(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
cwd: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
http_url: z.string().optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
tcp: z.string().optional(),
|
||||
type: z.enum(['sse', 'http']).optional(),
|
||||
timeout: z.number().optional(),
|
||||
trust: z.boolean().optional(),
|
||||
description: z.string().optional(),
|
||||
include_tools: z.array(z.string()).optional(),
|
||||
exclude_tools: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const localAgentSchema = z
|
||||
.object({
|
||||
kind: z.literal('local').optional().default('local'),
|
||||
@@ -115,6 +151,7 @@ const localAgentSchema = z
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
mcp_servers: z.record(mcpServerSchema).optional(),
|
||||
model: z.string().optional(),
|
||||
temperature: z.number().optional(),
|
||||
max_turns: z.number().int().positive().optional(),
|
||||
@@ -495,6 +532,28 @@ export function markdownToAgentDefinition(
|
||||
// If a model is specified, use it. Otherwise, inherit
|
||||
const modelName = markdown.model || 'inherit';
|
||||
|
||||
const mcpServers: Record<string, MCPServerConfig> = {};
|
||||
if (markdown.kind === 'local' && 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,
|
||||
@@ -520,6 +579,7 @@ export function markdownToAgentDefinition(
|
||||
tools: markdown.tools,
|
||||
}
|
||||
: undefined,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
|
||||
@@ -570,6 +570,19 @@ export class AgentRegistry {
|
||||
},
|
||||
};
|
||||
|
||||
if (overrides.tools) {
|
||||
merged.toolConfig = {
|
||||
tools: overrides.tools,
|
||||
};
|
||||
}
|
||||
|
||||
if (overrides.mcpServers) {
|
||||
merged.mcpServers = {
|
||||
...definition.mcpServers,
|
||||
...overrides.mcpServers,
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 { MCPServerConfig } from '../config/config.js';
|
||||
|
||||
/**
|
||||
* Describes the possible termination modes for an agent.
|
||||
@@ -130,6 +131,11 @@ export interface LocalAgentDefinition<
|
||||
// Optional configs
|
||||
toolConfig?: ToolConfig;
|
||||
|
||||
/**
|
||||
* Optional inline MCP servers for this agent.
|
||||
*/
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
|
||||
/**
|
||||
* An optional function to process the raw output from the agent's final tool
|
||||
* call into a string format.
|
||||
|
||||
@@ -240,6 +240,8 @@ export interface AgentOverride {
|
||||
modelConfig?: ModelConfig;
|
||||
runConfig?: AgentRunConfig;
|
||||
enabled?: boolean;
|
||||
tools?: string[];
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
}
|
||||
|
||||
export interface AgentSettings {
|
||||
|
||||
@@ -435,6 +435,25 @@ export abstract class DeclarativeTool<
|
||||
readonly extensionId?: string,
|
||||
) {}
|
||||
|
||||
clone(messageBus?: MessageBus): this {
|
||||
// Note: we cannot use structuredClone() here because it does not preserve
|
||||
// prototype chains or handle non-serializable properties (like functions).
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const cloned = Object.assign(
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
Object.create(Object.getPrototypeOf(this)),
|
||||
this,
|
||||
) as this;
|
||||
if (messageBus) {
|
||||
Object.defineProperty(cloned, 'messageBus', {
|
||||
value: messageBus,
|
||||
writable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
get isReadOnly(): boolean {
|
||||
return READ_ONLY_KINDS.includes(this.kind);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user