refactor: implement DelegateToAgentTool with discriminated union (#14769)

This commit is contained in:
Abhi
2025-12-10 16:14:13 -05:00
committed by GitHub
parent d4506e0fc0
commit 91b15fc9dc
12 changed files with 544 additions and 45 deletions
@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DelegateToAgentTool } from './delegate-to-agent-tool.js';
import { AgentRegistry } from './registry.js';
import type { Config } from '../config/config.js';
import type { AgentDefinition } from './types.js';
import { SubagentInvocation } from './invocation.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import { DELEGATE_TO_AGENT_TOOL_NAME } from '../tools/tool-names.js';
vi.mock('./invocation.js', () => ({
SubagentInvocation: vi.fn().mockImplementation(() => ({
execute: vi
.fn()
.mockResolvedValue({ content: [{ type: 'text', text: 'Success' }] }),
})),
}));
describe('DelegateToAgentTool', () => {
let registry: AgentRegistry;
let config: Config;
let tool: DelegateToAgentTool;
let messageBus: MessageBus;
const mockAgentDef: AgentDefinition = {
name: 'test_agent',
description: 'A test agent',
promptConfig: {},
modelConfig: { model: 'test-model', temp: 0, top_p: 0 },
inputConfig: {
inputs: {
arg1: { type: 'string', description: 'Argument 1', required: true },
arg2: { type: 'number', description: 'Argument 2', required: false },
},
},
runConfig: { max_turns: 1, max_time_minutes: 1 },
toolConfig: { tools: [] },
};
beforeEach(() => {
config = {
getDebugMode: () => false,
modelConfigService: {
registerRuntimeModelConfig: vi.fn(),
},
} as unknown as Config;
registry = new AgentRegistry(config);
// Manually register the mock agent (bypassing protected method for testing)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(registry as any).agents.set(mockAgentDef.name, mockAgentDef);
messageBus = {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
} as unknown as MessageBus;
tool = new DelegateToAgentTool(registry, config, messageBus);
});
it('should use dynamic description from registry', () => {
// registry has mockAgentDef registered in beforeEach
expect(tool.description).toContain(
'Delegates a task to a specialized sub-agent',
);
expect(tool.description).toContain(
`- **${mockAgentDef.name}**: ${mockAgentDef.description}`,
);
});
it('should validate agent_name exists in registry', async () => {
// Zod validation happens at build time now (or rather, build validates the schema)
// Since we use discriminated union, an invalid agent_name won't match any option.
expect(() =>
tool.build({
agent_name: 'non_existent_agent',
}),
).toThrow();
});
it('should validate correct arguments', async () => {
const invocation = tool.build({
agent_name: 'test_agent',
arg1: 'valid',
});
const result = await invocation.execute(new AbortController().signal);
expect(result).toEqual({ content: [{ type: 'text', text: 'Success' }] });
expect(SubagentInvocation).toHaveBeenCalledWith(
{ arg1: 'valid' },
mockAgentDef,
config,
messageBus,
);
});
it('should throw error for missing required argument', async () => {
// Missing arg1 should fail Zod validation
expect(() =>
tool.build({
agent_name: 'test_agent',
arg2: 123,
}),
).toThrow();
});
it('should throw error for invalid argument type', async () => {
// arg1 should be string, passing number
expect(() =>
tool.build({
agent_name: 'test_agent',
arg1: 123,
}),
).toThrow();
});
it('should allow optional arguments to be omitted', async () => {
const invocation = tool.build({
agent_name: 'test_agent',
arg1: 'valid',
// arg2 is optional
});
await expect(
invocation.execute(new AbortController().signal),
).resolves.toBeDefined();
});
it('should throw error if an agent has an input named "agent_name"', () => {
const invalidAgentDef: AgentDefinition = {
...mockAgentDef,
name: 'invalid_agent',
inputConfig: {
inputs: {
agent_name: {
type: 'string',
description: 'Conflict',
required: true,
},
},
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(registry as any).agents.set(invalidAgentDef.name, invalidAgentDef);
expect(() => new DelegateToAgentTool(registry, config)).toThrow(
"Agent 'invalid_agent' cannot have an input parameter named 'agent_name' as it is a reserved parameter for delegation.",
);
});
it('should use correct tool name "delegate_to_agent" when requesting confirmation', async () => {
const invocation = tool.build({
agent_name: 'test_agent',
arg1: 'valid',
});
// Trigger confirmation check
const p = invocation.shouldConfirmExecute(new AbortController().signal);
void p;
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
toolCall: expect.objectContaining({
name: DELEGATE_TO_AGENT_TOOL_NAME,
}),
}),
);
});
});
@@ -0,0 +1,182 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import {
BaseDeclarativeTool,
Kind,
type ToolInvocation,
type ToolResult,
BaseToolInvocation,
} from '../tools/tools.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { DELEGATE_TO_AGENT_TOOL_NAME } from '../tools/tool-names.js';
import type { AgentRegistry } from './registry.js';
import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { SubagentInvocation } from './invocation.js';
import type { AgentInputs } from './types.js';
type DelegateParams = { agent_name: string } & Record<string, unknown>;
export class DelegateToAgentTool extends BaseDeclarativeTool<
DelegateParams,
ToolResult
> {
constructor(
private readonly registry: AgentRegistry,
private readonly config: Config,
messageBus?: MessageBus,
) {
const definitions = registry.getAllDefinitions();
let schema: z.ZodTypeAny;
if (definitions.length === 0) {
// Fallback if no agents are registered (mostly for testing/safety)
schema = z.object({
agent_name: z.string().describe('No agents are currently available.'),
});
} else {
const agentSchemas = definitions.map((def) => {
const inputShape: Record<string, z.ZodTypeAny> = {
agent_name: z.literal(def.name).describe(def.description),
};
for (const [key, inputDef] of Object.entries(def.inputConfig.inputs)) {
if (key === 'agent_name') {
throw new Error(
`Agent '${def.name}' cannot have an input parameter named 'agent_name' as it is a reserved parameter for delegation.`,
);
}
let validator: z.ZodTypeAny;
// Map input types to Zod
switch (inputDef.type) {
case 'string':
validator = z.string();
break;
case 'number':
validator = z.number();
break;
case 'boolean':
validator = z.boolean();
break;
case 'integer':
validator = z.number().int();
break;
case 'string[]':
validator = z.array(z.string());
break;
case 'number[]':
validator = z.array(z.number());
break;
default: {
// This provides compile-time exhaustiveness checking.
const _exhaustiveCheck: never = inputDef.type;
void _exhaustiveCheck;
throw new Error(`Unhandled agent input type: '${inputDef.type}'`);
}
}
if (!inputDef.required) {
validator = validator.optional();
}
inputShape[key] = validator.describe(inputDef.description);
}
// Cast required because Zod can't infer the discriminator from dynamic keys
return z.object(
inputShape,
) as z.ZodDiscriminatedUnionOption<'agent_name'>;
});
// Create the discriminated union
// z.discriminatedUnion requires at least 2 options, so we handle the single agent case
if (agentSchemas.length === 1) {
schema = agentSchemas[0];
} else {
schema = z.discriminatedUnion(
'agent_name',
agentSchemas as [
z.ZodDiscriminatedUnionOption<'agent_name'>,
z.ZodDiscriminatedUnionOption<'agent_name'>,
...Array<z.ZodDiscriminatedUnionOption<'agent_name'>>,
],
);
}
}
super(
DELEGATE_TO_AGENT_TOOL_NAME,
'Delegate to Agent',
registry.getToolDescription(),
Kind.Think,
zodToJsonSchema(schema),
/* isOutputMarkdown */ true,
/* canUpdateOutput */ true,
messageBus,
);
}
protected createInvocation(
params: DelegateParams,
): ToolInvocation<DelegateParams, ToolResult> {
return new DelegateInvocation(
params,
this.registry,
this.config,
this.messageBus,
);
}
}
class DelegateInvocation extends BaseToolInvocation<
DelegateParams,
ToolResult
> {
constructor(
params: DelegateParams,
private readonly registry: AgentRegistry,
private readonly config: Config,
messageBus?: MessageBus,
) {
super(params, messageBus, DELEGATE_TO_AGENT_TOOL_NAME);
}
getDescription(): string {
return `Delegating to agent '${this.params.agent_name}'`;
}
async execute(
signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
): Promise<ToolResult> {
const definition = this.registry.getDefinition(this.params.agent_name);
if (!definition) {
throw new Error(
`Agent '${this.params.agent_name}' exists in the tool definition but could not be found in the registry.`,
);
}
// Extract arguments (everything except agent_name)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { agent_name, ...agentArgs } = this.params;
// Instantiate the Subagent Loop
const subagentInvocation = new SubagentInvocation(
agentArgs as AgentInputs,
definition,
this.config,
this.messageBus,
);
return subagentInvocation.execute(signal, updateOutput);
}
}
+29
View File
@@ -237,4 +237,33 @@ describe('AgentRegistry', () => {
);
});
});
describe('getToolDescription', () => {
it('should return default message when no agents are registered', () => {
expect(registry.getToolDescription()).toContain(
'No agents are currently available',
);
});
it('should return formatted list of agents when agents are available', () => {
registry.testRegisterAgent(MOCK_AGENT_V1);
registry.testRegisterAgent({
...MOCK_AGENT_V2,
name: 'AnotherAgent',
description: 'Another agent description',
});
const description = registry.getToolDescription();
expect(description).toContain(
'Delegates a task to a specialized sub-agent',
);
expect(description).toContain('Available agents:');
expect(description).toContain(
`- **${MOCK_AGENT_V1.name}**: ${MOCK_AGENT_V1.description}`,
);
expect(description).toContain(
`- **AnotherAgent**: Another agent description`,
);
});
});
});
+46
View File
@@ -150,4 +150,50 @@ export class AgentRegistry {
getAllDefinitions(): AgentDefinition[] {
return Array.from(this.agents.values());
}
/**
* Returns a list of all registered agent names.
*/
getAllAgentNames(): string[] {
return Array.from(this.agents.keys());
}
/**
* Generates a description for the delegate_to_agent tool.
* Unlike getDirectoryContext() which is for system prompts,
* this is formatted for tool descriptions.
*/
getToolDescription(): string {
if (this.agents.size === 0) {
return 'Delegates a task to a specialized sub-agent. No agents are currently available.';
}
const agentDescriptions = Array.from(this.agents.entries())
.map(([name, def]) => `- **${name}**: ${def.description}`)
.join('\n');
return `Delegates a task to a specialized sub-agent.
Available agents:
${agentDescriptions}`;
}
/**
* Generates a markdown "Phone Book" of available agents and their schemas.
* This MUST be injected into the System Prompt of the parent agent.
*/
getDirectoryContext(): string {
if (this.agents.size === 0) {
return 'No sub-agents are currently available.';
}
let context = '## Available Sub-Agents\n';
context +=
'Use `delegate_to_agent` for complex tasks requiring specialized analysis.\n\n';
for (const [name, def] of this.agents.entries()) {
context += `- **${name}**: ${def.description}\n`;
}
return context;
}
}