From 27d21f9921fd753b83768db3c5d0e7d5314e9e0c Mon Sep 17 00:00:00 2001 From: joshualitt Date: Wed, 21 Jan 2026 16:56:01 -0800 Subject: [PATCH] feat(core): Have subagents use a JSON schema type for input. (#17152) --- packages/core/src/agents/agentLoader.test.ts | 25 ++- packages/core/src/agents/agentLoader.ts | 14 +- .../core/src/agents/cli-help-agent.test.ts | 6 +- packages/core/src/agents/cli-help-agent.ts | 13 +- .../src/agents/codebase-investigator.test.ts | 8 +- .../core/src/agents/codebase-investigator.ts | 13 +- .../src/agents/delegate-to-agent-tool.test.ts | 66 ++++-- .../core/src/agents/delegate-to-agent-tool.ts | 198 +++++++----------- packages/core/src/agents/generalist-agent.ts | 13 +- .../core/src/agents/local-executor.test.ts | 16 +- packages/core/src/agents/local-executor.ts | 4 +- .../core/src/agents/local-invocation.test.ts | 10 +- packages/core/src/agents/registry.test.ts | 8 +- .../core/src/agents/remote-invocation.test.ts | 29 ++- packages/core/src/agents/remote-invocation.ts | 3 +- packages/core/src/agents/schema-utils.test.ts | 165 --------------- packages/core/src/agents/schema-utils.ts | 90 -------- .../src/agents/subagent-tool-wrapper.test.ts | 44 ++-- .../core/src/agents/subagent-tool-wrapper.ts | 7 +- packages/core/src/agents/types.ts | 25 +-- packages/core/src/utils/schemaValidator.ts | 14 +- 21 files changed, 271 insertions(+), 500 deletions(-) delete mode 100644 packages/core/src/agents/schema-utils.test.ts delete mode 100644 packages/core/src/agents/schema-utils.ts diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts index 088b233177..bf7a77b44b 100644 --- a/packages/core/src/agents/agentLoader.test.ts +++ b/packages/core/src/agents/agentLoader.test.ts @@ -253,11 +253,15 @@ Body`); maxTimeMinutes: 5, }, inputConfig: { - inputs: { - query: { - type: 'string', - required: false, + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The task for the agent.', + }, }, + required: [], }, }, }); @@ -309,12 +313,15 @@ Body`); displayName: undefined, agentCardUrl: 'https://example.com/card', inputConfig: { - inputs: { - query: { - type: 'string', - description: 'The task for the agent.', - required: false, + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The task for the agent.', + }, }, + required: [], }, }, }); diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index b08979bbe4..79295d4855 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -247,12 +247,16 @@ export function markdownToAgentDefinition( markdown: FrontmatterAgentDefinition, ): AgentDefinition { const inputConfig = { - inputs: { - query: { - type: 'string' as const, - description: 'The task for the agent.', - required: false, + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The task for the agent.', + }, }, + // query is not required because it defaults to "Get Started!" if not provided + required: [], }, }; diff --git a/packages/core/src/agents/cli-help-agent.test.ts b/packages/core/src/agents/cli-help-agent.test.ts index 579dd58d72..e330aa769b 100644 --- a/packages/core/src/agents/cli-help-agent.test.ts +++ b/packages/core/src/agents/cli-help-agent.test.ts @@ -26,8 +26,10 @@ describe('CliHelpAgent', () => { }); it('should have correctly configured inputs and outputs', () => { - expect(localAgent.inputConfig.inputs['question']).toBeDefined(); - expect(localAgent.inputConfig.inputs['question'].required).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inputSchema = localAgent.inputConfig.inputSchema as any; + expect(inputSchema.properties['question']).toBeDefined(); + expect(inputSchema.required).toContain('question'); expect(localAgent.outputConfig?.outputName).toBe('report'); expect(localAgent.outputConfig?.description).toBeDefined(); diff --git a/packages/core/src/agents/cli-help-agent.ts b/packages/core/src/agents/cli-help-agent.ts index 71a020f3a8..dee10f0ab6 100644 --- a/packages/core/src/agents/cli-help-agent.ts +++ b/packages/core/src/agents/cli-help-agent.ts @@ -32,12 +32,15 @@ export const CliHelpAgent = ( description: 'Specialized in answering questions about how users use you, (Gemini CLI): features, documentation, and current runtime configuration.', inputConfig: { - inputs: { - question: { - description: 'The specific question about Gemini CLI.', - type: 'string', - required: true, + inputSchema: { + type: 'object', + properties: { + question: { + type: 'string', + description: 'The specific question about Gemini CLI.', + }, }, + required: ['question'], }, }, outputConfig: { diff --git a/packages/core/src/agents/codebase-investigator.test.ts b/packages/core/src/agents/codebase-investigator.test.ts index 3d8453cb97..c7cbee92cc 100644 --- a/packages/core/src/agents/codebase-investigator.test.ts +++ b/packages/core/src/agents/codebase-investigator.test.ts @@ -21,9 +21,11 @@ describe('CodebaseInvestigatorAgent', () => { 'Codebase Investigator Agent', ); expect(CodebaseInvestigatorAgent.description).toBeDefined(); - expect( - CodebaseInvestigatorAgent.inputConfig.inputs['objective'].required, - ).toBe(true); + const inputSchema = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + CodebaseInvestigatorAgent.inputConfig.inputSchema as any; + expect(inputSchema.properties['objective']).toBeDefined(); + expect(inputSchema.required).toContain('objective'); expect(CodebaseInvestigatorAgent.outputConfig?.outputName).toBe('report'); expect(CodebaseInvestigatorAgent.modelConfig?.model).toBe( DEFAULT_GEMINI_MODEL, diff --git a/packages/core/src/agents/codebase-investigator.ts b/packages/core/src/agents/codebase-investigator.ts index 7e0b7fd3cf..bdfa378c50 100644 --- a/packages/core/src/agents/codebase-investigator.ts +++ b/packages/core/src/agents/codebase-investigator.ts @@ -51,13 +51,16 @@ export const CodebaseInvestigatorAgent: LocalAgentDefinition< Invoke this tool for tasks like vague requests, bug root-cause analysis, system refactoring, comprehensive feature implementation or to answer questions about the codebase that require investigation. It returns a structured report with key file paths, symbols, and actionable architectural insights.`, inputConfig: { - inputs: { - objective: { - description: `A comprehensive and detailed description of the user's ultimate goal. + inputSchema: { + type: 'object', + properties: { + objective: { + type: 'string', + description: `A comprehensive and detailed description of the user's ultimate goal. You must include original user's objective as well as questions and any extra context and questions you may have.`, - type: 'string', - required: true, + }, }, + required: ['objective'], }, }, outputConfig: { diff --git a/packages/core/src/agents/delegate-to-agent-tool.test.ts b/packages/core/src/agents/delegate-to-agent-tool.test.ts index bb60048835..89cd1babdb 100644 --- a/packages/core/src/agents/delegate-to-agent-tool.test.ts +++ b/packages/core/src/agents/delegate-to-agent-tool.test.ts @@ -61,9 +61,13 @@ describe('DelegateToAgentTool', () => { }, }, inputConfig: { - inputs: { - arg1: { type: 'string', description: 'Argument 1', required: true }, - arg2: { type: 'number', description: 'Argument 2', required: false }, + inputSchema: { + type: 'object', + properties: { + arg1: { type: 'string', description: 'Argument 1' }, + arg2: { type: 'number', description: 'Argument 2' }, + }, + required: ['arg1'], }, }, runConfig: { maxTurns: 1, maxTimeMinutes: 1 }, @@ -76,8 +80,12 @@ describe('DelegateToAgentTool', () => { description: 'A remote agent', agentCardUrl: 'https://example.com/agent.json', inputConfig: { - inputs: { - query: { type: 'string', description: 'Query', required: true }, + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Query' }, + }, + required: ['query'], }, }, }; @@ -153,7 +161,7 @@ describe('DelegateToAgentTool', () => { await expect(() => invocation.execute(new AbortController().signal), ).rejects.toThrow( - "arg1: Required. Expected inputs: 'arg1' (required string), 'arg2' (optional number).", + `Invalid arguments for agent 'test_agent': params must have required property 'arg1'. Input schema: ${JSON.stringify(mockAgentDef.inputConfig.inputSchema)}.`, ); }); @@ -166,7 +174,7 @@ describe('DelegateToAgentTool', () => { await expect(() => invocation.execute(new AbortController().signal), ).rejects.toThrow( - "arg1: Expected string, received number. Expected inputs: 'arg1' (required string), 'arg2' (optional number).", + `Invalid arguments for agent 'test_agent': params/arg1 must be string. Input schema: ${JSON.stringify(mockAgentDef.inputConfig.inputSchema)}.`, ); }); @@ -187,12 +195,15 @@ describe('DelegateToAgentTool', () => { ...mockAgentDef, name: 'invalid_agent', inputConfig: { - inputs: { - agent_name: { - type: 'string', - description: 'Conflict', - required: true, + inputSchema: { + type: 'object', + properties: { + agent_name: { + type: 'string', + description: 'Conflict', + }, }, + required: ['agent_name'], }, }, }; @@ -205,6 +216,37 @@ describe('DelegateToAgentTool', () => { ); }); + it('should allow a remote agent missing a "query" input (will default at runtime)', () => { + const invalidRemoteAgentDef: AgentDefinition = { + kind: 'remote', + name: 'invalid_remote', + description: 'Conflict', + agentCardUrl: 'https://example.com/agent.json', + inputConfig: { + inputSchema: { + type: 'object', + properties: { + not_query: { + type: 'string', + description: 'Not a query', + }, + }, + required: ['not_query'], + }, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (registry as any).agents.set( + invalidRemoteAgentDef.name, + invalidRemoteAgentDef, + ); + + expect( + () => new DelegateToAgentTool(registry, config, messageBus), + ).not.toThrow(); + }); + it('should execute local agents silently without requesting confirmation', async () => { const invocation = tool.build({ agent_name: 'test_agent', diff --git a/packages/core/src/agents/delegate-to-agent-tool.ts b/packages/core/src/agents/delegate-to-agent-tool.ts index be833a7f01..064428940d 100644 --- a/packages/core/src/agents/delegate-to-agent-tool.ts +++ b/packages/core/src/agents/delegate-to-agent-tool.ts @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; -import { zodToJsonSchema } from 'zod-to-json-schema'; import { BaseDeclarativeTool, Kind, @@ -21,6 +19,9 @@ import type { Config } from '../config/config.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import type { AgentDefinition, AgentInputs } from './types.js'; import { SubagentToolWrapper } from './subagent-tool-wrapper.js'; +import { SchemaValidator } from '../utils/schemaValidator.js'; +import { type AnySchema } from 'ajv'; +import { debugLogger } from '../utils/debugLogger.js'; export type DelegateParams = { agent_name: string } & Record; @@ -35,82 +36,76 @@ export class DelegateToAgentTool extends BaseDeclarativeTool< ) { const definitions = registry.getAllDefinitions(); - let schema: z.ZodTypeAny; + let toolSchema: AnySchema; 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.'), - }); + toolSchema = { + type: 'object', + properties: { + agent_name: { + type: 'string', + description: 'No agents are currently available.', + }, + }, + required: ['agent_name'], + }; } else { const agentSchemas = definitions.map((def) => { - const inputShape: Record = { - 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); + const schemaError = SchemaValidator.validateSchema( + def.inputConfig.inputSchema, + ); + if (schemaError) { + throw new Error(`Invalid schema for ${def.name}: ${schemaError}`); } - // Cast required because Zod can't infer the discriminator from dynamic keys - return z.object( - inputShape, - ) as z.ZodDiscriminatedUnionOption<'agent_name'>; + const inputSchema = def.inputConfig.inputSchema; + if (typeof inputSchema !== 'object' || inputSchema === null) { + throw new Error(`Agent '${def.name}' must provide an object schema.`); + } + + const schemaObj = inputSchema as Record; + const properties = schemaObj['properties'] as + | Record + | undefined; + if (properties && 'agent_name' in properties) { + throw new Error( + `Agent '${def.name}' cannot have an input parameter named 'agent_name' as it is a reserved parameter for delegation.`, + ); + } + + if (def.kind === 'remote') { + if (!properties || !properties['query']) { + debugLogger.log( + 'INFO', + `Remote agent '${def.name}' does not define a 'query' property in its inputSchema. It will default to 'Get Started!' during invocation.`, + ); + } + } + + return { + type: 'object', + properties: { + agent_name: { + const: def.name, + description: def.description, + }, + ...(properties || {}), + }, + required: [ + 'agent_name', + ...((schemaObj['required'] as string[]) || []), + ], + } as AnySchema; }); - // Create the discriminated union - // z.discriminatedUnion requires at least 2 options, so we handle the single agent case + // Create the anyOf schema if (agentSchemas.length === 1) { - schema = agentSchemas[0]; + toolSchema = agentSchemas[0]; } else { - schema = z.discriminatedUnion( - 'agent_name', - agentSchemas as [ - z.ZodDiscriminatedUnionOption<'agent_name'>, - z.ZodDiscriminatedUnionOption<'agent_name'>, - ...Array>, - ], - ); + toolSchema = { + anyOf: agentSchemas, + }; } } @@ -119,7 +114,7 @@ export class DelegateToAgentTool extends BaseDeclarativeTool< 'Delegate to Agent', registry.getToolDescription(), Kind.Think, - zodToJsonSchema(schema), + toolSchema, messageBus, /* isOutputMarkdown */ true, /* canUpdateOutput */ true, @@ -210,65 +205,16 @@ class DelegateInvocation extends BaseToolInvocation< const { agent_name: _agent_name, ...agentArgs } = this.params; - // Validate specific agent arguments here using Zod to generate helpful error messages. - const inputShape: Record = {}; - for (const [key, inputDef] of Object.entries( - definition.inputConfig.inputs, - )) { - let validator: z.ZodTypeAny; + // Validate specific agent arguments here using SchemaValidator to generate helpful error messages. + const validationError = SchemaValidator.validate( + definition.inputConfig.inputSchema, + agentArgs, + ); - 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; + if (validationError) { + throw new Error( + `Invalid arguments for agent '${definition.name}': ${validationError}. Input schema: ${JSON.stringify(definition.inputConfig.inputSchema)}.`, + ); } const invocation = this.buildSubInvocation( diff --git a/packages/core/src/agents/generalist-agent.ts b/packages/core/src/agents/generalist-agent.ts index c9c3340a0c..492fee52de 100644 --- a/packages/core/src/agents/generalist-agent.ts +++ b/packages/core/src/agents/generalist-agent.ts @@ -28,12 +28,15 @@ export const GeneralistAgent = ( "A general-purpose AI agent with access to all tools. Use it for complex tasks that don't fit into other specialized agents.", experimental: true, inputConfig: { - inputs: { - request: { - description: 'The task or question for the generalist agent.', - type: 'string', - required: true, + inputSchema: { + type: 'object', + properties: { + request: { + type: 'string', + description: 'The task or question for the generalist agent.', + }, }, + required: ['request'], }, }, outputConfig: { diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index c65442182c..e2269e815a 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -223,7 +223,13 @@ const createTestDefinition = ( name: 'TestAgent', description: 'An agent for testing.', inputConfig: { - inputs: { goal: { type: 'string', required: true, description: 'goal' } }, + inputSchema: { + type: 'object', + properties: { + goal: { type: 'string', description: 'goal' }, + }, + required: ['goal'], + }, }, modelConfig: { model: 'gemini-test-model', @@ -411,8 +417,12 @@ describe('LocalAgentExecutor', () => { it('should log AgentFinish with error if run throws', async () => { const definition = createTestDefinition(); // Make the definition invalid to cause an error during run - definition.inputConfig.inputs = { - goal: { type: 'string', required: true, description: 'goal' }, + definition.inputConfig.inputSchema = { + type: 'object', + properties: { + goal: { type: 'string', description: 'goal' }, + }, + required: ['goal'], }; const executor = await LocalAgentExecutor.create( definition, diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index 8859b72385..d20ca4c51c 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -38,7 +38,7 @@ import type { OutputObject, SubagentActivityEvent, } from './types.js'; -import { AgentTerminateMode } from './types.js'; +import { AgentTerminateMode, DEFAULT_QUERY_STRING } from './types.js'; import { templateString } from './utils.js'; import { DEFAULT_GEMINI_MODEL, isAutoModel } from '../config/models.js'; import type { RoutingContext } from '../routing/routingStrategy.js'; @@ -395,7 +395,7 @@ export class LocalAgentExecutor { chat = await this.createChatObject(augmentedInputs, tools); const query = this.definition.promptConfig.query ? templateString(this.definition.promptConfig.query, augmentedInputs) - : 'Get Started!'; + : DEFAULT_QUERY_STRING; let currentMessage: Content = { role: 'user', parts: [{ text: query }] }; while (true) { diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index 62de4b4c02..cdaa46fd76 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -28,9 +28,13 @@ const testDefinition: LocalAgentDefinition = { name: 'MockAgent', description: 'A mock agent.', inputConfig: { - inputs: { - task: { type: 'string', required: true, description: 'task' }, - priority: { type: 'number', required: false, description: 'prio' }, + inputSchema: { + type: 'object', + properties: { + task: { type: 'string', description: 'task' }, + priority: { type: 'number', description: 'prio' }, + }, + required: ['task'], }, }, modelConfig: { diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index 0b7a15b4bb..df7dea9384 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -59,7 +59,7 @@ const MOCK_AGENT_V1: AgentDefinition = { kind: 'local', name: 'MockAgent', description: 'Mock Description V1', - inputConfig: { inputs: {} }, + inputConfig: { inputSchema: { type: 'object' } }, modelConfig: { model: 'test', generateContentConfig: { @@ -447,7 +447,7 @@ describe('AgentRegistry', () => { name: 'RemoteAgent', description: 'A remote agent', agentCardUrl: 'https://example.com/card', - inputConfig: { inputs: {} }, + inputConfig: { inputSchema: { type: 'object' } }, }; vi.mocked(A2AClientManager.getInstance).mockReturnValue({ @@ -470,7 +470,7 @@ describe('AgentRegistry', () => { name: 'RemoteAgent', description: 'A remote agent', agentCardUrl: 'https://example.com/card', - inputConfig: { inputs: {} }, + inputConfig: { inputSchema: { type: 'object' } }, }; vi.mocked(A2AClientManager.getInstance).mockReturnValue({ @@ -791,7 +791,7 @@ describe('AgentRegistry', () => { name: 'RemoteAgent', description: 'A remote agent', agentCardUrl: 'https://example.com/card', - inputConfig: { inputs: {} }, + inputConfig: { inputSchema: { type: 'object' } }, }; await registry.testRegisterAgent(remoteAgent); diff --git a/packages/core/src/agents/remote-invocation.test.ts b/packages/core/src/agents/remote-invocation.test.ts index 5f2c3eb732..7baa77d941 100644 --- a/packages/core/src/agents/remote-invocation.test.ts +++ b/packages/core/src/agents/remote-invocation.test.ts @@ -34,7 +34,7 @@ describe('RemoteAgentInvocation', () => { displayName: 'Test Agent', description: 'A test agent', inputConfig: { - inputs: {}, + inputSchema: { type: 'object' }, }, }; @@ -70,10 +70,33 @@ describe('RemoteAgentInvocation', () => { }).not.toThrow(); }); - it('throws if query is missing', () => { + it('accepts missing query (defaults to "Get Started!")', () => { expect(() => { new RemoteAgentInvocation(mockDefinition, {}, mockMessageBus); - }).toThrow("requires a string 'query' input"); + }).not.toThrow(); + }); + + it('uses "Get Started!" default when query is missing during execution', async () => { + mockClientManager.getClient.mockReturnValue({}); + mockClientManager.sendMessage.mockResolvedValue({ + kind: 'message', + messageId: 'msg-1', + role: 'agent', + parts: [{ kind: 'text', text: 'Hello' }], + }); + + const invocation = new RemoteAgentInvocation( + mockDefinition, + {}, + mockMessageBus, + ); + await invocation.execute(new AbortController().signal); + + expect(mockClientManager.sendMessage).toHaveBeenCalledWith( + 'test-agent', + 'Get Started!', + expect.any(Object), + ); }); it('throws if query is not a string', () => { diff --git a/packages/core/src/agents/remote-invocation.ts b/packages/core/src/agents/remote-invocation.ts index a5894c37b4..10301f3090 100644 --- a/packages/core/src/agents/remote-invocation.ts +++ b/packages/core/src/agents/remote-invocation.ts @@ -10,6 +10,7 @@ import { type ToolResult, type ToolCallConfirmationDetails, } from '../tools/tools.js'; +import { DEFAULT_QUERY_STRING } from './types.js'; import type { RemoteAgentInputs, RemoteAgentDefinition, @@ -89,7 +90,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation< _toolName?: string, _toolDisplayName?: string, ) { - const query = params['query']; + const query = params['query'] ?? DEFAULT_QUERY_STRING; if (typeof query !== 'string') { throw new Error( `Remote agent '${definition.name}' requires a string 'query' input.`, diff --git a/packages/core/src/agents/schema-utils.test.ts b/packages/core/src/agents/schema-utils.test.ts deleted file mode 100644 index 48ef90e0b6..0000000000 --- a/packages/core/src/agents/schema-utils.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect } from 'vitest'; -import { convertInputConfigToJsonSchema } from './schema-utils.js'; -import type { InputConfig } from './types.js'; - -const PRIMITIVE_TYPES_CONFIG: InputConfig = { - inputs: { - goal: { - type: 'string', - description: 'The primary objective', - required: true, - }, - max_retries: { - type: 'integer', - description: 'Maximum number of retries', - required: false, - }, - temperature: { - type: 'number', - description: 'The model temperature', - required: true, - }, - verbose: { - type: 'boolean', - description: 'Enable verbose logging', - required: false, - }, - }, -}; - -const ARRAY_TYPES_CONFIG: InputConfig = { - inputs: { - filenames: { - type: 'string[]', - description: 'A list of file paths', - required: true, - }, - scores: { - type: 'number[]', - description: 'A list of scores', - required: false, - }, - }, -}; - -const NO_REQUIRED_FIELDS_CONFIG: InputConfig = { - inputs: { - optional_param: { - type: 'string', - description: 'An optional parameter', - required: false, - }, - }, -}; - -const ALL_REQUIRED_FIELDS_CONFIG: InputConfig = { - inputs: { - paramA: { type: 'string', description: 'Parameter A', required: true }, - paramB: { type: 'boolean', description: 'Parameter B', required: true }, - }, -}; - -const EMPTY_CONFIG: InputConfig = { - inputs: {}, -}; - -const UNSUPPORTED_TYPE_CONFIG: InputConfig = { - inputs: { - invalid_param: { - // @ts-expect-error - Intentionally testing an invalid type - type: 'date', - description: 'This type is not supported', - required: true, - }, - }, -}; - -describe('convertInputConfigToJsonSchema', () => { - describe('type conversion', () => { - it('should correctly convert an InputConfig with various primitive types', () => { - const result = convertInputConfigToJsonSchema(PRIMITIVE_TYPES_CONFIG); - - expect(result).toEqual({ - type: 'object', - properties: { - goal: { type: 'string', description: 'The primary objective' }, - max_retries: { - type: 'integer', - description: 'Maximum number of retries', - }, - temperature: { type: 'number', description: 'The model temperature' }, - verbose: { type: 'boolean', description: 'Enable verbose logging' }, - }, - required: ['goal', 'temperature'], - }); - }); - - it('should correctly handle array types for strings and numbers', () => { - const result = convertInputConfigToJsonSchema(ARRAY_TYPES_CONFIG); - - expect(result).toEqual({ - type: 'object', - properties: { - filenames: { - type: 'array', - description: 'A list of file paths', - items: { type: 'string' }, - }, - scores: { - type: 'array', - description: 'A list of scores', - items: { type: 'number' }, - }, - }, - required: ['filenames'], - }); - }); - }); - - describe('required field handling', () => { - it('should produce an undefined `required` field when no inputs are required', () => { - const result = convertInputConfigToJsonSchema(NO_REQUIRED_FIELDS_CONFIG); - - expect(result.properties['optional_param']).toBeDefined(); - // Per the implementation and JSON Schema spec, the `required` field - // should be omitted if no properties are required. - expect(result.required).toBeUndefined(); - }); - - it('should list all properties in `required` when all are marked as required', () => { - const result = convertInputConfigToJsonSchema(ALL_REQUIRED_FIELDS_CONFIG); - expect(result.required).toHaveLength(2); - expect(result.required).toEqual( - expect.arrayContaining(['paramA', 'paramB']), - ); - }); - }); - - describe('edge cases', () => { - it('should return a valid, empty schema for an empty input config', () => { - const result = convertInputConfigToJsonSchema(EMPTY_CONFIG); - - expect(result).toEqual({ - type: 'object', - properties: {}, - required: undefined, - }); - }); - }); - - describe('error handling', () => { - it('should throw an informative error for an unsupported input type', () => { - const action = () => - convertInputConfigToJsonSchema(UNSUPPORTED_TYPE_CONFIG); - - expect(action).toThrow(/Unsupported input type 'date'/); - expect(action).toThrow(/parameter 'invalid_param'/); - }); - }); -}); diff --git a/packages/core/src/agents/schema-utils.ts b/packages/core/src/agents/schema-utils.ts deleted file mode 100644 index 13516ef4c8..0000000000 --- a/packages/core/src/agents/schema-utils.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { InputConfig } from './types.js'; - -/** - * Defines the structure for a JSON Schema object, used for tool function - * declarations. - */ -interface JsonSchemaObject { - type: 'object'; - properties: Record; - required?: string[]; -} - -/** - * Defines the structure for a property within a {@link JsonSchemaObject}. - */ -interface JsonSchemaProperty { - type: 'string' | 'number' | 'integer' | 'boolean' | 'array'; - description: string; - items?: { type: 'string' | 'number' }; -} - -/** - * Converts an internal `InputConfig` definition into a standard JSON Schema - * object suitable for a tool's `FunctionDeclaration`. - * - * This utility ensures that the configuration for a subagent's inputs is - * correctly translated into the format expected by the generative model. - * - * @param inputConfig The internal `InputConfig` to convert. - * @returns A JSON Schema object representing the inputs. - * @throws An `Error` if an unsupported input type is encountered, ensuring - * configuration errors are caught early. - */ -export function convertInputConfigToJsonSchema( - inputConfig: InputConfig, -): JsonSchemaObject { - const properties: Record = {}; - const required: string[] = []; - - for (const [name, definition] of Object.entries(inputConfig.inputs)) { - const schemaProperty: Partial = { - description: definition.description, - }; - - switch (definition.type) { - case 'string': - case 'number': - case 'integer': - case 'boolean': - schemaProperty.type = definition.type; - break; - - case 'string[]': - schemaProperty.type = 'array'; - schemaProperty.items = { type: 'string' }; - break; - - case 'number[]': - schemaProperty.type = 'array'; - schemaProperty.items = { type: 'number' }; - break; - - default: { - const exhaustiveCheck: never = definition.type; - throw new Error( - `Unsupported input type '${exhaustiveCheck}' for parameter '${name}'. ` + - 'Supported types: string, number, integer, boolean, string[], number[]', - ); - } - } - - properties[name] = schemaProperty as JsonSchemaProperty; - - if (definition.required) { - required.push(name); - } - } - - return { - type: 'object', - properties, - required: required.length > 0 ? required : undefined, - }; -} diff --git a/packages/core/src/agents/subagent-tool-wrapper.test.ts b/packages/core/src/agents/subagent-tool-wrapper.test.ts index c45b085991..c4f3d178c9 100644 --- a/packages/core/src/agents/subagent-tool-wrapper.test.ts +++ b/packages/core/src/agents/subagent-tool-wrapper.test.ts @@ -7,7 +7,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SubagentToolWrapper } from './subagent-tool-wrapper.js'; import { LocalSubagentInvocation } from './local-invocation.js'; -import { convertInputConfigToJsonSchema } from './schema-utils.js'; import { makeFakeConfig } from '../test-utils/config.js'; import type { LocalAgentDefinition, AgentInputs } from './types.js'; import type { Config } from '../config/config.js'; @@ -17,12 +16,8 @@ import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; // Mock dependencies to isolate the SubagentToolWrapper class vi.mock('./local-invocation.js'); -vi.mock('./schema-utils.js'); const MockedLocalSubagentInvocation = vi.mocked(LocalSubagentInvocation); -const mockConvertInputConfigToJsonSchema = vi.mocked( - convertInputConfigToJsonSchema, -); // Define reusable test data let mockConfig: Config; @@ -34,13 +29,16 @@ const mockDefinition: LocalAgentDefinition = { displayName: 'Test Agent Display Name', description: 'An agent for testing.', inputConfig: { - inputs: { - goal: { type: 'string', required: true, description: 'The goal.' }, - priority: { - type: 'number', - required: false, - description: 'The priority.', + inputSchema: { + type: 'object', + properties: { + goal: { type: 'string', description: 'The goal.' }, + priority: { + type: 'number', + description: 'The priority.', + }, }, + required: ['goal'], }, }, modelConfig: { @@ -54,34 +52,14 @@ const mockDefinition: LocalAgentDefinition = { promptConfig: { systemPrompt: 'You are a test agent.' }, }; -const mockSchema = { - type: 'object', - properties: { - goal: { type: 'string', description: 'The goal.' }, - priority: { type: 'number', description: 'The priority.' }, - }, - required: ['goal'], -}; - describe('SubagentToolWrapper', () => { beforeEach(() => { vi.clearAllMocks(); mockConfig = makeFakeConfig(); mockMessageBus = createMockMessageBus(); - // Provide a mock implementation for the schema conversion utility - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockConvertInputConfigToJsonSchema.mockReturnValue(mockSchema as any); }); describe('constructor', () => { - it('should call convertInputConfigToJsonSchema with the correct agent inputConfig', () => { - new SubagentToolWrapper(mockDefinition, mockConfig, mockMessageBus); - - expect(convertInputConfigToJsonSchema).toHaveBeenCalledExactlyOnceWith( - mockDefinition.inputConfig, - ); - }); - it('should correctly configure the tool properties from the agent definition', () => { const wrapper = new SubagentToolWrapper( mockDefinition, @@ -120,7 +98,9 @@ describe('SubagentToolWrapper', () => { expect(schema.name).toBe(mockDefinition.name); expect(schema.description).toBe(mockDefinition.description); - expect(schema.parametersJsonSchema).toEqual(mockSchema); + expect(schema.parametersJsonSchema).toEqual( + mockDefinition.inputConfig.inputSchema, + ); }); }); diff --git a/packages/core/src/agents/subagent-tool-wrapper.ts b/packages/core/src/agents/subagent-tool-wrapper.ts index ccb0627b0b..d068973a67 100644 --- a/packages/core/src/agents/subagent-tool-wrapper.ts +++ b/packages/core/src/agents/subagent-tool-wrapper.ts @@ -12,7 +12,6 @@ import { } from '../tools/tools.js'; import type { Config } from '../config/config.js'; import type { AgentDefinition, AgentInputs } from './types.js'; -import { convertInputConfigToJsonSchema } from './schema-utils.js'; import { LocalSubagentInvocation } from './local-invocation.js'; import { RemoteAgentInvocation } from './remote-invocation.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; @@ -40,16 +39,12 @@ export class SubagentToolWrapper extends BaseDeclarativeTool< private readonly config: Config, messageBus: MessageBus, ) { - const parameterSchema = convertInputConfigToJsonSchema( - definition.inputConfig, - ); - super( definition.name, definition.displayName ?? definition.name, definition.description, Kind.Think, - parameterSchema, + definition.inputConfig.inputSchema, messageBus, /* isOutputMarkdown */ true, /* canUpdateOutput */ true, diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index a1811c0f15..f58b6fa0ae 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -12,6 +12,7 @@ import type { Content, FunctionDeclaration } from '@google/genai'; import type { AnyDeclarativeTool } from '../tools/tools.js'; import { type z } from 'zod'; import type { ModelConfig } from '../services/modelConfigService.js'; +import type { AnySchema } from 'ajv'; /** * Describes the possible termination modes for an agent. @@ -33,6 +34,11 @@ export interface OutputObject { terminate_reason: AgentTerminateMode; } +/** + * The default query string provided to an agent as input. + */ +export const DEFAULT_QUERY_STRING = 'Get Started!'; + /** * Represents the validated input parameters passed to an agent upon invocation. * Used primarily for templating the system prompt. (Replaces ContextState) @@ -137,24 +143,7 @@ export interface ToolConfig { * Configures the expected inputs (parameters) for the agent. */ export interface InputConfig { - /** - * Defines the parameters the agent accepts. - * This is vital for generating the tool wrapper schema. - */ - inputs: Record< - string, - { - description: string; - type: - | 'string' - | 'number' - | 'boolean' - | 'integer' - | 'string[]' - | 'number[]'; - required: boolean; - } - >; + inputSchema: AnySchema; } /** diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index a97b67b20b..ec3621aed9 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import AjvPkg from 'ajv'; +import AjvPkg, { type AnySchema } from 'ajv'; import * as addFormats from 'ajv-formats'; // Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -47,4 +47,16 @@ export class SchemaValidator { } return null; } + + /** + * Validates a JSON schema itself. Returns null if the schema is valid, + * otherwise returns a string describing the validation errors. + */ + static validateSchema(schema: AnySchema | undefined): string | null { + if (!schema) { + return null; + } + const isValid = ajValidator.validateSchema(schema); + return isValid ? null : ajValidator.errorsText(ajValidator.errors); + } }