From 8b4d02702ae146a0f22b8301f9fb204eb1911a7f Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Mon, 22 Dec 2025 14:18:24 -0500 Subject: [PATCH 1/3] feat: add remote agents to toml parser --- packages/core/src/agents/registry.test.ts | 34 ++++++ packages/core/src/agents/registry.ts | 6 +- packages/core/src/agents/toml-loader.test.ts | 43 +++++++- packages/core/src/agents/toml-loader.ts | 103 ++++++++++++++----- 4 files changed, 158 insertions(+), 28 deletions(-) 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..0de5dcf54e4 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -203,7 +203,11 @@ export class AgentRegistry { } // Register configured remote A2A agents. - // TODO: Implement remote agent registration. + 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..be21b851018 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,46 @@ 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 parse multiple agents in one file', async () => { + const filePath = await writeAgentToml(` + [[agents]] + name = "agent-1" + description = "Local 1" + [agents.prompts] + system_prompt = "Prompt 1" + + [[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[1].name).toBe('agent-2'); + expect(result[1].kind).toBe('remote'); + }); + it('should throw AgentLoadError if file reading fails', async () => { const filePath = path.join(tempDir, 'non-existent.toml'); await expect(parseAgentToml(filePath)).rejects.toThrow(AgentLoadError); diff --git a/packages/core/src/agents/toml-loader.ts b/packages/core/src/agents/toml-loader.ts index 204503471ba..9ed73e41ec8 100644 --- a/packages/core/src/agents/toml-loader.ts +++ b/packages/core/src/agents/toml-loader.ts @@ -18,10 +18,15 @@ import { /** * DTO for TOML parsing - represents the raw structure of the TOML file. */ -interface TomlAgentDefinition { +interface TomlBaseAgentDefinition { name: string; description: string; display_name?: string; + kind?: 'local' | 'remote'; +} + +interface TomlLocalAgentDefinition extends TomlBaseAgentDefinition { + kind?: 'local'; tools?: string[]; prompts: { system_prompt: string; @@ -37,6 +42,13 @@ interface TomlAgentDefinition { }; } +interface TomlRemoteAgentDefinition extends TomlBaseAgentDefinition { + kind: 'remote'; + agent_card_url: string; +} + +type TomlAgentDefinition = TomlLocalAgentDefinition | TomlRemoteAgentDefinition; + /** * Error thrown when an agent definition is invalid or cannot be loaded. */ @@ -58,8 +70,13 @@ export interface AgentLoadResult { errors: AgentLoadError[]; } -const tomlSchema = z.object({ - name: z.string().regex(/^[a-z0-9-_]+$/, 'Name must be a valid slug'), +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 @@ -87,16 +104,33 @@ const tomlSchema = z.object({ .optional(), }); +const remoteAgentSchema = z.object({ + kind: z.literal('remote'), + name: nameSchema, + description: z.string().min(1), + display_name: z.string().optional(), + agent_card_url: z.string().url(), +}); + +const agentSchema = z.union([remoteAgentSchema, localAgentSchema]); + +const tomlSchema = z.union([ + agentSchema, + z.object({ + agents: z.array(agentSchema), + }), +]); + /** * Parses and validates an agent TOML file. * * @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'); @@ -125,17 +159,20 @@ export async function parseAgentToml( throw new AgentLoadError(filePath, `Validation failed: ${issues}`); } - const definition = result.data as TomlAgentDefinition; + const data = result.data; + const tomls = 'agents' in data ? data.agents : [data]; - // Prevent sub-agents from delegating to other agents (to prevent recursion/complexity) - if (definition.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.`, - ); + for (const toml of tomls) { + // Prevent sub-agents from delegating to other agents (to prevent recursion/complexity) + 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 tomls as TomlAgentDefinition[]; } /** @@ -147,6 +184,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, + 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 +231,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 +279,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); From e66daa117f19c00fa8eae737b94860ff6cd7f191 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Mon, 22 Dec 2025 14:40:37 -0500 Subject: [PATCH 2/3] make toml agent vs agents strict --- packages/core/src/agents/toml-loader.test.ts | 20 +++++ packages/core/src/agents/toml-loader.ts | 84 +++++++++++--------- 2 files changed, 65 insertions(+), 39 deletions(-) diff --git a/packages/core/src/agents/toml-loader.test.ts b/packages/core/src/agents/toml-loader.test.ts index be21b851018..debcabff85e 100644 --- a/packages/core/src/agents/toml-loader.test.ts +++ b/packages/core/src/agents/toml-loader.test.ts @@ -156,6 +156,26 @@ describe('toml-loader', () => { /Validation failed: 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 = "..." + + [[agents]] + name = "array-agent" + description = "I am in an array" + [agents.prompts] + system_prompt = "..." + `); + + // Zod union errors can be verbose, but it should definitely fail validation + await expect(parseAgentToml(filePath)).rejects.toThrow( + /Validation failed/, + ); + }); }); describe('tomlToAgentDefinition', () => { diff --git a/packages/core/src/agents/toml-loader.ts b/packages/core/src/agents/toml-loader.ts index 9ed73e41ec8..d2451b5c9f2 100644 --- a/packages/core/src/agents/toml-loader.ts +++ b/packages/core/src/agents/toml-loader.ts @@ -74,51 +74,57 @@ 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(), -}); +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'), - name: nameSchema, - description: z.string().min(1), - display_name: z.string().optional(), - agent_card_url: z.string().url(), -}); +const remoteAgentSchema = z + .object({ + kind: z.literal('remote'), + name: nameSchema, + description: z.string().min(1), + display_name: z.string().optional(), + agent_card_url: z.string().url(), + }) + .strict(); const agentSchema = z.union([remoteAgentSchema, localAgentSchema]); const tomlSchema = z.union([ agentSchema, - z.object({ - agents: z.array(agentSchema), - }), + z + .object({ + agents: z.array(agentSchema), + }) + .strict(), ]); /** From b4c6a61d4cc55d32e78d815fde94ad57a30084ae Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Mon, 22 Dec 2025 15:06:04 -0500 Subject: [PATCH 3/3] move kind --- packages/core/src/agents/registry.ts | 2 +- packages/core/src/agents/toml-loader.test.ts | 112 ++++++++++++++++--- packages/core/src/agents/toml-loader.ts | 108 +++++++++++++----- 3 files changed, 172 insertions(+), 50 deletions(-) diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 0de5dcf54e4..72aac950771 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -202,7 +202,7 @@ export class AgentRegistry { ); } - // Register configured remote A2A agents. + // 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 debcabff85e..5e91887cd8c 100644 --- a/packages/core/src/agents/toml-loader.test.ts +++ b/packages/core/src/agents/toml-loader.test.ts @@ -74,15 +74,53 @@ describe('toml-loader', () => { }); }); + 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(` - [[agents]] + [[remote_agents]] + kind = "remote" name = "agent-1" - description = "Local 1" - [agents.prompts] - system_prompt = "Prompt 1" + description = "Remote 1" + agent_card_url = "https://example.com/1" - [[agents]] + [[remote_agents]] kind = "remote" name = "agent-2" description = "Remote 2" @@ -92,10 +130,36 @@ describe('toml-loader', () => { 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); @@ -153,34 +217,44 @@ 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 = "..." - - [[agents]] - name = "array-agent" - description = "I am in an array" - [agents.prompts] - system_prompt = "..." - `); + 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" + `); - // Zod union errors can be verbose, but it should definitely fail validation 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/, + ); + }); }); 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: { @@ -215,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: { @@ -231,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 d2451b5c9f2..28ab2207d69 100644 --- a/packages/core/src/agents/toml-loader.ts +++ b/packages/core/src/agents/toml-loader.ts @@ -20,13 +20,12 @@ import { */ interface TomlBaseAgentDefinition { name: string; - description: string; display_name?: string; - kind?: 'local' | 'remote'; } interface TomlLocalAgentDefinition extends TomlBaseAgentDefinition { - kind?: 'local'; + kind: 'local'; + description: string; tools?: string[]; prompts: { system_prompt: string; @@ -43,6 +42,7 @@ interface TomlLocalAgentDefinition extends TomlBaseAgentDefinition { } interface TomlRemoteAgentDefinition extends TomlBaseAgentDefinition { + description?: string; kind: 'remote'; agent_card_url: string; } @@ -108,27 +108,58 @@ const localAgentSchema = z const remoteAgentSchema = z .object({ - kind: z.literal('remote'), + kind: z.literal('remote').optional().default('remote'), name: nameSchema, - description: z.string().min(1), + description: z.string().optional(), display_name: z.string().optional(), agent_card_url: z.string().url(), }) .strict(); -const agentSchema = z.union([remoteAgentSchema, localAgentSchema]); +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 tomlSchema = z.union([ - agentSchema, - z - .object({ - agents: z.array(agentSchema), - }) - .strict(), +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}`; + }) + .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 An array of parsed and validated TomlAgentDefinitions. @@ -157,28 +188,43 @@ export async function parseAgentToml( ); } - const result = tomlSchema.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}`); - } - - const data = result.data; - const tomls = 'agents' in data ? data.agents : [data]; - - for (const toml of tomls) { - // Prevent sub-agents from delegating to other agents (to prevent recursion/complexity) - if ('tools' in toml && toml.tools?.includes(DELEGATE_TO_AGENT_TOOL_NAME)) { + // 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: tools list cannot include '${DELEGATE_TO_AGENT_TOOL_NAME}'. Sub-agents cannot delegate to other agents.`, + `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) { + throw new AgentLoadError( + filePath, + `Validation failed: ${formatZodError(result.error, 'Agent Definition')}`, + ); + } + + const toml = result.data as TomlAgentDefinition; + + // Prevent sub-agents from delegating to other agents (to prevent recursion/complexity) + 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 tomls as TomlAgentDefinition[]; + return [toml]; } /** @@ -204,7 +250,7 @@ export function tomlToAgentDefinition( return { kind: 'remote', name: toml.name, - description: toml.description, + description: toml.description || '(Loading description...)', displayName: toml.display_name, agentCardUrl: toml.agent_card_url, inputConfig,