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:
Abhijit Balaji
2026-03-19 12:59:48 -07:00
parent fbb17ebf58
commit 731433499b
16 changed files with 508 additions and 32 deletions
+32 -1
View File
@@ -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', () => {
+10
View File
@@ -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>`,
);
});
});
});
+24 -2
View File
@@ -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(
+12 -26
View File
@@ -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', () => {
+9 -1
View File
@@ -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;
}
+5
View File
@@ -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;
/**
+75
View File
@@ -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();
});
});
+112
View File
@@ -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);
}
}