feat(core): introduce remote agent infrastructure and rename local executor (#15110)

This commit is contained in:
Adam Weidman
2025-12-17 12:06:38 -05:00
committed by GitHub
parent da85aed5aa
commit d02f3f6809
15 changed files with 295 additions and 151 deletions

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { AgentDefinition } from './types.js';
import type { LocalAgentDefinition } from './types.js';
import {
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
@@ -41,18 +41,19 @@ const CodebaseInvestigationReportSchema = z.object({
* A Proof-of-Concept subagent specialized in analyzing codebase structure,
* dependencies, and technologies.
*/
export const CodebaseInvestigatorAgent: AgentDefinition<
export const CodebaseInvestigatorAgent: LocalAgentDefinition<
typeof CodebaseInvestigationReportSchema
> = {
name: 'codebase_investigator',
kind: 'local',
displayName: 'Codebase Investigator Agent',
description: `The specialized tool for codebase analysis, architectural mapping, and understanding system-wide dependencies.
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.
description: `The specialized tool for codebase analysis, architectural mapping, and understanding system-wide dependencies.
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.
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,

View File

@@ -9,13 +9,13 @@ 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 { LocalSubagentInvocation } from './local-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(() => ({
vi.mock('./local-invocation.js', () => ({
LocalSubagentInvocation: vi.fn().mockImplementation(() => ({
execute: vi
.fn()
.mockResolvedValue({ content: [{ type: 'text', text: 'Success' }] }),
@@ -29,6 +29,7 @@ describe('DelegateToAgentTool', () => {
let messageBus: MessageBus;
const mockAgentDef: AgentDefinition = {
kind: 'local',
name: 'test_agent',
description: 'A test agent',
promptConfig: {},
@@ -93,10 +94,10 @@ describe('DelegateToAgentTool', () => {
const result = await invocation.execute(new AbortController().signal);
expect(result).toEqual({ content: [{ type: 'text', text: 'Success' }] });
expect(SubagentInvocation).toHaveBeenCalledWith(
{ arg1: 'valid' },
expect(LocalSubagentInvocation).toHaveBeenCalledWith(
mockAgentDef,
config,
{ arg1: 'valid' },
messageBus,
);
});

View File

@@ -18,7 +18,7 @@ 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 { SubagentToolWrapper } from './subagent-tool-wrapper.js';
import type { AgentInputs } from './types.js';
type DelegateParams = { agent_name: string } & Record<string, unknown>;
@@ -169,14 +169,18 @@ class DelegateInvocation extends BaseToolInvocation<
// 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,
// Delegate the creation of the specific invocation (Local or Remote) to the wrapper.
// This centralizes the logic and ensures consistent handling.
const wrapper = new SubagentToolWrapper(
definition,
this.config,
this.messageBus,
);
return subagentInvocation.execute(signal, updateOutput);
// We could skip extra validation here if we trust the Registry's schema,
// but build() will do a safety check anyway.
const invocation = wrapper.build(agentArgs as AgentInputs);
return invocation.execute(signal, updateOutput);
}
}

View File

@@ -14,7 +14,7 @@ import {
type Mock,
} from 'vitest';
import { debugLogger } from '../utils/debugLogger.js';
import { AgentExecutor, type ActivityCallback } from './executor.js';
import { LocalAgentExecutor, type ActivityCallback } from './local-executor.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import { LSTool } from '../tools/ls.js';
@@ -48,8 +48,8 @@ import {
RecoveryAttemptEvent,
} from '../telemetry/types.js';
import type {
AgentDefinition,
AgentInputs,
LocalAgentDefinition,
SubagentActivityEvent,
OutputConfig,
} from './types.js';
@@ -193,12 +193,15 @@ let parentToolRegistry: ToolRegistry;
/**
* Type-safe helper to create agent definitions for tests.
*/
const createTestDefinition = <TOutput extends z.ZodTypeAny>(
export const createTestDefinition = <
TOutput extends z.ZodTypeAny = z.ZodUnknown,
>(
tools: Array<string | MockTool> = [LS_TOOL_NAME],
runConfigOverrides: Partial<AgentDefinition<TOutput>['runConfig']> = {},
runConfigOverrides: Partial<LocalAgentDefinition<TOutput>['runConfig']> = {},
outputConfigMode: 'default' | 'none' = 'default',
schema: TOutput = z.string() as unknown as TOutput,
): AgentDefinition<TOutput> => {
): LocalAgentDefinition<TOutput> => {
let outputConfig: OutputConfig<TOutput> | undefined;
if (outputConfigMode === 'default') {
@@ -210,6 +213,7 @@ const createTestDefinition = <TOutput extends z.ZodTypeAny>(
}
return {
kind: 'local',
name: 'TestAgent',
description: 'An agent for testing.',
inputConfig: {
@@ -223,7 +227,7 @@ const createTestDefinition = <TOutput extends z.ZodTypeAny>(
};
};
describe('AgentExecutor', () => {
describe('LocalAgentExecutor', () => {
let activities: SubagentActivityEvent[];
let onActivity: ActivityCallback;
let abortController: AbortController;
@@ -289,18 +293,18 @@ describe('AgentExecutor', () => {
describe('create (Initialization and Validation)', () => {
it('should create successfully with allowed tools', async () => {
const definition = createTestDefinition([LS_TOOL_NAME]);
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
expect(executor).toBeInstanceOf(AgentExecutor);
expect(executor).toBeInstanceOf(LocalAgentExecutor);
});
it('SECURITY: should throw if a tool is not on the non-interactive allowlist', async () => {
const definition = createTestDefinition([MOCK_TOOL_NOT_ALLOWED.name]);
await expect(
AgentExecutor.create(definition, mockConfig, onActivity),
LocalAgentExecutor.create(definition, mockConfig, onActivity),
).rejects.toThrow(/not on the allow-list for non-interactive execution/);
});
@@ -309,7 +313,7 @@ describe('AgentExecutor', () => {
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
]);
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -330,7 +334,7 @@ describe('AgentExecutor', () => {
mockedPromptIdContext.getStore.mockReturnValue(parentId);
const definition = createTestDefinition();
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -361,7 +365,7 @@ describe('AgentExecutor', () => {
},
]);
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -390,7 +394,7 @@ describe('AgentExecutor', () => {
definition.inputConfig.inputs = {
goal: { type: 'string', required: true, description: 'goal' },
};
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -413,7 +417,7 @@ describe('AgentExecutor', () => {
it('should execute successfully when model calls complete_task with output (Happy Path with Output)', async () => {
const definition = createTestDefinition();
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -562,7 +566,7 @@ describe('AgentExecutor', () => {
it('should execute successfully when model calls complete_task without output (Happy Path No Output)', async () => {
const definition = createTestDefinition([LS_TOOL_NAME], {}, 'none');
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -629,7 +633,7 @@ describe('AgentExecutor', () => {
it('should error immediately if the model stops tools without calling complete_task (Protocol Violation)', async () => {
const definition = createTestDefinition();
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -705,7 +709,7 @@ describe('AgentExecutor', () => {
it('should report an error if complete_task is called with missing required arguments', async () => {
const definition = createTestDefinition();
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -768,7 +772,7 @@ describe('AgentExecutor', () => {
it('should handle multiple calls to complete_task in the same turn (accept first, block rest)', async () => {
const definition = createTestDefinition([], {}, 'none');
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -803,7 +807,7 @@ describe('AgentExecutor', () => {
it('should execute parallel tool calls and then complete', async () => {
const definition = createTestDefinition([LS_TOOL_NAME]);
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -898,7 +902,7 @@ describe('AgentExecutor', () => {
it('SECURITY: should block unauthorized tools and provide explicit failure to model', async () => {
const definition = createTestDefinition([LS_TOOL_NAME]);
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -934,7 +938,7 @@ describe('AgentExecutor', () => {
// 2. Verify console warning
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(`[AgentExecutor] Blocked call:`),
expect.stringContaining(`[LocalAgentExecutor] Blocked call:`),
);
consoleWarnSpy.mockRestore();
@@ -975,7 +979,7 @@ describe('AgentExecutor', () => {
'default',
z.string().min(10), // The schema is for the output value itself
);
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -1044,7 +1048,7 @@ describe('AgentExecutor', () => {
});
// We expect the error to be thrown during the run, not creation
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -1075,7 +1079,7 @@ describe('AgentExecutor', () => {
it('should handle a failed tool call and feed the error to the model', async () => {
const definition = createTestDefinition([LS_TOOL_NAME]);
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -1196,7 +1200,7 @@ describe('AgentExecutor', () => {
const definition = createTestDefinition([LS_TOOL_NAME], {
max_turns: MAX,
});
const executor = await AgentExecutor.create(definition, mockConfig);
const executor = await LocalAgentExecutor.create(definition, mockConfig);
mockWorkResponse('t1');
mockWorkResponse('t2');
@@ -1213,7 +1217,7 @@ describe('AgentExecutor', () => {
const definition = createTestDefinition([LS_TOOL_NAME], {
max_time_minutes: 0.5, // 30 seconds
});
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -1270,7 +1274,7 @@ describe('AgentExecutor', () => {
const definition = createTestDefinition([LS_TOOL_NAME], {
max_time_minutes: 1,
});
const executor = await AgentExecutor.create(definition, mockConfig);
const executor = await LocalAgentExecutor.create(definition, mockConfig);
mockModelResponse([
{ name: LS_TOOL_NAME, args: { path: '.' }, id: 't1' },
@@ -1306,7 +1310,7 @@ describe('AgentExecutor', () => {
it('should terminate when AbortSignal is triggered', async () => {
const definition = createTestDefinition();
const executor = await AgentExecutor.create(definition, mockConfig);
const executor = await LocalAgentExecutor.create(definition, mockConfig);
mockSendMessageStream.mockImplementationOnce(async () =>
(async function* () {
@@ -1358,7 +1362,7 @@ describe('AgentExecutor', () => {
const definition = createTestDefinition([LS_TOOL_NAME], {
max_turns: MAX,
});
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -1406,7 +1410,7 @@ describe('AgentExecutor', () => {
const definition = createTestDefinition([LS_TOOL_NAME], {
max_turns: MAX,
});
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -1440,7 +1444,7 @@ describe('AgentExecutor', () => {
it('should recover successfully from a protocol violation (no complete_task)', async () => {
const definition = createTestDefinition();
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -1482,7 +1486,7 @@ describe('AgentExecutor', () => {
it('should fail recovery from a protocol violation if it violates again', async () => {
const definition = createTestDefinition();
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -1525,7 +1529,7 @@ describe('AgentExecutor', () => {
const definition = createTestDefinition([LS_TOOL_NAME], {
max_time_minutes: 0.5, // 30 seconds
});
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -1580,7 +1584,7 @@ describe('AgentExecutor', () => {
const definition = createTestDefinition([LS_TOOL_NAME], {
max_time_minutes: 0.5, // 30 seconds
});
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -1672,7 +1676,7 @@ describe('AgentExecutor', () => {
const definition = createTestDefinition([LS_TOOL_NAME], {
max_turns: MAX,
});
const executor = await AgentExecutor.create(definition, mockConfig);
const executor = await LocalAgentExecutor.create(definition, mockConfig);
// Turn 1 (hits max_turns)
mockWorkResponse('t1');
@@ -1697,7 +1701,7 @@ describe('AgentExecutor', () => {
const definition = createTestDefinition([LS_TOOL_NAME], {
max_turns: MAX,
});
const executor = await AgentExecutor.create(definition, mockConfig);
const executor = await LocalAgentExecutor.create(definition, mockConfig);
// Turn 1 (hits max_turns)
mockWorkResponse('t1');
@@ -1752,7 +1756,7 @@ describe('AgentExecutor', () => {
it('should attempt to compress chat history on each turn', async () => {
const definition = createTestDefinition();
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -1786,7 +1790,7 @@ describe('AgentExecutor', () => {
it('should update chat history when compression is successful', async () => {
const definition = createTestDefinition();
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -1821,7 +1825,7 @@ describe('AgentExecutor', () => {
it('should pass hasFailedCompressionAttempt=true to compression after a failure', async () => {
const definition = createTestDefinition();
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
@@ -1866,7 +1870,7 @@ describe('AgentExecutor', () => {
it('should reset hasFailedCompressionAttempt flag after a successful compression', async () => {
const definition = createTestDefinition();
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,

View File

@@ -41,7 +41,7 @@ import {
RecoveryAttemptEvent,
} from '../telemetry/types.js';
import type {
AgentDefinition,
LocalAgentDefinition,
AgentInputs,
OutputObject,
SubagentActivityEvent,
@@ -78,8 +78,8 @@ type AgentTurnResult =
* This executor runs the agent in a loop, calling tools until it calls the
* mandatory `complete_task` tool to signal completion.
*/
export class AgentExecutor<TOutput extends z.ZodTypeAny> {
readonly definition: AgentDefinition<TOutput>;
export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
readonly definition: LocalAgentDefinition<TOutput>;
private readonly agentId: string;
private readonly toolRegistry: ToolRegistry;
@@ -97,13 +97,13 @@ export class AgentExecutor<TOutput extends z.ZodTypeAny> {
* @param definition The definition object for the agent.
* @param runtimeContext The global runtime configuration.
* @param onActivity An optional callback to receive activity events.
* @returns A promise that resolves to a new `AgentExecutor` instance.
* @returns A promise that resolves to a new `LocalAgentExecutor` instance.
*/
static async create<TOutput extends z.ZodTypeAny>(
definition: AgentDefinition<TOutput>,
definition: LocalAgentDefinition<TOutput>,
runtimeContext: Config,
onActivity?: ActivityCallback,
): Promise<AgentExecutor<TOutput>> {
): Promise<LocalAgentExecutor<TOutput>> {
// Create an isolated tool registry for this agent instance.
const agentToolRegistry = new ToolRegistry(runtimeContext);
const parentToolRegistry = runtimeContext.getToolRegistry();
@@ -131,13 +131,16 @@ export class AgentExecutor<TOutput extends z.ZodTypeAny> {
agentToolRegistry.sortTools();
// Validate that all registered tools are safe for non-interactive
// execution.
await AgentExecutor.validateTools(agentToolRegistry, definition.name);
await LocalAgentExecutor.validateTools(
agentToolRegistry,
definition.name,
);
}
// Get the parent prompt ID from context
const parentPromptId = promptIdContext.getStore();
return new AgentExecutor(
return new LocalAgentExecutor(
definition,
runtimeContext,
agentToolRegistry,
@@ -153,7 +156,7 @@ export class AgentExecutor<TOutput extends z.ZodTypeAny> {
* instantiate the class.
*/
private constructor(
definition: AgentDefinition<TOutput>,
definition: LocalAgentDefinition<TOutput>,
runtimeContext: Config,
toolRegistry: ToolRegistry,
parentPromptId: string | undefined,
@@ -820,7 +823,7 @@ export class AgentExecutor<TOutput extends z.ZodTypeAny> {
if (!allowedToolNames.has(functionCall.name as string)) {
const error = `Unauthorized tool call: '${functionCall.name}' is not available to this agent.`;
debugLogger.warn(`[AgentExecutor] Blocked call: ${error}`);
debugLogger.warn(`[LocalAgentExecutor] Blocked call: ${error}`);
syncResponseParts.push({
functionResponse: {

View File

@@ -5,13 +5,10 @@
*/
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { SubagentInvocation } from './invocation.js';
import { AgentExecutor } from './executor.js';
import type {
AgentDefinition,
SubagentActivityEvent,
AgentInputs,
} from './types.js';
import type { LocalAgentDefinition } from './types.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { LocalAgentExecutor } from './local-executor.js';
import type { SubagentActivityEvent, AgentInputs } from './types.js';
import { AgentTerminateMode } from './types.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { ToolErrorType } from '../tools/tool-error.js';
@@ -19,13 +16,14 @@ import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { type z } from 'zod';
vi.mock('./executor.js');
vi.mock('./local-executor.js');
const MockAgentExecutor = vi.mocked(AgentExecutor);
const MockLocalAgentExecutor = vi.mocked(LocalAgentExecutor);
let mockConfig: Config;
const testDefinition: AgentDefinition<z.ZodUnknown> = {
const testDefinition: LocalAgentDefinition<z.ZodUnknown> = {
kind: 'local',
name: 'MockAgent',
description: 'A mock agent.',
inputConfig: {
@@ -39,8 +37,8 @@ const testDefinition: AgentDefinition<z.ZodUnknown> = {
promptConfig: { systemPrompt: 'test' },
};
describe('SubagentInvocation', () => {
let mockExecutorInstance: Mocked<AgentExecutor<z.ZodUnknown>>;
describe('LocalSubagentInvocation', () => {
let mockExecutorInstance: Mocked<LocalAgentExecutor<z.ZodUnknown>>;
beforeEach(() => {
vi.clearAllMocks();
@@ -49,20 +47,20 @@ describe('SubagentInvocation', () => {
mockExecutorInstance = {
run: vi.fn(),
definition: testDefinition,
} as unknown as Mocked<AgentExecutor<z.ZodUnknown>>;
} as unknown as Mocked<LocalAgentExecutor<z.ZodUnknown>>;
MockAgentExecutor.create.mockResolvedValue(
mockExecutorInstance as unknown as AgentExecutor<z.ZodTypeAny>,
MockLocalAgentExecutor.create.mockResolvedValue(
mockExecutorInstance as unknown as LocalAgentExecutor<z.ZodTypeAny>,
);
});
it('should pass the messageBus to the parent constructor', () => {
const mockMessageBus = {} as MessageBus;
const params = { task: 'Analyze data' };
const invocation = new SubagentInvocation<z.ZodUnknown>(
params,
const invocation = new LocalSubagentInvocation(
testDefinition,
mockConfig,
params,
mockMessageBus,
);
@@ -74,10 +72,10 @@ describe('SubagentInvocation', () => {
describe('getDescription', () => {
it('should format the description with inputs', () => {
const params = { task: 'Analyze data', priority: 5 };
const invocation = new SubagentInvocation<z.ZodUnknown>(
params,
const invocation = new LocalSubagentInvocation(
testDefinition,
mockConfig,
params,
);
const description = invocation.getDescription();
expect(description).toBe(
@@ -88,10 +86,10 @@ describe('SubagentInvocation', () => {
it('should truncate long input values', () => {
const longTask = 'A'.repeat(100);
const params = { task: longTask };
const invocation = new SubagentInvocation<z.ZodUnknown>(
params,
const invocation = new LocalSubagentInvocation(
testDefinition,
mockConfig,
params,
);
const description = invocation.getDescription();
// Default INPUT_PREVIEW_MAX_LENGTH is 50
@@ -102,7 +100,7 @@ describe('SubagentInvocation', () => {
it('should truncate the overall description if it exceeds the limit', () => {
// Create a definition and inputs that result in a very long description
const longNameDef = {
const longNameDef: LocalAgentDefinition = {
...testDefinition,
name: 'VeryLongAgentNameThatTakesUpSpace',
};
@@ -110,10 +108,10 @@ describe('SubagentInvocation', () => {
for (let i = 0; i < 20; i++) {
params[`input${i}`] = `value${i}`;
}
const invocation = new SubagentInvocation<z.ZodUnknown>(
params,
const invocation = new LocalSubagentInvocation(
longNameDef,
mockConfig,
params,
);
const description = invocation.getDescription();
// Default DESCRIPTION_MAX_LENGTH is 200
@@ -130,15 +128,15 @@ describe('SubagentInvocation', () => {
let signal: AbortSignal;
let updateOutput: ReturnType<typeof vi.fn>;
const params = { task: 'Execute task' };
let invocation: SubagentInvocation<z.ZodUnknown>;
let invocation: LocalSubagentInvocation;
beforeEach(() => {
signal = new AbortController().signal;
updateOutput = vi.fn();
invocation = new SubagentInvocation<z.ZodUnknown>(
params,
invocation = new LocalSubagentInvocation(
testDefinition,
mockConfig,
params,
);
});
@@ -151,7 +149,7 @@ describe('SubagentInvocation', () => {
const result = await invocation.execute(signal, updateOutput);
expect(MockAgentExecutor.create).toHaveBeenCalledWith(
expect(MockLocalAgentExecutor.create).toHaveBeenCalledWith(
testDefinition,
mockConfig,
expect.any(Function),
@@ -173,7 +171,7 @@ describe('SubagentInvocation', () => {
it('should stream THOUGHT_CHUNK activities from the executor', async () => {
mockExecutorInstance.run.mockImplementation(async () => {
const onActivity = MockAgentExecutor.create.mock.calls[0][2];
const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2];
if (onActivity) {
onActivity({
@@ -202,7 +200,7 @@ describe('SubagentInvocation', () => {
it('should NOT stream other activities (e.g., TOOL_CALL_START, ERROR)', async () => {
mockExecutorInstance.run.mockImplementation(async () => {
const onActivity = MockAgentExecutor.create.mock.calls[0][2];
const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2];
if (onActivity) {
onActivity({
@@ -230,7 +228,7 @@ describe('SubagentInvocation', () => {
it('should run successfully without an updateOutput callback', async () => {
mockExecutorInstance.run.mockImplementation(async () => {
const onActivity = MockAgentExecutor.create.mock.calls[0][2];
const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2];
if (onActivity) {
// Ensure calling activity doesn't crash when updateOutput is undefined
onActivity({
@@ -269,7 +267,7 @@ describe('SubagentInvocation', () => {
it('should handle executor creation failure', async () => {
const creationError = new Error('Failed to initialize tools.');
MockAgentExecutor.create.mockRejectedValue(creationError);
MockLocalAgentExecutor.create.mockRejectedValue(creationError);
const result = await invocation.execute(signal, updateOutput);

View File

@@ -5,17 +5,16 @@
*/
import type { Config } from '../config/config.js';
import { AgentExecutor } from './executor.js';
import { LocalAgentExecutor } from './local-executor.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { BaseToolInvocation, type ToolResult } from '../tools/tools.js';
import { ToolErrorType } from '../tools/tool-error.js';
import type {
AgentDefinition,
LocalAgentDefinition,
AgentInputs,
SubagentActivityEvent,
} from './types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { type z } from 'zod';
const INPUT_PREVIEW_MAX_LENGTH = 50;
const DESCRIPTION_MAX_LENGTH = 200;
@@ -24,28 +23,29 @@ const DESCRIPTION_MAX_LENGTH = 200;
* Represents a validated, executable instance of a subagent tool.
*
* This class orchestrates the execution of a defined agent by:
* 1. Initializing the {@link AgentExecutor}.
* 1. Initializing the {@link LocalAgentExecutor}.
* 2. Running the agent's execution loop.
* 3. Bridging the agent's streaming activity (e.g., thoughts) to the tool's
* live output stream.
* 4. Formatting the final result into a {@link ToolResult}.
*/
export class SubagentInvocation<
TOutput extends z.ZodTypeAny,
> extends BaseToolInvocation<AgentInputs, ToolResult> {
export class LocalSubagentInvocation extends BaseToolInvocation<
AgentInputs,
ToolResult
> {
/**
* @param params The validated input parameters for the agent.
* @param definition The definition object that configures the agent.
* @param config The global runtime configuration.
* @param params The validated input parameters for the agent.
* @param messageBus Optional message bus for policy enforcement.
*/
constructor(
params: AgentInputs,
private readonly definition: AgentDefinition<TOutput>,
private readonly definition: LocalAgentDefinition,
private readonly config: Config,
params: AgentInputs,
messageBus?: MessageBus,
) {
super(params, messageBus);
super(params, messageBus, definition.name, definition.displayName);
}
/**
@@ -94,7 +94,7 @@ export class SubagentInvocation<
}
};
const executor = await AgentExecutor.create(
const executor = await LocalAgentExecutor.create(
this.definition,
this.config,
onActivity,

View File

@@ -7,7 +7,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AgentRegistry, getModelConfigAlias } from './registry.js';
import { makeFakeConfig } from '../test-utils/config.js';
import type { AgentDefinition } from './types.js';
import type { AgentDefinition, LocalAgentDefinition } from './types.js';
import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js';
@@ -20,6 +20,7 @@ class TestableAgentRegistry extends AgentRegistry {
// Define mock agent structures for testing registration logic
const MOCK_AGENT_V1: AgentDefinition = {
kind: 'local',
name: 'MockAgent',
description: 'Mock Description V1',
inputConfig: { inputs: {} },
@@ -89,7 +90,9 @@ describe('AgentRegistry', () => {
'codebase_investigator',
);
expect(investigatorDef).toBeDefined();
expect(investigatorDef?.modelConfig.model).toBe('gemini-3-pro-preview');
expect((investigatorDef as LocalAgentDefinition).modelConfig.model).toBe(
'gemini-3-pro-preview',
);
});
});

View File

@@ -115,26 +115,31 @@ export class AgentRegistry {
// Register model config.
// TODO(12916): Migrate sub-agents where possible to static configs.
const modelConfig = definition.modelConfig;
if (definition.kind === 'local') {
const modelConfig = definition.modelConfig;
const runtimeAlias: ModelConfigAlias = {
modelConfig: {
model: modelConfig.model,
generateContentConfig: {
temperature: modelConfig.temp,
topP: modelConfig.top_p,
thinkingConfig: {
includeThoughts: true,
thinkingBudget: modelConfig.thinkingBudget ?? -1,
const runtimeAlias: ModelConfigAlias = {
modelConfig: {
model: modelConfig.model,
generateContentConfig: {
temperature: modelConfig.temp,
topP: modelConfig.top_p,
thinkingConfig: {
includeThoughts: true,
thinkingBudget: modelConfig.thinkingBudget ?? -1,
},
},
},
},
};
};
this.config.modelConfigService.registerRuntimeModelConfig(
getModelConfigAlias(definition),
runtimeAlias,
);
this.config.modelConfigService.registerRuntimeModelConfig(
getModelConfigAlias(definition),
runtimeAlias,
);
}
// Register configured remote A2A agents.
// TODO: Implement remote agent registration.
}
/**
@@ -191,7 +196,7 @@ ${agentDescriptions}`;
context +=
'Use `delegate_to_agent` for complex tasks requiring specialized analysis.\n\n';
for (const [name, def] of this.agents.entries()) {
for (const [name, def] of this.agents) {
context += `- **${name}**: ${def.description}\n`;
}
return context;

View File

@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import type { ToolCallConfirmationDetails } from '../tools/tools.js';
import { RemoteAgentInvocation } from './remote-invocation.js';
import type { RemoteAgentDefinition } from './types.js';
class TestableRemoteAgentInvocation extends RemoteAgentInvocation {
override async getConfirmationDetails(
abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
return super.getConfirmationDetails(abortSignal);
}
}
describe('RemoteAgentInvocation', () => {
const mockDefinition: RemoteAgentDefinition = {
kind: 'remote',
name: 'test-remote-agent',
description: 'A test remote agent',
displayName: 'Test Remote Agent',
agentCardUrl: 'https://example.com/agent-card',
inputConfig: {
inputs: {},
},
};
it('should be instantiated with correct params', () => {
const invocation = new RemoteAgentInvocation(mockDefinition, {});
expect(invocation).toBeDefined();
expect(invocation.getDescription()).toBe(
'Calling remote agent Test Remote Agent',
);
});
it('should return false for confirmation details (not yet implemented)', async () => {
const invocation = new TestableRemoteAgentInvocation(mockDefinition, {});
const details = await invocation.getConfirmationDetails(
new AbortController().signal,
);
expect(details).toBe(false);
});
});

View File

@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseToolInvocation,
type ToolResult,
type ToolCallConfirmationDetails,
} from '../tools/tools.js';
import type { AgentInputs, RemoteAgentDefinition } from './types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
/**
* A tool invocation that proxies to a remote A2A agent.
*
* This implementation bypasses the local `LocalAgentExecutor` loop and directly
* invokes the configured A2A tool.
*/
export class RemoteAgentInvocation extends BaseToolInvocation<
AgentInputs,
ToolResult
> {
constructor(
private readonly definition: RemoteAgentDefinition,
params: AgentInputs,
messageBus?: MessageBus,
) {
super(params, messageBus, definition.name, definition.displayName);
}
getDescription(): string {
return `Calling remote agent ${this.definition.displayName ?? this.definition.name}`;
}
protected override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
// TODO: Implement confirmation logic for remote agents.
return false;
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
// TODO: Implement remote agent invocation logic.
throw new Error(`Remote agent invocation not implemented.`);
}
}

View File

@@ -6,19 +6,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SubagentToolWrapper } from './subagent-tool-wrapper.js';
import { SubagentInvocation } from './invocation.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { convertInputConfigToJsonSchema } from './schema-utils.js';
import { makeFakeConfig } from '../test-utils/config.js';
import type { AgentDefinition, AgentInputs } from './types.js';
import type { LocalAgentDefinition, AgentInputs } from './types.js';
import type { Config } from '../config/config.js';
import { Kind } from '../tools/tools.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
// Mock dependencies to isolate the SubagentToolWrapper class
vi.mock('./invocation.js');
vi.mock('./local-invocation.js');
vi.mock('./schema-utils.js');
const MockedSubagentInvocation = vi.mocked(SubagentInvocation);
const MockedLocalSubagentInvocation = vi.mocked(LocalSubagentInvocation);
const mockConvertInputConfigToJsonSchema = vi.mocked(
convertInputConfigToJsonSchema,
);
@@ -26,7 +26,8 @@ const mockConvertInputConfigToJsonSchema = vi.mocked(
// Define reusable test data
let mockConfig: Config;
const mockDefinition: AgentDefinition = {
const mockDefinition: LocalAgentDefinition = {
kind: 'local',
name: 'TestAgent',
displayName: 'Test Agent Display Name',
description: 'An agent for testing.',
@@ -106,23 +107,23 @@ describe('SubagentToolWrapper', () => {
});
describe('createInvocation', () => {
it('should create a SubagentInvocation with the correct parameters', () => {
it('should create a LocalSubagentInvocation with the correct parameters', () => {
const wrapper = new SubagentToolWrapper(mockDefinition, mockConfig);
const params: AgentInputs = { goal: 'Test the invocation', priority: 1 };
// The public `build` method calls the protected `createInvocation` after validation
const invocation = wrapper.build(params);
expect(invocation).toBeInstanceOf(SubagentInvocation);
expect(MockedSubagentInvocation).toHaveBeenCalledExactlyOnceWith(
params,
expect(invocation).toBeInstanceOf(LocalSubagentInvocation);
expect(MockedLocalSubagentInvocation).toHaveBeenCalledExactlyOnceWith(
mockDefinition,
mockConfig,
params,
undefined,
);
});
it('should pass the messageBus to the SubagentInvocation constructor', () => {
it('should pass the messageBus to the LocalSubagentInvocation constructor', () => {
const mockMessageBus = {} as MessageBus;
const wrapper = new SubagentToolWrapper(
mockDefinition,
@@ -133,10 +134,10 @@ describe('SubagentToolWrapper', () => {
wrapper.build(params);
expect(MockedSubagentInvocation).toHaveBeenCalledWith(
params,
expect(MockedLocalSubagentInvocation).toHaveBeenCalledWith(
mockDefinition,
mockConfig,
params,
mockMessageBus,
);
});
@@ -151,7 +152,7 @@ describe('SubagentToolWrapper', () => {
expect(() => wrapper.build(invalidParams)).toThrow(
"params must have required property 'goal'",
);
expect(MockedSubagentInvocation).not.toHaveBeenCalled();
expect(MockedLocalSubagentInvocation).not.toHaveBeenCalled();
});
});
});

View File

@@ -13,7 +13,8 @@ import {
import type { Config } from '../config/config.js';
import type { AgentDefinition, AgentInputs } from './types.js';
import { convertInputConfigToJsonSchema } from './schema-utils.js';
import { SubagentInvocation } from './invocation.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { RemoteAgentInvocation } from './remote-invocation.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
/**
@@ -39,7 +40,6 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
private readonly config: Config,
messageBus?: MessageBus,
) {
// Dynamically generate the JSON schema required for the tool definition.
const parameterSchema = convertInputConfigToJsonSchema(
definition.inputConfig,
);
@@ -68,10 +68,15 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
protected createInvocation(
params: AgentInputs,
): ToolInvocation<AgentInputs, ToolResult> {
return new SubagentInvocation(
params,
this.definition,
const definition = this.definition;
if (definition.kind === 'remote') {
return new RemoteAgentInvocation(definition, params, this.messageBus);
}
return new LocalSubagentInvocation(
definition,
this.config,
params,
this.messageBus,
);
}

View File

@@ -49,20 +49,33 @@ export interface SubagentActivityEvent {
}
/**
* The definition for an agent.
* The base definition for an agent.
* @template TOutput The specific Zod schema for the agent's final output object.
*/
export interface AgentDefinition<TOutput extends z.ZodTypeAny = z.ZodUnknown> {
export interface BaseAgentDefinition<
TOutput extends z.ZodTypeAny = z.ZodUnknown,
> {
/** Unique identifier for the agent. */
name: string;
displayName?: string;
description: string;
inputConfig: InputConfig;
outputConfig?: OutputConfig<TOutput>;
}
export interface LocalAgentDefinition<
TOutput extends z.ZodTypeAny = z.ZodUnknown,
> extends BaseAgentDefinition<TOutput> {
kind: 'local';
// Local agent required configs
promptConfig: PromptConfig;
modelConfig: ModelConfig;
runConfig: RunConfig;
// Optional configs
toolConfig?: ToolConfig;
outputConfig?: OutputConfig<TOutput>;
inputConfig: InputConfig;
/**
* An optional function to process the raw output from the agent's final tool
* call into a string format.
@@ -73,6 +86,17 @@ export interface AgentDefinition<TOutput extends z.ZodTypeAny = z.ZodUnknown> {
processOutput?: (output: z.infer<TOutput>) => string;
}
export interface RemoteAgentDefinition<
TOutput extends z.ZodTypeAny = z.ZodUnknown,
> extends BaseAgentDefinition<TOutput> {
kind: 'remote';
agentCardUrl: string;
}
export type AgentDefinition<TOutput extends z.ZodTypeAny = z.ZodUnknown> =
| LocalAgentDefinition<TOutput>
| RemoteAgentDefinition<TOutput>;
/**
* Configures the initial prompt for the agent.
*/

View File

@@ -236,7 +236,7 @@ describe('ModelConfigService Integration', () => {
// Re-instantiate service for this isolated test to not pollute other tests
const service = new ModelConfigService(complexConfig);
// Register a runtime alias, simulating what AgentExecutor does.
// Register a runtime alias, simulating what LocalAgentExecutor does.
// This alias extends a static base and provides its own settings.
service.registerRuntimeModelConfig('agent-runtime:my-agent', {
extends: 'creative-writer', // extends a multi-level alias