mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-16 21:10:40 -07:00
feat(core): implement tool-based topic grouping (Chapters)
Organize agent work into logical phases (Chapters) using a dedicated tool instead of prompt-only narration. This improves UI organization and provides the model with explicit state tracking. Key changes: - Introduced 'create_new_topic' tool for semantic phase transitions. - Added TopicManager singleton to maintain session chapter state. - Gated the feature behind 'experimental.topicUpdateNarration' flag. - Limited tool registration to Gemini-3 model set. - Modified Scheduler to prioritize topic markers at start of batches. - Injected active topic context into system prompt footers. - Replaced SI narration in snippets with tool-based mandates.
This commit is contained in:
@@ -57,7 +57,10 @@ import {
|
||||
} from '../telemetry/loggers.js';
|
||||
import { RipgrepFallbackEvent } from '../telemetry/types.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { ACTIVATE_SKILL_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import {
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
import type { SkillDefinition } from '../skills/skillLoader.js';
|
||||
import type { McpClientManager } from '../tools/mcp-client-manager.js';
|
||||
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
|
||||
@@ -1664,6 +1667,34 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(config.getSandboxNetworkAccess()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Topic & Update Narration', () => {
|
||||
it('should NOT exclude topic tool when narration is enabled', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
topicUpdateNarration: true,
|
||||
});
|
||||
const excluded = config.getExcludeTools();
|
||||
expect(excluded!.has(CREATE_NEW_TOPIC_TOOL_NAME)).toBe(false);
|
||||
});
|
||||
|
||||
it('should exclude topic tool when narration is disabled', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
topicUpdateNarration: false,
|
||||
});
|
||||
const excluded = config.getExcludeTools();
|
||||
expect(excluded).toBeDefined();
|
||||
expect(excluded!.has(CREATE_NEW_TOPIC_TOOL_NAME)).toBe(true);
|
||||
});
|
||||
|
||||
it('should default to disabled and exclude topic tool', () => {
|
||||
const config = new Config(baseParams);
|
||||
expect(config.isTopicUpdateNarrationEnabled()).toBe(false);
|
||||
const excluded = config.getExcludeTools();
|
||||
expect(excluded!.has(CREATE_NEW_TOPIC_TOOL_NAME)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GemmaModelRouterSettings', () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { OverageStrategy } from '../billing/billing.js';
|
||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { CREATE_NEW_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import { LSTool } from '../tools/ls.js';
|
||||
import { ReadFileTool } from '../tools/read-file.js';
|
||||
import { GrepTool } from '../tools/grep.js';
|
||||
@@ -33,6 +34,7 @@ import { WebFetchTool } from '../tools/web-fetch.js';
|
||||
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
|
||||
import { WebSearchTool } from '../tools/web-search.js';
|
||||
import { AskUserTool } from '../tools/ask-user.js';
|
||||
import { CreateNewTopicTool } from '../tools/topicTool.js';
|
||||
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
|
||||
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
@@ -2025,6 +2027,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
excludeToolsSet.add(tool);
|
||||
}
|
||||
|
||||
if (!this.isTopicUpdateNarrationEnabled()) {
|
||||
excludeToolsSet.add(CREATE_NEW_TOPIC_TOOL_NAME);
|
||||
}
|
||||
|
||||
return excludeToolsSet;
|
||||
}
|
||||
|
||||
@@ -3191,6 +3197,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
};
|
||||
|
||||
maybeRegister(CreateNewTopicTool, () =>
|
||||
registry.registerTool(new CreateNewTopicTool(this.messageBus)),
|
||||
);
|
||||
|
||||
maybeRegister(LSTool, () =>
|
||||
registry.registerTool(new LSTool(this, this.messageBus)),
|
||||
);
|
||||
|
||||
@@ -59,6 +59,11 @@ describe('Core System Prompt Substitution', () => {
|
||||
getSkills: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
|
||||
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
|
||||
isTrackerEnabled: vi.fn().mockReturnValue(false),
|
||||
isModelSteeringEnabled: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import { PREVIEW_GEMINI_MODEL } from '../config/models.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
import { MockTool } from '../test-utils/mock-tool.js';
|
||||
import { CREATE_NEW_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import { TopicManager } from '../tools/topicTool.js';
|
||||
import type { CallableTool } from '@google/genai';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||
@@ -72,6 +74,8 @@ describe('PromptProvider', () => {
|
||||
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
|
||||
getApprovalMode: vi.fn(),
|
||||
isTrackerEnabled: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
@@ -233,4 +237,65 @@ describe('PromptProvider', () => {
|
||||
expect(prompt).not.toContain('### APPROVED PLAN PRESERVATION');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Topic & Update Narration', () => {
|
||||
beforeEach(() => {
|
||||
TopicManager.getInstance().reset();
|
||||
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true);
|
||||
(mockConfig.getToolRegistry as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
getAllToolNames: vi.fn().mockReturnValue([CREATE_NEW_TOPIC_TOOL_NAME]),
|
||||
getAllTools: vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
new MockTool({
|
||||
name: CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
displayName: 'Topic',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
vi.mocked(mockConfig.getHasAccessToPreviewModel).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getGemini31LaunchedSync).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should include active topic context when narration is enabled', () => {
|
||||
TopicManager.getInstance().setTopic('Active Chapter');
|
||||
const provider = new PromptProvider();
|
||||
const prompt = provider.getCoreSystemPrompt(mockConfig);
|
||||
|
||||
expect(prompt).toContain('[Active Topic: Active Chapter]');
|
||||
});
|
||||
|
||||
it('should NOT include active topic context when narration is disabled', () => {
|
||||
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(
|
||||
false,
|
||||
);
|
||||
TopicManager.getInstance().setTopic('Active Chapter');
|
||||
const provider = new PromptProvider();
|
||||
const prompt = provider.getCoreSystemPrompt(mockConfig);
|
||||
|
||||
expect(prompt).not.toContain('[Active Topic: Active Chapter]');
|
||||
});
|
||||
|
||||
it('should filter out create_new_topic tool when narration is disabled', () => {
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
|
||||
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(
|
||||
false,
|
||||
);
|
||||
const provider = new PromptProvider();
|
||||
|
||||
const prompt = provider.getCoreSystemPrompt(mockConfig);
|
||||
expect(prompt).not.toContain(CREATE_NEW_TOPIC_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should NOT filter out create_new_topic tool when narration is enabled', () => {
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
|
||||
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true);
|
||||
const provider = new PromptProvider();
|
||||
const prompt = provider.getCoreSystemPrompt(mockConfig);
|
||||
|
||||
expect(prompt).toContain(
|
||||
`<tool>\`${CREATE_NEW_TOPIC_TOOL_NAME}\`</tool>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,11 +26,13 @@ import {
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
import { resolveModel, supportsModernFeatures } from '../config/models.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
import { getAllGeminiMdFilenames } from '../tools/memoryTool.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { TopicManager } from '../tools/topicTool.js';
|
||||
|
||||
/**
|
||||
* Orchestrates prompt generation by gathering context and building options.
|
||||
@@ -57,6 +59,10 @@ export class PromptProvider {
|
||||
const skills = context.config.getSkillManager().getSkills();
|
||||
const toolNames = context.toolRegistry.getAllToolNames();
|
||||
const enabledToolNames = new Set(toolNames);
|
||||
|
||||
if (!context.config.isTopicUpdateNarrationEnabled()) {
|
||||
enabledToolNames.delete(CREATE_NEW_TOPIC_TOOL_NAME);
|
||||
}
|
||||
const approvedPlanPath = context.config.getApprovedPlanPath();
|
||||
|
||||
const desiredModel = resolveModel(
|
||||
@@ -73,7 +79,15 @@ export class PromptProvider {
|
||||
// --- Context Gathering ---
|
||||
let planModeToolsList = '';
|
||||
if (isPlanMode) {
|
||||
const allTools = context.toolRegistry.getAllTools();
|
||||
const allTools = context.toolRegistry.getAllTools().filter((t) => {
|
||||
if (
|
||||
!context.config.isTopicUpdateNarrationEnabled() &&
|
||||
t.name === CREATE_NEW_TOPIC_TOOL_NAME
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
planModeToolsList = allTools
|
||||
.map((t) => {
|
||||
if (t instanceof DiscoveredMCPTool) {
|
||||
@@ -228,7 +242,15 @@ export class PromptProvider {
|
||||
);
|
||||
|
||||
// Sanitize erratic newlines from composition
|
||||
const sanitizedPrompt = finalPrompt.replace(/\n{3,}/g, '\n\n');
|
||||
let sanitizedPrompt = finalPrompt.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
// Context Reinjection (Active Topic)
|
||||
if (context.config.isTopicUpdateNarrationEnabled()) {
|
||||
const activeTopic = TopicManager.getInstance().getTopic();
|
||||
if (activeTopic) {
|
||||
sanitizedPrompt += `\n\n[Active Topic: ${activeTopic}]`;
|
||||
}
|
||||
}
|
||||
|
||||
// Write back to file if requested
|
||||
this.maybeWriteSystemMd(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
EDIT_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
MEMORY_TOOL_NAME,
|
||||
@@ -578,18 +579,17 @@ function mandateConfirm(interactive: boolean): string {
|
||||
function mandateTopicUpdateModel(): string {
|
||||
return `
|
||||
- **Protocol: Topic Model**
|
||||
You are an agentic system. You must maintain a visible state log that tracks broad logical phases using a specific header format.
|
||||
You are an agentic system. You must organize your work into logical "Chapters" using the \`${CREATE_NEW_TOPIC_TOOL_NAME}\` tool.
|
||||
|
||||
- **1. Topic Initialization & Persistence:**
|
||||
- **The Trigger:** You MUST issue a \`Topic: <Phase> : <Brief Summary>\` header ONLY when beginning a task or when the broad logical nature of the task changes (e.g., transitioning from research to implementation).
|
||||
- **The Format:** Use exactly \`Topic: <Phase> : <Brief Summary>\` (e.g., \`Topic: <Research> : Researching Agent Skills in the repo\`).
|
||||
- **Persistence:** Once a Topic is declared, do NOT repeat it for subsequent tool calls or in subsequent messages within that same phase.
|
||||
- **Start of Task:** Your very first tool execution must be preceded by a Topic header.
|
||||
- **1. Chapter Initialization:**
|
||||
- **The Trigger:** You MUST call \`${CREATE_NEW_TOPIC_TOOL_NAME}\` ONLY when beginning a new task or when the broad logical nature of your work changes (e.g., transitioning from Research to Strategy, or from Strategy to Implementation).
|
||||
- **The Format:** Provide a concise, high-level title for the chapter (e.g., \`create_new_topic(title="Researching Agent Skills")\`).
|
||||
- **Start of Task:** Your very first tool execution in a new session must be \`${CREATE_NEW_TOPIC_TOOL_NAME}\`.
|
||||
|
||||
- **2. Tool Execution Protocol (Zero-Noise):**
|
||||
- **No Per-Tool Headers:** It is a violation of protocol to print "Topic:" before every tool call.
|
||||
- **2. Zero-Noise Execution:**
|
||||
- **No Text Headers:** You are FORBIDDEN from printing "Topic: <Phase>" or any similar text-based headers in your response. The tool handles all UI narration.
|
||||
- **Silent Mode:** No conversational filler, no "I will now...", and no summaries between tools.
|
||||
- Only the Topic header at the start of a broad phase is permitted to break the silence. Everything in between must be silent.
|
||||
- Only the tool execution is permitted to define the state. Everything in between must be silent.
|
||||
|
||||
- **3. Thinking Protocol:**
|
||||
- Use internal thought blocks to keep track of what tools you have called, plan your next steps, and reason about the task.
|
||||
@@ -599,24 +599,10 @@ function mandateTopicUpdateModel(): string {
|
||||
- **4. Completion:**
|
||||
- Only when the entire task is finalized do you provide a **Final Summary**.
|
||||
|
||||
**IMPORTANT: Topic Headers vs. Thoughts**
|
||||
The \`Topic: <Phase> : <Brief Summary>\` header must **NOT** be placed inside a thought block. It must be standard text output so that it is properly rendered and displayed in the UI.
|
||||
**IMPORTANT: Tool Usage vs. Text**
|
||||
The \`${CREATE_NEW_TOPIC_TOOL_NAME}\` tool MUST be called as a proper tool. Do NOT simply write the tool name in your text response.
|
||||
|
||||
**Correct State Log Example:**
|
||||
\`\`\`
|
||||
Topic: <Research> : Researching Agent Skills in the repo
|
||||
<tool_call 1>
|
||||
<tool_call 2>
|
||||
<tool_call 3>
|
||||
|
||||
Topic: <Implementation> : Implementing the skill-creator logic
|
||||
<tool_call 1>
|
||||
<tool_call 2>
|
||||
|
||||
The task is complete. [Final Summary]
|
||||
\`\`\`
|
||||
|
||||
- **Constraint Enforcement:** If you repeat a "Topic:" line without a fundamental shift in work, or if you provide a Topic for every tool call, you have failed the system integrity protocol.`;
|
||||
- **Constraint Enforcement:** If you provide a manual "Topic:" text header, or if you call the tool for every single tool execution without a fundamental shift in work, you have failed the system integrity protocol.`;
|
||||
}
|
||||
|
||||
function mandateExplainBeforeActing(): string {
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
type AnyDeclarativeTool,
|
||||
type AnyToolInvocation,
|
||||
} from '../tools/tools.js';
|
||||
import { CREATE_NEW_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
ROOT_SCHEDULER_ID,
|
||||
@@ -439,6 +440,44 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should sort CREATE_NEW_TOPIC_TOOL_NAME to the front of the batch', async () => {
|
||||
const topicReq: ToolCallRequestInfo = {
|
||||
callId: 'call-topic',
|
||||
name: CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
args: { title: 'New Chapter' },
|
||||
prompt_id: 'p1',
|
||||
isClientInitiated: false,
|
||||
};
|
||||
const otherReq: ToolCallRequestInfo = {
|
||||
callId: 'call-other',
|
||||
name: 'test-tool',
|
||||
args: {},
|
||||
prompt_id: 'p1',
|
||||
isClientInitiated: false,
|
||||
};
|
||||
|
||||
// Mock tool registry to return a tool for create_new_topic
|
||||
vi.mocked(mockToolRegistry.getTool).mockImplementation((name) => {
|
||||
if (name === CREATE_NEW_TOPIC_TOOL_NAME) {
|
||||
return {
|
||||
name: CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
build: vi.fn().mockReturnValue({}),
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
}
|
||||
return mockTool;
|
||||
});
|
||||
|
||||
// Schedule in reverse order (other first, topic second)
|
||||
await scheduler.schedule([otherReq, topicReq], signal);
|
||||
|
||||
// Verify they were enqueued in the correct sorted order (topic first)
|
||||
const enqueueCalls = vi.mocked(mockStateManager.enqueue).mock.calls;
|
||||
const lastCall = enqueueCalls[enqueueCalls.length - 1][0];
|
||||
|
||||
expect(lastCall[0].request.callId).toBe('call-topic');
|
||||
expect(lastCall[1].request.callId).toBe('call-other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase 2: Queue Management', () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type ScheduledToolCall,
|
||||
} from './types.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { CREATE_NEW_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import { PolicyDecision, type ApprovalMode } from '../policy/types.js';
|
||||
import {
|
||||
ToolConfirmationOutcome,
|
||||
@@ -298,9 +299,16 @@ export class Scheduler {
|
||||
this.state.clearBatch();
|
||||
const currentApprovalMode = this.config.getApprovalMode();
|
||||
|
||||
// Sort requests to ensure Topic changes happen before actions in the same batch.
|
||||
const sortedRequests = [...requests].sort((a, b) => {
|
||||
if (a.name === CREATE_NEW_TOPIC_TOOL_NAME) return -1;
|
||||
if (b.name === CREATE_NEW_TOPIC_TOOL_NAME) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
try {
|
||||
const toolRegistry = this.context.toolRegistry;
|
||||
const newCalls: ToolCall[] = requests.map((request) => {
|
||||
const newCalls: ToolCall[] = sortedRequests.map((request) => {
|
||||
const enrichedRequest: ToolCallRequestInfo = {
|
||||
...request,
|
||||
schedulerId: this.schedulerId,
|
||||
|
||||
@@ -122,3 +122,7 @@ export const EXIT_PLAN_PARAM_PLAN_PATH = 'plan_path';
|
||||
// -- enter_plan_mode --
|
||||
export const ENTER_PLAN_MODE_TOOL_NAME = 'enter_plan_mode';
|
||||
export const PLAN_MODE_PARAM_REASON = 'reason';
|
||||
|
||||
// -- create_new_topic --
|
||||
export const CREATE_NEW_TOPIC_TOOL_NAME = 'create_new_topic';
|
||||
export const TOPIC_PARAM_TITLE = 'title';
|
||||
|
||||
@@ -19,8 +19,7 @@ import {
|
||||
getActivateSkillDeclaration,
|
||||
} from './dynamic-declaration-helpers.js';
|
||||
|
||||
// Re-export names for compatibility
|
||||
export {
|
||||
import {
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
LS_TOOL_NAME,
|
||||
@@ -91,8 +90,85 @@ export {
|
||||
PLAN_MODE_PARAM_REASON,
|
||||
EXIT_PLAN_PARAM_PLAN_PATH,
|
||||
SKILL_PARAM_NAME,
|
||||
CREATE_NEW_TOPIC_TOOL_NAME as CREATE_NEW_TOPIC_TOOL_NAME_BASE,
|
||||
TOPIC_PARAM_TITLE,
|
||||
} from './base-declarations.js';
|
||||
|
||||
export {
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
LS_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
SHELL_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
WRITE_TODOS_TOOL_NAME,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
READ_MANY_FILES_TOOL_NAME,
|
||||
MEMORY_TOOL_NAME,
|
||||
GET_INTERNAL_DOCS_TOOL_NAME,
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
CREATE_NEW_TOPIC_TOOL_NAME_BASE as CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
// Shared parameter names
|
||||
PARAM_FILE_PATH,
|
||||
PARAM_DIR_PATH,
|
||||
PARAM_PATTERN,
|
||||
PARAM_CASE_SENSITIVE,
|
||||
PARAM_RESPECT_GIT_IGNORE,
|
||||
PARAM_RESPECT_GEMINI_IGNORE,
|
||||
PARAM_FILE_FILTERING_OPTIONS,
|
||||
PARAM_DESCRIPTION,
|
||||
// Tool-specific parameter names
|
||||
READ_FILE_PARAM_START_LINE,
|
||||
READ_FILE_PARAM_END_LINE,
|
||||
WRITE_FILE_PARAM_CONTENT,
|
||||
GREP_PARAM_INCLUDE_PATTERN,
|
||||
GREP_PARAM_EXCLUDE_PATTERN,
|
||||
GREP_PARAM_NAMES_ONLY,
|
||||
GREP_PARAM_MAX_MATCHES_PER_FILE,
|
||||
GREP_PARAM_TOTAL_MAX_MATCHES,
|
||||
GREP_PARAM_FIXED_STRINGS,
|
||||
GREP_PARAM_CONTEXT,
|
||||
GREP_PARAM_AFTER,
|
||||
GREP_PARAM_BEFORE,
|
||||
GREP_PARAM_NO_IGNORE,
|
||||
EDIT_PARAM_INSTRUCTION,
|
||||
EDIT_PARAM_OLD_STRING,
|
||||
EDIT_PARAM_NEW_STRING,
|
||||
EDIT_PARAM_ALLOW_MULTIPLE,
|
||||
LS_PARAM_IGNORE,
|
||||
SHELL_PARAM_COMMAND,
|
||||
SHELL_PARAM_IS_BACKGROUND,
|
||||
WEB_SEARCH_PARAM_QUERY,
|
||||
WEB_FETCH_PARAM_PROMPT,
|
||||
READ_MANY_PARAM_INCLUDE,
|
||||
READ_MANY_PARAM_EXCLUDE,
|
||||
READ_MANY_PARAM_RECURSIVE,
|
||||
READ_MANY_PARAM_USE_DEFAULT_EXCLUDES,
|
||||
MEMORY_PARAM_FACT,
|
||||
TODOS_PARAM_TODOS,
|
||||
TODOS_ITEM_PARAM_DESCRIPTION,
|
||||
TODOS_ITEM_PARAM_STATUS,
|
||||
DOCS_PARAM_PATH,
|
||||
ASK_USER_PARAM_QUESTIONS,
|
||||
ASK_USER_QUESTION_PARAM_QUESTION,
|
||||
ASK_USER_QUESTION_PARAM_HEADER,
|
||||
ASK_USER_QUESTION_PARAM_TYPE,
|
||||
ASK_USER_QUESTION_PARAM_OPTIONS,
|
||||
ASK_USER_QUESTION_PARAM_MULTI_SELECT,
|
||||
ASK_USER_QUESTION_PARAM_PLACEHOLDER,
|
||||
ASK_USER_OPTION_PARAM_LABEL,
|
||||
ASK_USER_OPTION_PARAM_DESCRIPTION,
|
||||
PLAN_MODE_PARAM_REASON,
|
||||
EXIT_PLAN_PARAM_PLAN_PATH,
|
||||
SKILL_PARAM_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
};
|
||||
|
||||
// Re-export sets for compatibility
|
||||
export { DEFAULT_LEGACY_SET } from './model-family-sets/default-legacy.js';
|
||||
export { GEMINI_3_SET } from './model-family-sets/gemini-3.js';
|
||||
@@ -221,6 +297,18 @@ export const ENTER_PLAN_MODE_DEFINITION: ToolDefinition = {
|
||||
overrides: (modelId) => getToolSet(modelId).enter_plan_mode,
|
||||
};
|
||||
|
||||
export const CREATE_NEW_TOPIC_DEFINITION: ToolDefinition = {
|
||||
get base() {
|
||||
// This tool is only supported on Gemini 3.
|
||||
// For other models, we provide a placeholder that won't be used if gated correctly.
|
||||
return {
|
||||
name: CREATE_NEW_TOPIC_TOOL_NAME_BASE,
|
||||
description: 'NOT SUPPORTED',
|
||||
};
|
||||
},
|
||||
overrides: (modelId) => getToolSet(modelId).create_new_topic,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// DYNAMIC TOOL DEFINITIONS (LEGACY EXPORTS)
|
||||
// ============================================================================
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
SHELL_PARAM_IS_BACKGROUND,
|
||||
EXIT_PLAN_PARAM_PLAN_PATH,
|
||||
SKILL_PARAM_NAME,
|
||||
CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
} from './base-declarations.js';
|
||||
|
||||
/**
|
||||
@@ -171,3 +173,24 @@ export function getActivateSkillDeclaration(
|
||||
parametersJsonSchema: zodToJsonSchema(schema),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the FunctionDeclaration for creating a new topic.
|
||||
*/
|
||||
export function getCreateNewTopicDeclaration(): FunctionDeclaration {
|
||||
return {
|
||||
name: CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
description:
|
||||
'Organizes work into a new "Chapter" or "Topic". Call this when transitioning between major phases (e.g., from Research to Implementation).',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[TOPIC_PARAM_TITLE]: {
|
||||
type: 'string',
|
||||
description: 'The title of the new topic or chapter.',
|
||||
},
|
||||
},
|
||||
required: [TOPIC_PARAM_TITLE],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
getShellDeclaration,
|
||||
getExitPlanModeDeclaration,
|
||||
getActivateSkillDeclaration,
|
||||
getCreateNewTopicDeclaration,
|
||||
} from '../dynamic-declaration-helpers.js';
|
||||
import {
|
||||
DEFAULT_MAX_LINES_TEXT_FILE,
|
||||
@@ -716,4 +717,5 @@ The agent did not use the todo list because this task could be completed by a ti
|
||||
|
||||
exit_plan_mode: (plansDir) => getExitPlanModeDeclaration(plansDir),
|
||||
activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames),
|
||||
create_new_topic: getCreateNewTopicDeclaration(),
|
||||
};
|
||||
|
||||
@@ -49,4 +49,5 @@ export interface CoreToolSet {
|
||||
enter_plan_mode: FunctionDeclaration;
|
||||
exit_plan_mode: (plansDir: string) => FunctionDeclaration;
|
||||
activate_skill: (skillNames: string[]) => FunctionDeclaration;
|
||||
create_new_topic?: FunctionDeclaration;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ASK_USER_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
// Shared parameter names
|
||||
PARAM_FILE_PATH,
|
||||
PARAM_DIR_PATH,
|
||||
@@ -75,6 +76,7 @@ import {
|
||||
PLAN_MODE_PARAM_REASON,
|
||||
EXIT_PLAN_PARAM_PLAN_PATH,
|
||||
SKILL_PARAM_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
} from './definitions/coreTools.js';
|
||||
|
||||
export {
|
||||
@@ -95,6 +97,7 @@ export {
|
||||
ASK_USER_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
// Shared parameter names
|
||||
PARAM_FILE_PATH,
|
||||
PARAM_DIR_PATH,
|
||||
@@ -148,6 +151,7 @@ export {
|
||||
PLAN_MODE_PARAM_REASON,
|
||||
EXIT_PLAN_PARAM_PLAN_PATH,
|
||||
SKILL_PARAM_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
};
|
||||
|
||||
export const LS_TOOL_NAME_LEGACY = 'list_directory'; // Just to be safe if anything used the old exported name directly
|
||||
@@ -251,6 +255,7 @@ export const ALL_BUILTIN_TOOL_NAMES = [
|
||||
GET_INTERNAL_DOCS_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TopicManager, CreateNewTopicTool } from './topicTool.js';
|
||||
import { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { PolicyEngine } from '../policy/policy-engine.js';
|
||||
import {
|
||||
CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
} from './definitions/base-declarations.js';
|
||||
|
||||
describe('TopicManager', () => {
|
||||
beforeEach(() => {
|
||||
TopicManager.getInstance().reset();
|
||||
});
|
||||
|
||||
it('should store and retrieve topic title', () => {
|
||||
const manager = TopicManager.getInstance();
|
||||
expect(manager.getTopic()).toBeUndefined();
|
||||
|
||||
manager.setTopic('Test Topic');
|
||||
expect(manager.getTopic()).toBe('Test Topic');
|
||||
});
|
||||
|
||||
it('should reset topic', () => {
|
||||
const manager = TopicManager.getInstance();
|
||||
manager.setTopic('Test Topic');
|
||||
manager.reset();
|
||||
expect(manager.getTopic()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should be a singleton', () => {
|
||||
const manager1 = TopicManager.getInstance();
|
||||
const manager2 = TopicManager.getInstance();
|
||||
expect(manager1).toBe(manager2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateNewTopicTool', () => {
|
||||
let tool: CreateNewTopicTool;
|
||||
let mockMessageBus: MessageBus;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMessageBus = new MessageBus(vi.mocked({} as PolicyEngine));
|
||||
tool = new CreateNewTopicTool(mockMessageBus);
|
||||
TopicManager.getInstance().reset();
|
||||
});
|
||||
|
||||
it('should have correct name and display name', () => {
|
||||
expect(tool.name).toBe(CREATE_NEW_TOPIC_TOOL_NAME);
|
||||
expect(tool.displayName).toBe('Create New Topic');
|
||||
});
|
||||
|
||||
it('should update TopicManager on execute', async () => {
|
||||
const invocation = tool.build({ [TOPIC_PARAM_TITLE]: 'New Chapter' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('New Chapter');
|
||||
expect(TopicManager.getInstance().getTopic()).toBe('New Chapter');
|
||||
});
|
||||
|
||||
it('should return error if title is missing', async () => {
|
||||
const invocation = tool.build({ [TOPIC_PARAM_TITLE]: '' } as {
|
||||
[TOPIC_PARAM_TITLE]: string;
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(TopicManager.getInstance().getTopic()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
} from './definitions/coreTools.js';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolResult,
|
||||
} from './tools.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { getCreateNewTopicDeclaration } from './definitions/dynamic-declaration-helpers.js';
|
||||
|
||||
/**
|
||||
* Singleton to manage the current active topic title.
|
||||
*/
|
||||
export class TopicManager {
|
||||
private static instance: TopicManager;
|
||||
private activeTopicTitle?: string;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): TopicManager {
|
||||
if (!TopicManager.instance) {
|
||||
TopicManager.instance = new TopicManager();
|
||||
}
|
||||
return TopicManager.instance;
|
||||
}
|
||||
|
||||
setTopic(title: string): void {
|
||||
this.activeTopicTitle = title;
|
||||
}
|
||||
|
||||
getTopic(): string | undefined {
|
||||
return this.activeTopicTitle;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.activeTopicTitle = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface CreateNewTopicParams {
|
||||
[TOPIC_PARAM_TITLE]: string;
|
||||
}
|
||||
|
||||
class CreateNewTopicInvocation extends BaseToolInvocation<
|
||||
CreateNewTopicParams,
|
||||
ToolResult
|
||||
> {
|
||||
getDescription(): string {
|
||||
return `Create new topic: "${this.params[TOPIC_PARAM_TITLE]}"`;
|
||||
}
|
||||
|
||||
async execute(): Promise<ToolResult> {
|
||||
const title = this.params[TOPIC_PARAM_TITLE];
|
||||
|
||||
if (!title) {
|
||||
return {
|
||||
llmContent: 'Error: A valid topic title is required.',
|
||||
returnDisplay: 'Error: A valid topic title is required.',
|
||||
error: {
|
||||
message: 'A valid topic title is required.',
|
||||
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
debugLogger.log(`[TopicTool] Changing topic to: "${title.trim()}"`);
|
||||
TopicManager.getInstance().setTopic(title.trim());
|
||||
|
||||
return {
|
||||
llmContent: `Topic changed to: "${title.trim()}"`,
|
||||
returnDisplay: `Topic changed to: **${title.trim()}**`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool to create a new semantic topic (chapter) for UI grouping.
|
||||
*/
|
||||
export class CreateNewTopicTool extends BaseDeclarativeTool<
|
||||
CreateNewTopicParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(messageBus: MessageBus) {
|
||||
const declaration = getCreateNewTopicDeclaration();
|
||||
super(
|
||||
CREATE_NEW_TOPIC_TOOL_NAME,
|
||||
'Create New Topic',
|
||||
declaration.description ?? '',
|
||||
Kind.Think,
|
||||
declaration.parametersJsonSchema,
|
||||
messageBus,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: CreateNewTopicParams,
|
||||
messageBus: MessageBus,
|
||||
): CreateNewTopicInvocation {
|
||||
return new CreateNewTopicInvocation(params, messageBus, this.name);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user