diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index 3719d132e9e..097253db65c 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -257,6 +257,40 @@ describe('AgentRegistry', () => { }); }); + it('should register a remote agent definition', () => { + const remoteAgent: AgentDefinition = { + kind: 'remote', + name: 'RemoteAgent', + description: 'A remote agent', + agentCardUrl: 'https://example.com/card', + inputConfig: { inputs: {} }, + }; + registry.testRegisterAgent(remoteAgent); + expect(registry.getDefinition('RemoteAgent')).toEqual(remoteAgent); + }); + + it('should log remote agent registration in debug mode', () => { + const debugConfig = makeFakeConfig({ debugMode: true }); + const debugRegistry = new TestableAgentRegistry(debugConfig); + const debugLogSpy = vi + .spyOn(debugLogger, 'log') + .mockImplementation(() => {}); + + const remoteAgent: AgentDefinition = { + kind: 'remote', + name: 'RemoteAgent', + description: 'A remote agent', + agentCardUrl: 'https://example.com/card', + inputConfig: { inputs: {} }, + }; + + debugRegistry.testRegisterAgent(remoteAgent); + + expect(debugLogSpy).toHaveBeenCalledWith( + `[AgentRegistry] Registered remote agent 'RemoteAgent' with card: https://example.com/card`, + ); + }); + it('should handle special characters in agent names', () => { const specialAgent = { ...MOCK_AGENT_V1, diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index bdbdb216d4c..72aac950771 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -202,8 +202,12 @@ export class AgentRegistry { ); } - // Register configured remote A2A agents. - // TODO: Implement remote agent registration. + // Log remote A2A agent registration for visibility. + if (definition.kind === 'remote' && this.config.getDebugMode()) { + debugLogger.log( + `[AgentRegistry] Registered remote agent '${definition.name}' with card: ${definition.agentCardUrl}`, + ); + } } /** diff --git a/packages/core/src/agents/toml-loader.test.ts b/packages/core/src/agents/toml-loader.test.ts index 68f130a611c..5e91887cd8c 100644 --- a/packages/core/src/agents/toml-loader.test.ts +++ b/packages/core/src/agents/toml-loader.test.ts @@ -46,7 +46,8 @@ describe('toml-loader', () => { `); const result = await parseAgentToml(filePath); - expect(result).toEqual({ + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ name: 'test-agent', description: 'A test agent', prompts: { @@ -55,6 +56,110 @@ describe('toml-loader', () => { }); }); + it('should parse a valid remote agent TOML file', async () => { + const filePath = await writeAgentToml(` + kind = "remote" + name = "remote-agent" + description = "A remote agent" + agent_card_url = "https://example.com/card" + `); + + const result = await parseAgentToml(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: 'remote', + name: 'remote-agent', + description: 'A remote agent', + agent_card_url: 'https://example.com/card', + }); + }); + + it('should infer remote agent kind from agent_card_url', async () => { + const filePath = await writeAgentToml(` + name = "inferred-remote" + description = "Inferred" + agent_card_url = "https://example.com/inferred" + `); + + const result = await parseAgentToml(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: 'remote', + name: 'inferred-remote', + description: 'Inferred', + agent_card_url: 'https://example.com/inferred', + }); + }); + + it('should parse a remote agent without description', async () => { + const filePath = await writeAgentToml(` + kind = "remote" + name = "no-description-remote" + agent_card_url = "https://example.com/card" + `); + + const result = await parseAgentToml(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: 'remote', + name: 'no-description-remote', + agent_card_url: 'https://example.com/card', + }); + expect(result[0].description).toBeUndefined(); + + // defined after conversion to AgentDefinition + const agentDef = tomlToAgentDefinition(result[0]); + expect(agentDef.description).toBe('(Loading description...)'); + }); + + it('should parse multiple agents in one file', async () => { + const filePath = await writeAgentToml(` + [[remote_agents]] + kind = "remote" + name = "agent-1" + description = "Remote 1" + agent_card_url = "https://example.com/1" + + [[remote_agents]] + kind = "remote" + name = "agent-2" + description = "Remote 2" + agent_card_url = "https://example.com/2" + `); + + const result = await parseAgentToml(filePath); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('agent-1'); + expect(result[0].kind).toBe('remote'); + expect(result[1].name).toBe('agent-2'); + expect(result[1].kind).toBe('remote'); + }); + + it('should allow omitting kind in remote_agents block', async () => { + const filePath = await writeAgentToml(` + [[remote_agents]] + name = "implicit-remote-1" + agent_card_url = "https://example.com/1" + + [[remote_agents]] + name = "implicit-remote-2" + agent_card_url = "https://example.com/2" + `); + + const result = await parseAgentToml(filePath); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'implicit-remote-1', + agent_card_url: 'https://example.com/1', + }); + expect(result[1]).toMatchObject({ + kind: 'remote', + name: 'implicit-remote-2', + agent_card_url: 'https://example.com/2', + }); + }); + it('should throw AgentLoadError if file reading fails', async () => { const filePath = path.join(tempDir, 'non-existent.toml'); await expect(parseAgentToml(filePath)).rejects.toThrow(AgentLoadError); @@ -112,7 +217,36 @@ describe('toml-loader', () => { system_prompt = "You are a test agent." `); await expect(parseAgentToml(filePath)).rejects.toThrow( - /Validation failed: tools.0: Invalid tool name/, + /Validation failed:[\s\S]*tools.0: Invalid tool name/, + ); + }); + + it('should throw AgentLoadError if file contains both single and multiple agents', async () => { + const filePath = await writeAgentToml(` + name = "top-level-agent" + description = "I should not be here" + [prompts] + system_prompt = "..." + + [[remote_agents]] + kind = "remote" + name = "array-agent" + description = "I am in an array" + agent_card_url = "https://example.com/card" + `); + + await expect(parseAgentToml(filePath)).rejects.toThrow( + /Validation failed/, + ); + }); + + it('should show both options in error message when validation fails ambiguously', async () => { + const filePath = await writeAgentToml(` + name = "ambiguous-agent" + description = "I have neither prompts nor card" + `); + await expect(parseAgentToml(filePath)).rejects.toThrow( + /Validation failed: Agent Definition:\n\(Local Agent\) prompts: Required\n\(Remote Agent\) agent_card_url: Required/, ); }); }); @@ -120,6 +254,7 @@ describe('toml-loader', () => { describe('tomlToAgentDefinition', () => { it('should convert valid TOML to AgentDefinition with defaults', () => { const toml = { + kind: 'local' as const, name: 'test-agent', description: 'A test agent', prompts: { @@ -154,6 +289,7 @@ describe('toml-loader', () => { it('should pass through model aliases', () => { const toml = { + kind: 'local' as const, name: 'test-agent', description: 'A test agent', model: { @@ -170,6 +306,7 @@ describe('toml-loader', () => { it('should pass through unknown model names (e.g. auto)', () => { const toml = { + kind: 'local' as const, name: 'test-agent', description: 'A test agent', model: { diff --git a/packages/core/src/agents/toml-loader.ts b/packages/core/src/agents/toml-loader.ts index 204503471ba..28ab2207d69 100644 --- a/packages/core/src/agents/toml-loader.ts +++ b/packages/core/src/agents/toml-loader.ts @@ -18,10 +18,14 @@ import { /** * DTO for TOML parsing - represents the raw structure of the TOML file. */ -interface TomlAgentDefinition { +interface TomlBaseAgentDefinition { name: string; - description: string; display_name?: string; +} + +interface TomlLocalAgentDefinition extends TomlBaseAgentDefinition { + kind: 'local'; + description: string; tools?: string[]; prompts: { system_prompt: string; @@ -37,6 +41,14 @@ interface TomlAgentDefinition { }; } +interface TomlRemoteAgentDefinition extends TomlBaseAgentDefinition { + description?: string; + kind: 'remote'; + agent_card_url: string; +} + +type TomlAgentDefinition = TomlLocalAgentDefinition | TomlRemoteAgentDefinition; + /** * Error thrown when an agent definition is invalid or cannot be loaded. */ @@ -58,45 +70,104 @@ export interface AgentLoadResult { errors: AgentLoadError[]; } -const tomlSchema = z.object({ - name: z.string().regex(/^[a-z0-9-_]+$/, 'Name must be a valid slug'), - description: z.string().min(1), - display_name: z.string().optional(), - tools: z - .array( - z.string().refine((val) => isValidToolName(val), { - message: 'Invalid tool name', - }), - ) - .optional(), - prompts: z.object({ - system_prompt: z.string().min(1), - query: z.string().optional(), - }), - model: z - .object({ - model: z.string().optional(), - temperature: z.number().optional(), - }) - .optional(), - run: z - .object({ - max_turns: z.number().int().positive().optional(), - timeout_mins: z.number().int().positive().optional(), +const nameSchema = z + .string() + .regex(/^[a-z0-9-_]+$/, 'Name must be a valid slug'); + +const localAgentSchema = z + .object({ + kind: z.literal('local').optional().default('local'), + name: nameSchema, + description: z.string().min(1), + display_name: z.string().optional(), + tools: z + .array( + z.string().refine((val) => isValidToolName(val), { + message: 'Invalid tool name', + }), + ) + .optional(), + prompts: z.object({ + system_prompt: z.string().min(1), + query: z.string().optional(), + }), + model: z + .object({ + model: z.string().optional(), + temperature: z.number().optional(), + }) + .optional(), + run: z + .object({ + max_turns: z.number().int().positive().optional(), + timeout_mins: z.number().int().positive().optional(), + }) + .optional(), + }) + .strict(); + +const remoteAgentSchema = z + .object({ + kind: z.literal('remote').optional().default('remote'), + name: nameSchema, + description: z.string().optional(), + display_name: z.string().optional(), + agent_card_url: z.string().url(), + }) + .strict(); + +const remoteAgentsConfigSchema = z + .object({ + remote_agents: z.array(remoteAgentSchema), + }) + .strict(); + +// Use a Zod union to automatically discriminate between local and remote +// agent types. This is more robust than manually checking the 'kind' field, +// as it correctly handles cases where 'kind' is omitted by relying on +// the presence of unique fields like `agent_card_url` or `prompts`. +const agentUnionOptions = [ + { schema: localAgentSchema, label: 'Local Agent' }, + { schema: remoteAgentSchema, label: 'Remote Agent' }, +] as const; + +const singleAgentSchema = z.union([ + agentUnionOptions[0].schema, + agentUnionOptions[1].schema, +]); + +function formatZodError(error: z.ZodError, context: string): string { + const issues = error.issues + .map((i) => { + // Handle union errors specifically to give better context + if (i.code === z.ZodIssueCode.invalid_union) { + return i.unionErrors + .map((unionError, index) => { + const label = + agentUnionOptions[index]?.label ?? `Agent type #${index + 1}`; + const unionIssues = unionError.issues + .map((u) => `${u.path.join('.')}: ${u.message}`) + .join(', '); + return `(${label}) ${unionIssues}`; + }) + .join('\n'); + } + return `${i.path.join('.')}: ${i.message}`; }) - .optional(), -}); + .join('\n'); + return `${context}:\n${issues}`; +} /** - * Parses and validates an agent TOML file. + * Parses and validates an agent TOML file. Returns a validated array of RemoteAgentDefinitions or a single LocalAgentDefinition. * * @param filePath Path to the TOML file. - * @returns The parsed and validated TomlAgentDefinition. + * @returns An array of parsed and validated TomlAgentDefinitions. * @throws AgentLoadError if parsing or validation fails. */ export async function parseAgentToml( filePath: string, -): Promise { +): Promise { let content: string; try { content = await fs.readFile(filePath, 'utf-8'); @@ -117,25 +188,43 @@ export async function parseAgentToml( ); } - const result = tomlSchema.safeParse(raw); + // Check for `remote_agents` array + if ( + typeof raw === 'object' && + raw !== null && + 'remote_agents' in (raw as Record) + ) { + const result = remoteAgentsConfigSchema.safeParse(raw); + if (!result.success) { + throw new AgentLoadError( + filePath, + `Validation failed: ${formatZodError(result.error, 'Remote Agents Config')}`, + ); + } + return result.data.remote_agents as TomlAgentDefinition[]; + } + + // Single Agent Logic + const result = singleAgentSchema.safeParse(raw); + if (!result.success) { - const issues = result.error.issues - .map((i) => `${i.path.join('.')}: ${i.message}`) - .join(', '); - throw new AgentLoadError(filePath, `Validation failed: ${issues}`); + throw new AgentLoadError( + filePath, + `Validation failed: ${formatZodError(result.error, 'Agent Definition')}`, + ); } - const definition = result.data as TomlAgentDefinition; + const toml = result.data as TomlAgentDefinition; // Prevent sub-agents from delegating to other agents (to prevent recursion/complexity) - if (definition.tools?.includes(DELEGATE_TO_AGENT_TOOL_NAME)) { + if ('tools' in toml && toml.tools?.includes(DELEGATE_TO_AGENT_TOOL_NAME)) { throw new AgentLoadError( filePath, `Validation failed: tools list cannot include '${DELEGATE_TO_AGENT_TOOL_NAME}'. Sub-agents cannot delegate to other agents.`, ); } - return definition; + return [toml]; } /** @@ -147,6 +236,27 @@ export async function parseAgentToml( export function tomlToAgentDefinition( toml: TomlAgentDefinition, ): AgentDefinition { + const inputConfig = { + inputs: { + query: { + type: 'string' as const, + description: 'The task for the agent.', + required: false, + }, + }, + }; + + if (toml.kind === 'remote') { + return { + kind: 'remote', + name: toml.name, + description: toml.description || '(Loading description...)', + displayName: toml.display_name, + agentCardUrl: toml.agent_card_url, + inputConfig, + }; + } + // If a model is specified, use it. Otherwise, inherit const modelName = toml.model?.model || 'inherit'; @@ -173,16 +283,7 @@ export function tomlToAgentDefinition( tools: toml.tools, } : undefined, - // Default input config for MVA - inputConfig: { - inputs: { - query: { - type: 'string', - description: 'The task for the agent.', - required: false, - }, - }, - }, + inputConfig, }; } @@ -230,9 +331,11 @@ export async function loadAgentsFromDirectory( for (const file of files) { const filePath = path.join(dir, file); try { - const toml = await parseAgentToml(filePath); - const agent = tomlToAgentDefinition(toml); - result.agents.push(agent); + const tomls = await parseAgentToml(filePath); + for (const toml of tomls) { + const agent = tomlToAgentDefinition(toml); + result.agents.push(agent); + } } catch (error) { if (error instanceof AgentLoadError) { result.errors.push(error);