From 731433499b625b1707d43510f2dff71fe77a7125 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Thu, 19 Mar 2026 12:59:48 -0700 Subject: [PATCH] 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. --- packages/core/src/config/config.test.ts | 33 +++++- packages/core/src/config/config.ts | 10 ++ .../src/core/prompts-substitution.test.ts | 5 + .../core/src/prompts/promptProvider.test.ts | 65 ++++++++++ packages/core/src/prompts/promptProvider.ts | 26 +++- packages/core/src/prompts/snippets.ts | 38 ++---- packages/core/src/scheduler/scheduler.test.ts | 39 ++++++ packages/core/src/scheduler/scheduler.ts | 10 +- .../tools/definitions/base-declarations.ts | 4 + .../core/src/tools/definitions/coreTools.ts | 92 +++++++++++++- .../dynamic-declaration-helpers.ts | 23 ++++ .../definitions/model-family-sets/gemini-3.ts | 2 + packages/core/src/tools/definitions/types.ts | 1 + packages/core/src/tools/tool-names.ts | 5 + packages/core/src/tools/topicTool.test.ts | 75 ++++++++++++ packages/core/src/tools/topicTool.ts | 112 ++++++++++++++++++ 16 files changed, 508 insertions(+), 32 deletions(-) create mode 100644 packages/core/src/tools/topicTool.test.ts create mode 100644 packages/core/src/tools/topicTool.ts diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index e1db5c6e8e..162f82bb25 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 051c56228e..5cd1525c03 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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)), ); diff --git a/packages/core/src/core/prompts-substitution.test.ts b/packages/core/src/core/prompts-substitution.test.ts index 9bad6a066d..64eb8d939f 100644 --- a/packages/core/src/core/prompts-substitution.test.ts +++ b/packages/core/src/core/prompts-substitution.test.ts @@ -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; }); diff --git a/packages/core/src/prompts/promptProvider.test.ts b/packages/core/src/prompts/promptProvider.test.ts index 700062de50..d9ef21c99f 100644 --- a/packages/core/src/prompts/promptProvider.test.ts +++ b/packages/core/src/prompts/promptProvider.test.ts @@ -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).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( + `\`${CREATE_NEW_TOPIC_TOOL_NAME}\``, + ); + }); + }); }); diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index bd884aeab5..f869bcfa48 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -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( diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index d5ff8714b0..0be80dc205 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -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: : \` 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: : \` (e.g., \`Topic: : 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: " 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: : \` 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: : Researching Agent Skills in the repo - - - - -Topic: : Implementing the skill-creator logic - - - -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 { diff --git a/packages/core/src/scheduler/scheduler.test.ts b/packages/core/src/scheduler/scheduler.test.ts index 35cfdc3af7..11a4ab5cd6 100644 --- a/packages/core/src/scheduler/scheduler.test.ts +++ b/packages/core/src/scheduler/scheduler.test.ts @@ -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', () => { diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index cc14e3d875..d37d88d2b9 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -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, diff --git a/packages/core/src/tools/definitions/base-declarations.ts b/packages/core/src/tools/definitions/base-declarations.ts index b39dc42286..c0a01ff6e0 100644 --- a/packages/core/src/tools/definitions/base-declarations.ts +++ b/packages/core/src/tools/definitions/base-declarations.ts @@ -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'; diff --git a/packages/core/src/tools/definitions/coreTools.ts b/packages/core/src/tools/definitions/coreTools.ts index b5121ca5d2..c254bf3881 100644 --- a/packages/core/src/tools/definitions/coreTools.ts +++ b/packages/core/src/tools/definitions/coreTools.ts @@ -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) // ============================================================================ diff --git a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts index 79c66d81f6..56f4d57a68 100644 --- a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts +++ b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts @@ -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], + }, + }; +} diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index cac98a90b3..c792cf9fef 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -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(), }; diff --git a/packages/core/src/tools/definitions/types.ts b/packages/core/src/tools/definitions/types.ts index a9bd3d85d7..547be60997 100644 --- a/packages/core/src/tools/definitions/types.ts +++ b/packages/core/src/tools/definitions/types.ts @@ -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; } diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index e818881662..dd5061019a 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -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; /** diff --git a/packages/core/src/tools/topicTool.test.ts b/packages/core/src/tools/topicTool.test.ts new file mode 100644 index 0000000000..1141b6e1c4 --- /dev/null +++ b/packages/core/src/tools/topicTool.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/tools/topicTool.ts b/packages/core/src/tools/topicTool.ts new file mode 100644 index 0000000000..af1f5f62d2 --- /dev/null +++ b/packages/core/src/tools/topicTool.ts @@ -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 { + 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); + } +}