mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-18 15:52:53 -07:00
feat(core): mvp port planner to subagent.
This commit is contained in:
@@ -1157,6 +1157,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.plannerSubagent`** (boolean):
|
||||
- **Description:** Use the new planner subagent for plan mode.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.enableAgents`** (boolean):
|
||||
- **Description:** Enable local and remote subagents. Warning: Experimental
|
||||
feature, uses YOLO mode for subagents
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('Planner Subagent E2E', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
// TODO(joshualitt): Re-enable after planner subagent is working end-to-end.
|
||||
it.skip('should enter plan mode, create a plan, and exit plan mode', async () => {
|
||||
// We'll use a real run to see if it works.
|
||||
// We need to enable the planner agent and the required tools.
|
||||
rig.setup('planner-subagent-e2e', {
|
||||
settings: {
|
||||
experimental: {
|
||||
plan: true,
|
||||
agents: true,
|
||||
},
|
||||
tools: {
|
||||
core: [
|
||||
'enter_plan_mode',
|
||||
'exit_plan_mode',
|
||||
'write_file',
|
||||
'read_file',
|
||||
'list_directory',
|
||||
],
|
||||
},
|
||||
// Add policy to allow enter_plan_mode without confirmation in yolo mode
|
||||
// Actually TestRig.run uses --approval-mode=yolo by default if not specified.
|
||||
// But enter_plan_mode and exit_plan_mode might still ask if not explicitly allowed.
|
||||
policy: {
|
||||
rules: [
|
||||
{ toolName: 'enter_plan_mode', decision: 'ALLOW' },
|
||||
{ toolName: 'exit_plan_mode', decision: 'ALLOW' },
|
||||
{ toolName: 'write_file', decision: 'ALLOW' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
rig.mkdir('plans');
|
||||
rig.createFile('hello.ts', 'console.log("hello");');
|
||||
|
||||
// Prompt the agent to create a plan for refactoring hello.ts
|
||||
// We expect:
|
||||
// 1. Main agent calls enter_plan_mode
|
||||
// 2. Planner subagent is launched
|
||||
// 3. Planner subagent reads hello.ts
|
||||
// 4. Planner subagent writes plans/refactor.md
|
||||
// 5. Planner subagent calls exit_plan_mode
|
||||
// 6. Main agent resumes
|
||||
|
||||
await rig.run({
|
||||
stdin:
|
||||
'Please create a refactoring plan for hello.ts to use a function. Put the plan in plans/refactor.md and then approve it.',
|
||||
approvalMode: 'yolo',
|
||||
timeout: 60000, // Planning can take a while
|
||||
});
|
||||
|
||||
// 1. Verify enter_plan_mode was called
|
||||
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(enterPlanCalled).toBe(true);
|
||||
|
||||
// 2. Verify write_file was called for the plan
|
||||
const writeFileCalled = await rig.waitForToolCall(
|
||||
'write_file',
|
||||
20000,
|
||||
(args) => {
|
||||
return args.includes('plans/refactor.md');
|
||||
},
|
||||
);
|
||||
expect(writeFileCalled).toBe(true);
|
||||
|
||||
// 3. Verify exit_plan_mode was called
|
||||
const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode');
|
||||
expect(exitPlanCalled).toBe(true);
|
||||
|
||||
// 4. Verify the plan file exists on disk
|
||||
const planPath = path.join(rig.testDir!, 'plans/refactor.md');
|
||||
expect(fs.existsSync(planPath)).toBe(true);
|
||||
const planContent = fs.readFileSync(planPath, 'utf-8');
|
||||
expect(planContent).toContain('hello.ts');
|
||||
});
|
||||
});
|
||||
@@ -802,6 +802,7 @@ export async function loadCliConfig(
|
||||
extensionLoader: extensionManager,
|
||||
extensionRegistryURI,
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
plannerSubagent: settings.experimental?.plannerSubagent,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
tracker: settings.experimental?.taskTracker,
|
||||
|
||||
@@ -1833,6 +1833,15 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
plannerSubagent: {
|
||||
type: 'boolean',
|
||||
label: 'Planner Subagent',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Use the new planner subagent for plan mode.',
|
||||
showInDialog: false,
|
||||
},
|
||||
enableAgents: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Agents',
|
||||
|
||||
@@ -103,8 +103,13 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
private readonly onActivity?: ActivityCallback;
|
||||
private readonly compressionService: ChatCompressionService;
|
||||
private readonly parentCallId?: string;
|
||||
private readonly completionToolName: string;
|
||||
private hasFailedCompressionAttempt = false;
|
||||
|
||||
private usesCustomCompletionTool(): boolean {
|
||||
return this.completionToolName !== TASK_COMPLETE_TOOL_NAME;
|
||||
}
|
||||
|
||||
private get config(): Config {
|
||||
return this.context.config;
|
||||
}
|
||||
@@ -264,6 +269,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.onActivity = onActivity;
|
||||
this.compressionService = new ChatCompressionService();
|
||||
this.parentCallId = parentCallId;
|
||||
this.completionToolName =
|
||||
definition.runConfig.completionToolName || TASK_COMPLETE_TOOL_NAME;
|
||||
|
||||
const randomIdPart = Math.random().toString(36).slice(2, 8);
|
||||
// parentPromptId will be undefined if this agent is invoked directly
|
||||
@@ -309,7 +316,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
// If the model stops calling tools without calling complete_task, it's an error.
|
||||
if (functionCalls.length === 0) {
|
||||
this.emitActivity('ERROR', {
|
||||
error: `Agent stopped calling tools but did not call '${TASK_COMPLETE_TOOL_NAME}' to finalize the session.`,
|
||||
error: `Agent stopped calling tools but did not call '${this.completionToolName}' to finalize the session.`,
|
||||
context: 'protocol_violation',
|
||||
});
|
||||
return {
|
||||
@@ -374,7 +381,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
default:
|
||||
throw new Error(`Unknown terminate reason: ${reason}`);
|
||||
}
|
||||
return `${explanation} You have one final chance to complete the task with a short grace period. You MUST call \`${TASK_COMPLETE_TOOL_NAME}\` immediately with your best answer and explain that your investigation was interrupted. Do not call any other tools.`;
|
||||
const explainInterrupted = this.usesCustomCompletionTool()
|
||||
? ''
|
||||
: ' and explain that your investigation was interrupted';
|
||||
return `${explanation} You have one final chance to complete the task with a short grace period. You MUST call \`${this.completionToolName}\` immediately with your best answer${explainInterrupted}. Do not call any other tools.`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -641,7 +651,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
// The finalResult was already set by executeTurn, but we re-emit just in case.
|
||||
finalResult =
|
||||
finalResult ||
|
||||
`Agent stopped calling tools but did not call '${TASK_COMPLETE_TOOL_NAME}'.`;
|
||||
`Agent stopped calling tools but did not call '${this.completionToolName}'.`;
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'protocol_violation',
|
||||
@@ -900,6 +910,54 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and processes the final output of the agent.
|
||||
*/
|
||||
private validateAndProcessOutput(outputValue: unknown): {
|
||||
submittedOutput: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
const { outputConfig, processOutput } = this.definition;
|
||||
|
||||
if (outputConfig) {
|
||||
const validationResult = outputConfig.schema.safeParse(outputValue);
|
||||
|
||||
if (!validationResult.success) {
|
||||
return {
|
||||
submittedOutput: '',
|
||||
success: false,
|
||||
error: `Output validation failed: ${JSON.stringify(validationResult.error.flatten())}`,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const validatedOutput = validationResult.data;
|
||||
if (processOutput) {
|
||||
return {
|
||||
submittedOutput: processOutput(validatedOutput),
|
||||
success: true,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
submittedOutput:
|
||||
typeof validatedOutput === 'string'
|
||||
? validatedOutput
|
||||
: JSON.stringify(validatedOutput, null, 2),
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
submittedOutput:
|
||||
typeof outputValue === 'string'
|
||||
? outputValue
|
||||
: JSON.stringify(outputValue, null, 2),
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes function calls requested by the model and returns the results.
|
||||
*
|
||||
@@ -918,7 +976,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
}> {
|
||||
const allowedToolNames = new Set(this.toolRegistry.getAllToolNames());
|
||||
// Always allow the completion tool
|
||||
allowedToolNames.add(TASK_COMPLETE_TOOL_NAME);
|
||||
allowedToolNames.add(this.completionToolName);
|
||||
|
||||
let submittedOutput: string | null = null;
|
||||
let taskCompleted = false;
|
||||
@@ -959,7 +1017,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
callId,
|
||||
});
|
||||
|
||||
if (toolName === TASK_COMPLETE_TOOL_NAME) {
|
||||
if (
|
||||
toolName === TASK_COMPLETE_TOOL_NAME &&
|
||||
!this.usesCustomCompletionTool()
|
||||
) {
|
||||
if (taskCompleted) {
|
||||
const error =
|
||||
'Task already marked complete in this turn. Ignoring duplicate call.';
|
||||
@@ -983,13 +1044,13 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
|
||||
if (outputConfig) {
|
||||
const outputName = outputConfig.outputName;
|
||||
if (args[outputName] !== undefined) {
|
||||
const outputValue = args[outputName];
|
||||
const validationResult = outputConfig.schema.safeParse(outputValue);
|
||||
const outputValue = args[outputName];
|
||||
if (outputValue !== undefined) {
|
||||
const result = this.validateAndProcessOutput(outputValue);
|
||||
|
||||
if (!validationResult.success) {
|
||||
if (!result.success) {
|
||||
taskCompleted = false; // Validation failed, revoke completion
|
||||
const error = `Output validation failed: ${JSON.stringify(validationResult.error.flatten())}`;
|
||||
const error = result.error!;
|
||||
syncResults.set(callId, {
|
||||
functionResponse: {
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
@@ -1005,16 +1066,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const validatedOutput = validationResult.data;
|
||||
if (this.definition.processOutput) {
|
||||
submittedOutput = this.definition.processOutput(validatedOutput);
|
||||
} else {
|
||||
submittedOutput =
|
||||
typeof outputValue === 'string'
|
||||
? outputValue
|
||||
: JSON.stringify(outputValue, null, 2);
|
||||
}
|
||||
submittedOutput = result.submittedOutput;
|
||||
syncResults.set(callId, {
|
||||
functionResponse: {
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
@@ -1149,6 +1201,34 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
id: call.request.callId,
|
||||
output: call.response.resultDisplay,
|
||||
});
|
||||
|
||||
// Check if this was a custom completion tool and it signaled task completion
|
||||
if (
|
||||
toolName === this.completionToolName &&
|
||||
call.response.isTaskCompletion
|
||||
) {
|
||||
debugLogger.log(
|
||||
`[LocalAgentExecutor] Custom completion tool '${toolName}' signaled task completion.`,
|
||||
);
|
||||
const outputValue = call.response.data;
|
||||
const result = this.validateAndProcessOutput(outputValue);
|
||||
|
||||
if (result.success) {
|
||||
taskCompleted = true;
|
||||
submittedOutput = result.submittedOutput;
|
||||
} else {
|
||||
// If validation fails, we still mark it as completed but use the raw output
|
||||
// and maybe log a warning.
|
||||
debugLogger.warn(
|
||||
`[LocalAgentExecutor] Custom completion tool '${toolName}' returned data that failed output validation: ${result.error}`,
|
||||
);
|
||||
taskCompleted = true;
|
||||
submittedOutput =
|
||||
typeof outputValue === 'string'
|
||||
? outputValue
|
||||
: JSON.stringify(outputValue, null, 2);
|
||||
}
|
||||
}
|
||||
} else if (call.status === 'error') {
|
||||
this.emitActivity('ERROR', {
|
||||
context: 'tool_call',
|
||||
@@ -1218,42 +1298,44 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
toolsList.push(...this.toolRegistry.getFunctionDeclarations());
|
||||
}
|
||||
|
||||
// Always inject complete_task.
|
||||
// Configure its schema based on whether output is expected.
|
||||
const completeTool: FunctionDeclaration = {
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
description: outputConfig
|
||||
? 'Call this tool to submit your final answer and complete the task. This is the ONLY way to finish.'
|
||||
: 'Call this tool to submit your final findings and complete the task. This is the ONLY way to finish.',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
};
|
||||
|
||||
if (outputConfig) {
|
||||
const jsonSchema = zodToJsonSchema(outputConfig.schema);
|
||||
const {
|
||||
$schema: _$schema,
|
||||
definitions: _definitions,
|
||||
...schema
|
||||
} = jsonSchema;
|
||||
completeTool.parameters!.properties![outputConfig.outputName] =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
schema as Schema;
|
||||
completeTool.parameters!.required!.push(outputConfig.outputName);
|
||||
} else {
|
||||
completeTool.parameters!.properties!['result'] = {
|
||||
type: Type.STRING,
|
||||
description:
|
||||
'Your final results or findings to return to the orchestrator. ' +
|
||||
'Ensure this is comprehensive and follows any formatting requested in your instructions.',
|
||||
// Always inject completion tool if it's complete_task.
|
||||
// If it's a custom tool, it should be in toolConfig.
|
||||
if (!this.usesCustomCompletionTool()) {
|
||||
const completeTool: FunctionDeclaration = {
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
description: outputConfig
|
||||
? 'Call this tool to submit your final answer and complete the task. This is the ONLY way to finish.'
|
||||
: 'Call this tool to submit your final findings and complete the task. This is the ONLY way to finish.',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
};
|
||||
completeTool.parameters!.required!.push('result');
|
||||
}
|
||||
|
||||
toolsList.push(completeTool);
|
||||
if (outputConfig) {
|
||||
const jsonSchema = zodToJsonSchema(outputConfig.schema);
|
||||
const {
|
||||
$schema: _$schema,
|
||||
definitions: _definitions,
|
||||
...schema
|
||||
} = jsonSchema;
|
||||
completeTool.parameters!.properties![outputConfig.outputName] =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
schema as Schema;
|
||||
completeTool.parameters!.required!.push(outputConfig.outputName);
|
||||
} else {
|
||||
completeTool.parameters!.properties!['result'] = {
|
||||
type: Type.STRING,
|
||||
description:
|
||||
'Your final results or findings to return to the orchestrator. ' +
|
||||
'Ensure this is comprehensive and follows any formatting requested in your instructions.',
|
||||
};
|
||||
completeTool.parameters!.required!.push('result');
|
||||
}
|
||||
|
||||
toolsList.push(completeTool);
|
||||
}
|
||||
|
||||
return toolsList;
|
||||
}
|
||||
@@ -1279,7 +1361,12 @@ Important Rules:
|
||||
* Work systematically using available tools to complete your task.
|
||||
* Always use absolute paths for file operations. Construct them using the provided "Environment Context".`;
|
||||
|
||||
if (this.definition.outputConfig) {
|
||||
if (this.usesCustomCompletionTool()) {
|
||||
finalPrompt += `
|
||||
* When you have completed your task, you MUST call the \`${this.completionToolName}\` tool to signal completion.
|
||||
* Do not call any other tools in the same turn as \`${this.completionToolName}\`.
|
||||
* This is the ONLY way to complete your mission. If you stop calling tools without calling this, you have failed.`;
|
||||
} else if (this.definition.outputConfig) {
|
||||
finalPrompt += `
|
||||
* When you have completed your task, you MUST call the \`${TASK_COMPLETE_TOOL_NAME}\` tool with your structured output.
|
||||
* Do not call any other tools in the same turn as \`${TASK_COMPLETE_TOOL_NAME}\`.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PlannerAgentSchema = z.object({
|
||||
plan_path: z
|
||||
.string()
|
||||
.describe('The path to the finalized and approved plan.'),
|
||||
});
|
||||
|
||||
export type PlannerAgentOutput = z.infer<typeof PlannerAgentSchema>;
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { getCoreSystemPrompt } from '../core/prompts.js';
|
||||
import type { LocalAgentDefinition } from './types.js';
|
||||
import { PlannerAgentSchema } from './planner-schema.js';
|
||||
import {
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
LS_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
|
||||
/**
|
||||
* A specialized subagent for research and planning.
|
||||
* It operates with read-only tools (mostly) and is restricted to writing plans
|
||||
* until user approval is received.
|
||||
*/
|
||||
export const PlannerAgent = (
|
||||
config: Config,
|
||||
): LocalAgentDefinition<typeof PlannerAgentSchema> => ({
|
||||
kind: 'local',
|
||||
name: 'planner',
|
||||
displayName: 'Planner Agent',
|
||||
description:
|
||||
'A specialized subagent for research and planning. It explores the codebase, designs solutions, and drafts detailed implementation plans for user approval.',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: 'The reason for entering plan mode or the task to plan.',
|
||||
},
|
||||
},
|
||||
required: ['reason'],
|
||||
},
|
||||
},
|
||||
outputConfig: {
|
||||
outputName: 'plan_path',
|
||||
description: 'The path to the finalized and approved plan.',
|
||||
schema: PlannerAgentSchema,
|
||||
},
|
||||
processOutput: (output) => output.plan_path,
|
||||
modelConfig: {
|
||||
model: 'inherit',
|
||||
},
|
||||
runConfig: {
|
||||
maxTimeMinutes: 10,
|
||||
maxTurns: 20,
|
||||
completionToolName: EXIT_PLAN_MODE_TOOL_NAME,
|
||||
hasCustomEntryPoint: true,
|
||||
},
|
||||
toolConfig: {
|
||||
tools: [
|
||||
LS_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
],
|
||||
},
|
||||
promptConfig: {
|
||||
get systemPrompt() {
|
||||
return getCoreSystemPrompt(
|
||||
config,
|
||||
undefined, // userMemory
|
||||
false, // interactiveOverride
|
||||
);
|
||||
},
|
||||
query: 'Start planning for: ${reason}',
|
||||
},
|
||||
});
|
||||
@@ -274,6 +274,7 @@ describe('AgentRegistry', () => {
|
||||
codebase_investigator: { enabled: false },
|
||||
cli_help: { enabled: false },
|
||||
generalist: { enabled: false },
|
||||
planner: { enabled: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -335,6 +336,15 @@ describe('AgentRegistry', () => {
|
||||
expect(registry.getDefinition('generalist')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should NOT register planner agent if plannerSubagent is false', async () => {
|
||||
const config = makeMockedConfig({ plannerSubagent: false });
|
||||
const registry = new TestableAgentRegistry(config);
|
||||
|
||||
await registry.initialize();
|
||||
|
||||
expect(registry.getDefinition('planner')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT register a non-experimental agent if enabled is false', async () => {
|
||||
// CLI help is NOT experimental, but we explicitly disable it via enabled: false
|
||||
const config = makeMockedConfig({
|
||||
|
||||
@@ -12,6 +12,7 @@ import { loadAgentsFromDirectory } from './agentLoader.js';
|
||||
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
|
||||
import { CliHelpAgent } from './cli-help-agent.js';
|
||||
import { GeneralistAgent } from './generalist-agent.js';
|
||||
import { PlannerAgent } from './planner.js';
|
||||
import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js';
|
||||
import { A2AClientManager } from './a2a-client-manager.js';
|
||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||
@@ -243,6 +244,9 @@ export class AgentRegistry {
|
||||
this.registerLocalAgent(CodebaseInvestigatorAgent(this.config));
|
||||
this.registerLocalAgent(CliHelpAgent(this.config));
|
||||
this.registerLocalAgent(GeneralistAgent(this.config));
|
||||
if (this.config.isPlannerSubagentEnabled()) {
|
||||
this.registerLocalAgent(PlannerAgent(this.config));
|
||||
}
|
||||
|
||||
// Register the browser agent if enabled in settings.
|
||||
// Tools are configured dynamically at invocation time via browserAgentFactory.
|
||||
|
||||
@@ -231,4 +231,14 @@ export interface RunConfig {
|
||||
* If not specified, defaults to DEFAULT_MAX_TURNS (30).
|
||||
*/
|
||||
maxTurns?: number;
|
||||
/**
|
||||
* The name of the tool that signals task completion.
|
||||
* Defaults to 'complete_task'.
|
||||
*/
|
||||
completionToolName?: string;
|
||||
/**
|
||||
* Whether or not this subagent has a custom entry point.
|
||||
* Defaults to `false`, and wraps subagents in a type-safe entry-point.
|
||||
*/
|
||||
hasCustomEntryPoint?: boolean;
|
||||
}
|
||||
|
||||
@@ -618,6 +618,7 @@ export interface ConfigParameters {
|
||||
disabledHooks?: string[];
|
||||
projectHooks?: { [K in HookEventName]?: HookDefinition[] };
|
||||
enableAgents?: boolean;
|
||||
plannerSubagent?: boolean;
|
||||
enableEventDrivenScheduler?: boolean;
|
||||
skillsSupport?: boolean;
|
||||
disabledSkills?: string[];
|
||||
@@ -837,6 +838,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
overageStrategy: OverageStrategy;
|
||||
};
|
||||
|
||||
private readonly plannerSubagent: boolean;
|
||||
private readonly enableAgents: boolean;
|
||||
private agents: AgentSettings;
|
||||
private readonly enableEventDrivenScheduler: boolean;
|
||||
@@ -948,6 +950,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.model = params.model;
|
||||
this.disableLoopDetection = params.disableLoopDetection ?? false;
|
||||
this._activeModel = params.model;
|
||||
this.plannerSubagent = params.plannerSubagent ?? false;
|
||||
this.enableAgents = params.enableAgents ?? false;
|
||||
this.agents = params.agents ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
@@ -2481,6 +2484,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.approvedPlanPath = path;
|
||||
}
|
||||
|
||||
isPlannerSubagentEnabled(): boolean {
|
||||
return this.plannerSubagent && this.isPlanEnabled();
|
||||
}
|
||||
|
||||
isAgentsEnabled(): boolean {
|
||||
return this.enableAgents;
|
||||
}
|
||||
@@ -3128,7 +3135,13 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
for (const definition of definitions) {
|
||||
try {
|
||||
const tool = new SubagentTool(definition, this, this.messageBus);
|
||||
if (
|
||||
definition.kind === 'local' &&
|
||||
definition.runConfig.hasCustomEntryPoint === true
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const tool = new SubagentTool(definition, this, this._messageBus);
|
||||
registry.registerTool(tool);
|
||||
} catch (e: unknown) {
|
||||
debugLogger.warn(
|
||||
|
||||
@@ -364,6 +364,7 @@ export class ToolExecutor {
|
||||
errorType: undefined,
|
||||
outputFile,
|
||||
contentLength: typeof content === 'string' ? content.length : undefined,
|
||||
isTaskCompletion: toolResult.isTaskCompletion,
|
||||
data: toolResult.data,
|
||||
};
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface ToolCallResponseInfo {
|
||||
errorType: ToolErrorType | undefined;
|
||||
outputFile?: string | undefined;
|
||||
contentLength?: number;
|
||||
isTaskCompletion?: boolean;
|
||||
/**
|
||||
* Optional data payload for passing structured information back to the caller.
|
||||
*/
|
||||
|
||||
@@ -10,25 +10,40 @@ import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { ToolConfirmationOutcome } from './tools.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { SubagentToolWrapper } from '../agents/subagent-tool-wrapper.js';
|
||||
import type { LocalAgentDefinition } from '../agents/types.js';
|
||||
|
||||
vi.mock('../agents/subagent-tool-wrapper.js');
|
||||
|
||||
describe('EnterPlanModeTool', () => {
|
||||
let tool: EnterPlanModeTool;
|
||||
let mockMessageBus: ReturnType<typeof createMockMessageBus>;
|
||||
let mockConfig: Partial<Config>;
|
||||
let mockConfig: Config;
|
||||
let mockPlannerDefinition: LocalAgentDefinition;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMessageBus = createMockMessageBus();
|
||||
vi.mocked(mockMessageBus.publish).mockResolvedValue(undefined);
|
||||
|
||||
mockPlannerDefinition = {
|
||||
kind: 'local',
|
||||
name: 'planner',
|
||||
description: 'Mock Planner',
|
||||
inputConfig: { inputSchema: {} },
|
||||
} as LocalAgentDefinition;
|
||||
|
||||
mockConfig = {
|
||||
setApprovalMode: vi.fn(),
|
||||
isPlannerSubagentEnabled: vi.fn().mockReturnValue(true),
|
||||
getAgentRegistry: vi.fn().mockReturnValue({
|
||||
getDefinition: vi.fn().mockReturnValue(mockPlannerDefinition),
|
||||
}),
|
||||
storage: {
|
||||
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
} as unknown as Config['storage'],
|
||||
};
|
||||
} as unknown as Config;
|
||||
tool = new EnterPlanModeTool(
|
||||
mockConfig as Config,
|
||||
mockConfig,
|
||||
mockMessageBus as unknown as MessageBus,
|
||||
);
|
||||
});
|
||||
@@ -41,7 +56,6 @@ describe('EnterPlanModeTool', () => {
|
||||
it('should return info confirmation details when policy says ASK_USER', async () => {
|
||||
const invocation = tool.build({});
|
||||
|
||||
// Mock getMessageBusDecision to return ASK_USER
|
||||
vi.spyOn(
|
||||
invocation as unknown as {
|
||||
getMessageBusDecision: () => Promise<string>;
|
||||
@@ -64,73 +78,41 @@ describe('EnterPlanModeTool', () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false when policy decision is ALLOW', async () => {
|
||||
const invocation = tool.build({});
|
||||
|
||||
// Mock getMessageBusDecision to return ALLOW
|
||||
vi.spyOn(
|
||||
invocation as unknown as {
|
||||
getMessageBusDecision: () => Promise<string>;
|
||||
},
|
||||
'getMessageBusDecision',
|
||||
).mockResolvedValue('ALLOW');
|
||||
|
||||
const result = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error when policy decision is DENY', async () => {
|
||||
const invocation = tool.build({});
|
||||
|
||||
// Mock getMessageBusDecision to return DENY
|
||||
vi.spyOn(
|
||||
invocation as unknown as {
|
||||
getMessageBusDecision: () => Promise<string>;
|
||||
},
|
||||
'getMessageBusDecision',
|
||||
).mockResolvedValue('DENY');
|
||||
|
||||
await expect(
|
||||
invocation.shouldConfirmExecute(new AbortController().signal),
|
||||
).rejects.toThrow(/denied by policy/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should set approval mode to PLAN and return message', async () => {
|
||||
const invocation = tool.build({});
|
||||
it('should delegate to planner subagent', async () => {
|
||||
const invocation = tool.build({ reason: 'test reason' });
|
||||
|
||||
const mockSubInvocation = {
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: 'Plan created.',
|
||||
returnDisplay: 'Plan Display',
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(SubagentToolWrapper).prototype.build = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockSubInvocation);
|
||||
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.PLAN,
|
||||
expect(mockConfig.getAgentRegistry().getDefinition).toHaveBeenCalledWith(
|
||||
'planner',
|
||||
);
|
||||
expect(result.llmContent).toContain('Switching to Plan mode');
|
||||
expect(result.returnDisplay).toBe('Switching to Plan mode');
|
||||
});
|
||||
|
||||
it('should include optional reason in output display but not in llmContent', async () => {
|
||||
const reason = 'Design new database schema';
|
||||
const invocation = tool.build({ reason });
|
||||
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.PLAN,
|
||||
expect(SubagentToolWrapper).toHaveBeenCalledWith(
|
||||
mockPlannerDefinition,
|
||||
mockConfig,
|
||||
mockMessageBus,
|
||||
);
|
||||
expect(result.llmContent).toBe('Switching to Plan mode.');
|
||||
expect(result.llmContent).not.toContain(reason);
|
||||
expect(result.returnDisplay).toContain(reason);
|
||||
expect(mockSubInvocation.execute).toHaveBeenCalled();
|
||||
expect(result.llmContent).toBe('Plan created.');
|
||||
expect(result.returnDisplay).toBe('Plan Display');
|
||||
});
|
||||
|
||||
it('should not enter plan mode if cancelled', async () => {
|
||||
const invocation = tool.build({});
|
||||
|
||||
// Simulate getting confirmation details
|
||||
vi.spyOn(
|
||||
invocation as unknown as {
|
||||
getMessageBusDecision: () => Promise<string>;
|
||||
@@ -141,30 +123,14 @@ describe('EnterPlanModeTool', () => {
|
||||
const details = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(details).not.toBe(false);
|
||||
|
||||
if (details) {
|
||||
// Simulate user cancelling
|
||||
await details.onConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}
|
||||
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(mockConfig.setApprovalMode).not.toHaveBeenCalled();
|
||||
expect(result.returnDisplay).toBe('Cancelled');
|
||||
expect(result.llmContent).toContain('User cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToolParams', () => {
|
||||
it('should allow empty params', () => {
|
||||
const result = tool.validateToolParams({});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow reason param', () => {
|
||||
const result = tool.validateToolParams({ reason: 'test' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Kind,
|
||||
type ToolInfoConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
type ToolLiveOutput,
|
||||
} from './tools.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -18,6 +19,7 @@ import { ENTER_PLAN_MODE_TOOL_NAME } from './tool-names.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { ENTER_PLAN_MODE_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { SubagentToolWrapper } from '../agents/subagent-tool-wrapper.js';
|
||||
|
||||
export interface EnterPlanModeParams {
|
||||
reason?: string;
|
||||
@@ -40,6 +42,8 @@ export class EnterPlanModeTool extends BaseDeclarativeTool<
|
||||
Kind.Plan,
|
||||
ENTER_PLAN_MODE_DEFINITION.base.parametersJsonSchema,
|
||||
messageBus,
|
||||
/* isOutputMarkdown */ true,
|
||||
/* canUpdateOutput */ true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +116,10 @@ export class EnterPlanModeInvocation extends BaseToolInvocation<
|
||||
};
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
async execute(
|
||||
signal: AbortSignal,
|
||||
updateOutput?: (output: ToolLiveOutput) => void,
|
||||
): Promise<ToolResult> {
|
||||
if (this.confirmationOutcome === ToolConfirmationOutcome.Cancel) {
|
||||
return {
|
||||
llmContent: 'User cancelled entering Plan Mode.',
|
||||
@@ -122,11 +129,31 @@ export class EnterPlanModeInvocation extends BaseToolInvocation<
|
||||
|
||||
this.config.setApprovalMode(ApprovalMode.PLAN);
|
||||
|
||||
return {
|
||||
llmContent: 'Switching to Plan mode.',
|
||||
returnDisplay: this.params.reason
|
||||
? `Switching to Plan mode: ${this.params.reason}`
|
||||
: 'Switching to Plan mode',
|
||||
};
|
||||
if (!this.config.isPlannerSubagentEnabled()) {
|
||||
return {
|
||||
llmContent: 'Switching to Plan mode.',
|
||||
returnDisplay: this.params.reason
|
||||
? `Switching to Plan mode: ${this.params.reason}`
|
||||
: 'Switching to Plan mode',
|
||||
};
|
||||
}
|
||||
|
||||
const plannerDefinition = this.config
|
||||
.getAgentRegistry()
|
||||
.getDefinition('planner');
|
||||
if (!plannerDefinition) {
|
||||
throw new Error('Planner agent not found.');
|
||||
}
|
||||
|
||||
const wrapper = new SubagentToolWrapper(
|
||||
plannerDefinition,
|
||||
this.config,
|
||||
this.messageBus,
|
||||
);
|
||||
const subInvocation = wrapper.build({
|
||||
reason: this.params.reason || 'Requested by main agent',
|
||||
});
|
||||
|
||||
return subInvocation.execute(signal, updateOutput);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('ExitPlanModeTool', () => {
|
||||
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
|
||||
setApprovalMode: vi.fn(),
|
||||
setApprovedPlanPath: vi.fn(),
|
||||
isPlannerSubagentEnabled: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
|
||||
} as unknown as Config['storage'],
|
||||
@@ -200,6 +201,10 @@ describe('ExitPlanModeTool', () => {
|
||||
const expectedPath = path.join(mockPlansDir, 'test.md');
|
||||
|
||||
expect(result).toEqual({
|
||||
data: {
|
||||
plan_path: expectedPath,
|
||||
},
|
||||
isTaskCompletion: true,
|
||||
llmContent: `Plan approved. Switching to Default mode (edits will require confirmation).
|
||||
|
||||
The approved implementation plan is stored at: ${expectedPath}
|
||||
@@ -228,6 +233,10 @@ Read and follow the plan strictly during implementation.`,
|
||||
const expectedPath = path.join(mockPlansDir, 'test.md');
|
||||
|
||||
expect(result).toEqual({
|
||||
data: {
|
||||
plan_path: expectedPath,
|
||||
},
|
||||
isTaskCompletion: true,
|
||||
llmContent: `Plan approved. Switching to Auto-Edit mode (edits will be applied automatically).
|
||||
|
||||
The approved implementation plan is stored at: ${expectedPath}
|
||||
|
||||
@@ -26,10 +26,9 @@ import { PlanExecutionEvent } from '../telemetry/types.js';
|
||||
import { getExitPlanModeDefinition } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { getPlanModeExitMessage } from '../utils/approvalModeUtils.js';
|
||||
import { type PlannerAgentOutput } from '../agents/planner-schema.js';
|
||||
|
||||
export interface ExitPlanModeParams {
|
||||
plan_path: string;
|
||||
}
|
||||
export type ExitPlanModeParams = PlannerAgentOutput;
|
||||
|
||||
export class ExitPlanModeTool extends BaseDeclarativeTool<
|
||||
ExitPlanModeParams,
|
||||
@@ -225,14 +224,20 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
logPlanExecution(this.config, new PlanExecutionEvent(newMode));
|
||||
|
||||
const exitMessage = getPlanModeExitMessage(newMode);
|
||||
|
||||
return {
|
||||
const result: ToolResult = {
|
||||
llmContent: `${exitMessage}
|
||||
|
||||
The approved implementation plan is stored at: ${resolvedPlanPath}
|
||||
Read and follow the plan strictly during implementation.`,
|
||||
returnDisplay: `Plan approved: ${resolvedPlanPath}`,
|
||||
};
|
||||
|
||||
if (this.config.isPlannerSubagentEnabled()) {
|
||||
result.isTaskCompletion = true;
|
||||
result.data = { plan_path: resolvedPlanPath };
|
||||
}
|
||||
|
||||
return result;
|
||||
} else {
|
||||
const feedback = payload?.feedback?.trim();
|
||||
if (feedback) {
|
||||
|
||||
@@ -680,6 +680,12 @@ export interface ToolResult {
|
||||
type?: ToolErrorType; // An optional machine-readable error type (e.g., 'FILE_NOT_FOUND').
|
||||
};
|
||||
|
||||
/**
|
||||
* If true, this tool result signals an agent indicating they have completed a
|
||||
* task.
|
||||
*/
|
||||
isTaskCompletion?: boolean;
|
||||
|
||||
/**
|
||||
* Optional data payload for passing structured information back to the caller.
|
||||
*/
|
||||
|
||||
@@ -1968,6 +1968,13 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plannerSubagent": {
|
||||
"title": "Planner Subagent",
|
||||
"description": "Use the new planner subagent for plan mode.",
|
||||
"markdownDescription": "Use the new planner subagent for plan mode.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"enableAgents": {
|
||||
"title": "Enable Agents",
|
||||
"description": "Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents",
|
||||
|
||||
Reference in New Issue
Block a user