fix(core): Provide compact, actionable errors for agent delegation failures (#16493)

Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
Sandy Tao
2026-01-20 09:30:59 -08:00
committed by GitHub
parent 85b17166a5
commit 15f26175b8
2 changed files with 114 additions and 27 deletions
@@ -5,7 +5,10 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DelegateToAgentTool } from './delegate-to-agent-tool.js';
import {
DelegateToAgentTool,
type DelegateParams,
} from './delegate-to-agent-tool.js';
import { AgentRegistry } from './registry.js';
import type { Config } from '../config/config.js';
import type { AgentDefinition } from './types.js';
@@ -110,14 +113,17 @@ describe('DelegateToAgentTool', () => {
);
});
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 throw helpful error when agent_name does not exist', async () => {
// We allow validation to pass now, checking happens in execute.
const invocation = tool.build({
agent_name: 'non_existent_agent',
} as DelegateParams);
await expect(() =>
invocation.execute(new AbortController().signal),
).rejects.toThrow(
"Agent 'non_existent_agent' not found. Available agents are: 'test_agent' (A test agent), 'remote_agent' (A remote agent). Please choose a valid agent_name.",
);
});
it('should validate correct arguments', async () => {
@@ -138,24 +144,30 @@ describe('DelegateToAgentTool', () => {
);
});
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 helpful error for missing required argument', async () => {
const invocation = tool.build({
agent_name: 'test_agent',
arg2: 123,
} as DelegateParams);
await expect(() =>
invocation.execute(new AbortController().signal),
).rejects.toThrow(
"arg1: Required. Expected inputs: 'arg1' (required string), 'arg2' (optional number).",
);
});
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 throw helpful error for invalid argument type', async () => {
const invocation = tool.build({
agent_name: 'test_agent',
arg1: 123,
} as DelegateParams);
await expect(() =>
invocation.execute(new AbortController().signal),
).rejects.toThrow(
"arg1: Expected string, received number. Expected inputs: 'arg1' (required string), 'arg2' (optional number).",
);
});
it('should allow optional arguments to be omitted', async () => {
@@ -22,7 +22,7 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { AgentDefinition, AgentInputs } from './types.js';
import { SubagentToolWrapper } from './subagent-tool-wrapper.js';
type DelegateParams = { agent_name: string } & Record<string, unknown>;
export type DelegateParams = { agent_name: string } & Record<string, unknown>;
export class DelegateToAgentTool extends BaseDeclarativeTool<
DelegateParams,
@@ -126,6 +126,14 @@ export class DelegateToAgentTool extends BaseDeclarativeTool<
);
}
override validateToolParams(_params: DelegateParams): string | null {
// We override the default schema validation because the generic JSON schema validation
// produces poor error messages for discriminated unions (anyOf).
// Instead, we perform detailed, agent-specific validation in the `execute` method
// to provide rich error messages that help the LLM self-heal.
return null;
}
protected createInvocation(
params: DelegateParams,
messageBus: MessageBus,
@@ -190,12 +198,79 @@ class DelegateInvocation extends BaseToolInvocation<
): Promise<ToolResult> {
const definition = this.registry.getDefinition(this.params.agent_name);
if (!definition) {
const availableAgents = this.registry
.getAllDefinitions()
.map((def) => `'${def.name}' (${def.description})`)
.join(', ');
throw new Error(
`Agent '${this.params.agent_name}' not found in registry.`,
`Agent '${this.params.agent_name}' not found. Available agents are: ${availableAgents}. Please choose a valid agent_name.`,
);
}
const { agent_name: _agent_name, ...agentArgs } = this.params;
// Validate specific agent arguments here using Zod to generate helpful error messages.
const inputShape: Record<string, z.ZodTypeAny> = {};
for (const [key, inputDef] of Object.entries(
definition.inputConfig.inputs,
)) {
let validator: z.ZodTypeAny;
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:
validator = z.unknown();
}
if (!inputDef.required) {
validator = validator.optional();
}
inputShape[key] = validator.describe(inputDef.description);
}
const agentSchema = z.object(inputShape);
try {
agentSchema.parse(agentArgs);
} catch (e) {
if (e instanceof z.ZodError) {
const errorMessages = e.errors.map(
(err) => `${err.path.join('.')}: ${err.message}`,
);
const expectedInputs = Object.entries(definition.inputConfig.inputs)
.map(
([key, input]) =>
`'${key}' (${input.required ? 'required' : 'optional'} ${input.type})`,
)
.join(', ');
throw new Error(
`${errorMessages.join(', ')}. Expected inputs: ${expectedInputs}.`,
);
}
throw e;
}
const invocation = this.buildSubInvocation(
definition,
agentArgs as AgentInputs,