feat(core): implement tool-based topic grouping (Chapters) (#23150)

Co-authored-by: Christian Gunderman <gundermanc@google.com>
This commit is contained in:
Abhijit Balaji
2026-03-27 18:28:25 -07:00
committed by GitHub
parent ae123c547c
commit afc1d50c20
22 changed files with 663 additions and 50 deletions
+12
View File
@@ -92,6 +92,7 @@ vi.mock('../tools/tool-registry', () => {
ToolRegistryMock.prototype.sortTools = vi.fn();
ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed
ToolRegistryMock.prototype.getTool = vi.fn();
ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []);
ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []);
return { ToolRegistry: ToolRegistryMock };
});
@@ -1563,6 +1564,17 @@ describe('Server Config (config.ts)', () => {
expect(config.getSandboxNetworkAccess()).toBe(false);
});
});
it('should have independent TopicState across instances', () => {
const config1 = new Config(baseParams);
const config2 = new Config(baseParams);
config1.topicState.setTopic('Topic 1');
config2.topicState.setTopic('Topic 2');
expect(config1.topicState.getTopic()).toBe('Topic 1');
expect(config2.topicState.getTopic()).toBe('Topic 2');
});
});
describe('GemmaModelRouterSettings', () => {
+6
View File
@@ -36,6 +36,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 { UpdateTopicTool, TopicState } 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';
@@ -728,6 +729,7 @@ export class Config implements McpContext, AgentLoopContext {
private clientVersion: string;
private fileSystemService: FileSystemService;
private trackerService?: TrackerService;
readonly topicState = new TopicState();
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGenerator!: ContentGenerator;
readonly modelConfigService: ModelConfigService;
@@ -3354,6 +3356,10 @@ export class Config implements McpContext, AgentLoopContext {
}
};
maybeRegister(UpdateTopicTool, () =>
registry.registerTool(new UpdateTopicTool(this, 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;
});
@@ -113,6 +113,13 @@ decision = "allow"
priority = 70
modes = ["plan"]
# Topic grouping tool is innocuous and used for UI organization.
[[rule]]
toolName = "update_topic"
decision = "allow"
priority = 70
modes = ["plan"]
[[rule]]
toolName = ["ask_user", "save_memory"]
decision = "ask_user"
@@ -55,4 +55,10 @@ priority = 50
[[rule]]
toolName = ["codebase_investigator", "cli_help", "get_internal_docs"]
decision = "allow"
priority = 50
# Topic grouping tool is innocuous and used for UI organization.
[[rule]]
toolName = "update_topic"
decision = "allow"
priority = 50
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import * as path from 'node:path';
import { loadPoliciesFromToml } from './toml-loader.js';
import { PolicyEngine } from './policy-engine.js';
import { ApprovalMode, PolicyDecision } from './types.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
describe('Topic Tool Policy', () => {
async function loadDefaultPolicies() {
// Path relative to packages/core root
const policiesDir = path.resolve(process.cwd(), 'src/policy/policies');
const getPolicyTier = () => 1; // Default tier
const result = await loadPoliciesFromToml([policiesDir], getPolicyTier);
return result.rules;
}
it('should allow update_topic in DEFAULT mode', async () => {
const rules = await loadDefaultPolicies();
const engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.DEFAULT,
});
const result = await engine.check(
{ name: UPDATE_TOPIC_TOOL_NAME },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should allow update_topic in PLAN mode', async () => {
const rules = await loadDefaultPolicies();
const engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.PLAN,
});
const result = await engine.check(
{ name: UPDATE_TOPIC_TOOL_NAME },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should allow update_topic in YOLO mode', async () => {
const rules = await loadDefaultPolicies();
const engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.YOLO,
});
const result = await engine.check(
{ name: UPDATE_TOPIC_TOOL_NAME },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
});
@@ -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 { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import { TopicState } 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';
@@ -53,6 +55,7 @@ describe('PromptProvider', () => {
).getToolRegistry?.() as unknown as ToolRegistry;
},
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
topicState: new TopicState(),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
storage: {
@@ -73,6 +76,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;
});
@@ -234,4 +239,67 @@ describe('PromptProvider', () => {
expect(prompt).not.toContain('### APPROVED PLAN PRESERVATION');
});
});
describe('Topic & Update Narration', () => {
beforeEach(() => {
mockConfig.topicState.reset();
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true);
(mockConfig.getToolRegistry as ReturnType<typeof vi.fn>).mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue([UPDATE_TOPIC_TOOL_NAME]),
getAllTools: vi.fn().mockReturnValue([
new MockTool({
name: UPDATE_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', () => {
mockConfig.topicState.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,
);
mockConfig.topicState.setTopic('Active Chapter');
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).not.toContain('[Active Topic: Active Chapter]');
});
it('should filter out update_topic tool when narration is disabled', () => {
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(
false,
);
// Simulate registry behavior where it filters out update_topic
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue(
[],
);
vi.mocked(mockConfig.getToolRegistry().getAllTools).mockReturnValue([]);
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).not.toContain(UPDATE_TOPIC_TOOL_NAME);
});
it('should NOT filter out update_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>\`${UPDATE_TOPIC_TOOL_NAME}\`</tool>`);
});
});
});
+13 -2
View File
@@ -57,6 +57,7 @@ export class PromptProvider {
const skills = context.config.getSkillManager().getSkills();
const toolNames = context.toolRegistry.getAllToolNames();
const enabledToolNames = new Set(toolNames);
const approvedPlanPath = context.config.getApprovedPlanPath();
const desiredModel = resolveModel(
@@ -71,7 +72,6 @@ export class PromptProvider {
const activeSnippets = isModernModel ? snippets : legacySnippets;
const contextFilenames = getAllGeminiMdFilenames();
// --- Context Gathering ---
let planModeToolsList = '';
if (isPlanMode) {
const allTools = context.toolRegistry.getAllTools();
@@ -232,7 +232,18 @@ 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 = context.config.topicState.getTopic();
if (activeTopic) {
const sanitizedTopic = activeTopic
.replace(/\n/g, ' ')
.replace(/\]/g, '');
sanitizedPrompt += `\n\n[Active Topic: ${sanitizedTopic}]`;
}
}
// Write back to file if requested
this.maybeWriteSystemMd(
+18 -40
View File
@@ -10,6 +10,9 @@ import {
EDIT_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
MEMORY_TOOL_NAME,
@@ -239,7 +242,9 @@ Use the following guidelines to optimize your search and read patterns.
? mandateTopicUpdateModel()
: mandateExplainBeforeActing()
}
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance(options.hasSkills)}${mandateContinueWork(options.interactive)}
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance(
options.hasSkills,
)}${mandateContinueWork(options.interactive)}
`.trim();
}
@@ -361,7 +366,7 @@ export function renderOperationalGuidelines(
- **Role:** A senior software engineer and collaborative peer programmer.
- **High-Signal Output:** Focus exclusively on **intent** and **technical rationale**. Avoid conversational filler, apologies, and ${
options.topicUpdateNarration
? 'per-tool explanations.'
? 'unnecessary per-tool explanations.'
: 'mechanical tool-use narration (e.g., "I will now call...").'
}
- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
@@ -614,46 +619,19 @@ 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.
## Topic Updates
As you work, the user follows along by reading topic updates that you publish with ${UPDATE_TOPIC_TOOL_NAME}. Keep them informed by doing the following:
- **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.
- Always call ${UPDATE_TOPIC_TOOL_NAME} in your first and last turn. The final turn should always recap what was done.
- Each topic update should give a concise description of what you are doing for the next few turns in the \`${TOPIC_PARAM_SUMMARY}\` parameter.
- Provide topic updates whenever you change "topics". A topic is typically a discrete subgoal and will be every 3 to 10 turns. Do not use ${UPDATE_TOPIC_TOOL_NAME} on every turn.
- The typical user message should call ${UPDATE_TOPIC_TOOL_NAME} 3 or more times. Each corresponds to a distinct phase of the task, such as "Researching X", "Researching Y", "Implementing Z with X", and "Testing Z".
- Remember to call ${UPDATE_TOPIC_TOOL_NAME} when you experience an unexpected event (e.g., a test failure, compilation error, environment issue, or unexpected learning) that requires a strategic detour.
- **Examples:**
- \`update_topic(${TOPIC_PARAM_TITLE}="Researching Parser", ${TOPIC_PARAM_SUMMARY}="I am starting an investigation into the parser timeout bug. My goal is to first understand the current test coverage and then attempt to reproduce the failure. This phase will focus on identifying the bottleneck in the main loop before we move to implementation.")\`
- \`update_topic(${TOPIC_PARAM_TITLE}="Implementing Buffer Fix", ${TOPIC_PARAM_SUMMARY}="I have completed the research phase and identified a race condition in the tokenizer's buffer management. I am now transitioning to implementation. This new chapter will focus on refactoring the buffer logic to handle async chunks safely, followed by unit testing the fix.")\`
- **2. Tool Execution Protocol (Zero-Noise):**
- **No Per-Tool Headers:** It is a violation of protocol to print "Topic:" before every tool call.
- **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.
- **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.
- Without reasoning and tracking in thought blocks, you may lose context.
- Always use the required syntax for thought blocks to ensure they remain hidden from the user interface.
- **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.
**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.`;
`;
}
function mandateExplainBeforeActing(): string {
@@ -74,6 +74,7 @@ import {
type AnyDeclarativeTool,
type AnyToolInvocation,
} from '../tools/tools.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import {
CoreToolCallStatus,
ROOT_SCHEDULER_ID,
@@ -441,6 +442,44 @@ describe('Scheduler (Orchestrator)', () => {
]),
);
});
it('should sort UPDATE_TOPIC_TOOL_NAME to the front of the batch', async () => {
const topicReq: ToolCallRequestInfo = {
callId: 'call-topic',
name: UPDATE_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 update_topic
vi.mocked(mockToolRegistry.getTool).mockImplementation((name) => {
if (name === UPDATE_TOPIC_TOOL_NAME) {
return {
name: UPDATE_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
@@ -26,6 +26,7 @@ import {
type ScheduledToolCall,
} from './types.js';
import { ToolErrorType } from '../tools/tool-error.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import { PolicyDecision, type ApprovalMode } from '../policy/types.js';
import {
ToolConfirmationOutcome,
@@ -302,9 +303,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 === UPDATE_TOPIC_TOOL_NAME) return -1;
if (b.name === UPDATE_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,
@@ -125,3 +125,9 @@ export const PLAN_MODE_PARAM_REASON = 'reason';
// -- sandbox --
export const PARAM_ADDITIONAL_PERMISSIONS = 'additional_permissions';
// -- update_topic --
export const UPDATE_TOPIC_TOOL_NAME = 'update_topic';
export const TOPIC_PARAM_TITLE = 'title';
export const TOPIC_PARAM_SUMMARY = 'summary';
export const TOPIC_PARAM_STRATEGIC_INTENT = 'strategic_intent';
@@ -17,6 +17,7 @@ import {
getShellDeclaration,
getExitPlanModeDeclaration,
getActivateSkillDeclaration,
getUpdateTopicDeclaration,
} from './dynamic-declaration-helpers.js';
// Re-export names for compatibility
@@ -38,6 +39,7 @@ export {
ASK_USER_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
@@ -91,6 +93,9 @@ export {
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './base-declarations.js';
// Re-export sets for compatibility
@@ -221,6 +226,13 @@ export const ENTER_PLAN_MODE_DEFINITION: ToolDefinition = {
overrides: (modelId) => getToolSet(modelId).enter_plan_mode,
};
export const UPDATE_TOPIC_DEFINITION: ToolDefinition = {
get base() {
return getUpdateTopicDeclaration();
},
overrides: (modelId) => getToolSet(modelId).update_topic,
};
// ============================================================================
// DYNAMIC TOOL DEFINITIONS (LEGACY EXPORTS)
// ============================================================================
@@ -24,6 +24,10 @@ import {
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
PARAM_ADDITIONAL_PERMISSIONS,
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './base-declarations.js';
/**
@@ -204,3 +208,34 @@ export function getActivateSkillDeclaration(
parametersJsonSchema: zodToJsonSchema(schema),
};
}
/**
* Returns the FunctionDeclaration for updating the topic context.
*/
export function getUpdateTopicDeclaration(): FunctionDeclaration {
return {
name: UPDATE_TOPIC_TOOL_NAME,
description:
'Manages your narrative flow. Include `title` and `summary` only when starting a new Chapter (logical phase) or shifting strategic intent.',
parametersJsonSchema: {
type: 'object',
properties: {
[TOPIC_PARAM_TITLE]: {
type: 'string',
description: 'The title of the new topic or chapter.',
},
[TOPIC_PARAM_SUMMARY]: {
type: 'string',
description:
'(OPTIONAL) A detailed summary (5-10 sentences) covering both the work completed in the previous topic and the strategic intent of the new topic. This is required when transitioning between topics to maintain continuity.',
},
[TOPIC_PARAM_STRATEGIC_INTENT]: {
type: 'string',
description:
'A mandatory one-sentence statement of your immediate intent.',
},
},
required: [TOPIC_PARAM_STRATEGIC_INTENT],
},
};
}
@@ -78,6 +78,7 @@ import {
getShellDeclaration,
getExitPlanModeDeclaration,
getActivateSkillDeclaration,
getUpdateTopicDeclaration,
} from '../dynamic-declaration-helpers.js';
import {
DEFAULT_MAX_LINES_TEXT_FILE,
@@ -724,4 +725,5 @@ The agent did not use the todo list because this task could be completed by a ti
exit_plan_mode: () => getExitPlanModeDeclaration(),
activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames),
update_topic: getUpdateTopicDeclaration(),
};
@@ -50,4 +50,5 @@ export interface CoreToolSet {
enter_plan_mode: FunctionDeclaration;
exit_plan_mode: () => FunctionDeclaration;
activate_skill: (skillNames: string[]) => FunctionDeclaration;
update_topic?: FunctionDeclaration;
}
+10
View File
@@ -75,6 +75,10 @@ import {
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './definitions/coreTools.js';
export {
@@ -95,6 +99,7 @@ export {
ASK_USER_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
@@ -148,6 +153,9 @@ export {
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
};
export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]);
@@ -253,6 +261,7 @@ export const ALL_BUILTIN_TOOL_NAMES = [
GET_INTERNAL_DOCS_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
] as const;
/**
@@ -269,6 +278,7 @@ export const PLAN_MODE_TOOLS = [
ASK_USER_TOOL_NAME,
ACTIVATE_SKILL_TOOL_NAME,
GET_INTERNAL_DOCS_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
'codebase_investigator',
'cli_help',
] as const;
+34 -1
View File
@@ -19,7 +19,10 @@ import { Config, type ConfigParameters } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import { ToolRegistry, DiscoveredTool } from './tool-registry.js';
import { DISCOVERED_TOOL_PREFIX } from './tool-names.js';
import {
DISCOVERED_TOOL_PREFIX,
UPDATE_TOPIC_TOOL_NAME,
} from './tool-names.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import {
mcpToTool,
@@ -800,6 +803,36 @@ describe('ToolRegistry', () => {
const toolNames = allTools.map((t) => t.name);
expect(toolNames).not.toContain('mcp_test-server_write-mcp-tool');
});
it('should exclude topic tool when narration is disabled in config', () => {
const topicTool = new MockTool({
name: UPDATE_TOPIC_TOOL_NAME,
displayName: 'Topic Tool',
});
toolRegistry.registerTool(topicTool);
vi.spyOn(config, 'isTopicUpdateNarrationEnabled').mockReturnValue(false);
mockConfigGetExcludedTools.mockReturnValue(new Set());
expect(toolRegistry.getAllToolNames()).not.toContain(
UPDATE_TOPIC_TOOL_NAME,
);
expect(toolRegistry.getTool(UPDATE_TOPIC_TOOL_NAME)).toBeUndefined();
});
it('should NOT exclude topic tool when narration is enabled in config', () => {
const topicTool = new MockTool({
name: UPDATE_TOPIC_TOOL_NAME,
displayName: 'Topic Tool',
});
toolRegistry.registerTool(topicTool);
vi.spyOn(config, 'isTopicUpdateNarrationEnabled').mockReturnValue(true);
mockConfigGetExcludedTools.mockReturnValue(new Set());
expect(toolRegistry.getAllToolNames()).toContain(UPDATE_TOPIC_TOOL_NAME);
expect(toolRegistry.getTool(UPDATE_TOPIC_TOOL_NAME)).toBe(topicTool);
});
});
describe('DiscoveredToolInvocation', () => {
+7
View File
@@ -30,6 +30,7 @@ import {
getToolAliases,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
} from './tool-names.js';
type ToolParams = Record<string, unknown>;
@@ -576,6 +577,12 @@ export class ToolRegistry {
),
) ?? new Set([]);
if (tool.name === UPDATE_TOPIC_TOOL_NAME) {
if (!this.config.isTopicUpdateNarrationEnabled()) {
return false;
}
}
const normalizedClassName = tool.constructor.name.replace(/^_+/, '');
const possibleNames = [tool.name, normalizedClassName];
if (tool instanceof DiscoveredMCPTool) {
+133
View File
@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TopicState, UpdateTopicTool } from './topicTool.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { PolicyEngine } from '../policy/policy-engine.js';
import {
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './definitions/base-declarations.js';
import type { Config } from '../config/config.js';
describe('TopicState', () => {
let state: TopicState;
beforeEach(() => {
state = new TopicState();
});
it('should store and retrieve topic title and intent', () => {
expect(state.getTopic()).toBeUndefined();
expect(state.getIntent()).toBeUndefined();
const success = state.setTopic('Test Topic', 'Test Intent');
expect(success).toBe(true);
expect(state.getTopic()).toBe('Test Topic');
expect(state.getIntent()).toBe('Test Intent');
});
it('should sanitize newlines and carriage returns', () => {
state.setTopic('Topic\nWith\r\nLines', 'Intent\nWith\r\nLines');
expect(state.getTopic()).toBe('Topic With Lines');
expect(state.getIntent()).toBe('Intent With Lines');
});
it('should trim whitespace', () => {
state.setTopic(' Spaced Topic ', ' Spaced Intent ');
expect(state.getTopic()).toBe('Spaced Topic');
expect(state.getIntent()).toBe('Spaced Intent');
});
it('should reject empty or whitespace-only inputs', () => {
expect(state.setTopic('', '')).toBe(false);
});
it('should reset topic and intent', () => {
state.setTopic('Test Topic', 'Test Intent');
state.reset();
expect(state.getTopic()).toBeUndefined();
expect(state.getIntent()).toBeUndefined();
});
});
describe('UpdateTopicTool', () => {
let tool: UpdateTopicTool;
let mockMessageBus: MessageBus;
let mockConfig: Config;
beforeEach(() => {
mockMessageBus = new MessageBus(vi.mocked({} as PolicyEngine));
// Mock enough of Config to satisfy the tool
mockConfig = {
topicState: new TopicState(),
} as unknown as Config;
tool = new UpdateTopicTool(mockConfig, mockMessageBus);
});
it('should have correct name and display name', () => {
expect(tool.name).toBe(UPDATE_TOPIC_TOOL_NAME);
expect(tool.displayName).toBe('Update Topic Context');
});
it('should update TopicState and include strategic intent on execute', async () => {
const invocation = tool.build({
[TOPIC_PARAM_TITLE]: 'New Chapter',
[TOPIC_PARAM_SUMMARY]: 'The goal is to implement X. Previously we did Y.',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Initial Move',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Current topic: "New Chapter"');
expect(result.llmContent).toContain(
'Topic summary: The goal is to implement X. Previously we did Y.',
);
expect(result.llmContent).toContain('Strategic Intent: Initial Move');
expect(mockConfig.topicState.getTopic()).toBe('New Chapter');
expect(mockConfig.topicState.getIntent()).toBe('Initial Move');
expect(result.returnDisplay).toContain('## 📂 Topic: **New Chapter**');
expect(result.returnDisplay).toContain('**Summary:**');
expect(result.returnDisplay).toContain(
'> [!STRATEGY]\n> **Intent:** Initial Move',
);
});
it('should render only intent for tactical updates (same topic)', async () => {
mockConfig.topicState.setTopic('New Chapter');
const invocation = tool.build({
[TOPIC_PARAM_TITLE]: 'New Chapter',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Subsequent Move',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).not.toContain('## 📂 Topic:');
expect(result.returnDisplay).toBe(
'> [!STRATEGY]\n> **Intent:** Subsequent Move',
);
expect(result.llmContent).toBe('Strategic Intent: Subsequent Move');
});
it('should return error if strategic_intent is missing', async () => {
try {
tool.build({
[TOPIC_PARAM_TITLE]: 'Title',
});
expect.fail('Should have thrown validation error');
} catch (e: unknown) {
if (e instanceof Error) {
expect(e.message).toContain(
"must have required property 'strategic_intent'",
);
} else {
expect.fail('Expected Error instance');
}
}
expect(mockConfig.topicState.getTopic()).toBeUndefined();
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './definitions/coreTools.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
type ToolResult,
} from './tools.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { debugLogger } from '../utils/debugLogger.js';
import { getUpdateTopicDeclaration } from './definitions/dynamic-declaration-helpers.js';
import type { Config } from '../config/config.js';
/**
* Manages the current active topic title and tactical intent for a session.
* Hosted within the Config instance for session-scoping.
*/
export class TopicState {
private activeTopicTitle?: string;
private activeIntent?: string;
/**
* Sanitizes and sets the topic title and/or intent.
* @returns true if the input was valid and set, false otherwise.
*/
setTopic(title?: string, intent?: string): boolean {
const sanitizedTitle = title?.trim().replace(/[\r\n]+/g, ' ');
const sanitizedIntent = intent?.trim().replace(/[\r\n]+/g, ' ');
if (!sanitizedTitle && !sanitizedIntent) return false;
if (sanitizedTitle) {
this.activeTopicTitle = sanitizedTitle;
}
if (sanitizedIntent) {
this.activeIntent = sanitizedIntent;
}
return true;
}
getTopic(): string | undefined {
return this.activeTopicTitle;
}
getIntent(): string | undefined {
return this.activeIntent;
}
reset(): void {
this.activeTopicTitle = undefined;
this.activeIntent = undefined;
}
}
interface UpdateTopicParams {
[TOPIC_PARAM_TITLE]?: string;
[TOPIC_PARAM_SUMMARY]?: string;
[TOPIC_PARAM_STRATEGIC_INTENT]?: string;
}
class UpdateTopicInvocation extends BaseToolInvocation<
UpdateTopicParams,
ToolResult
> {
constructor(
params: UpdateTopicParams,
messageBus: MessageBus,
toolName: string,
private readonly config: Config,
) {
super(params, messageBus, toolName);
}
getDescription(): string {
const title = this.params[TOPIC_PARAM_TITLE];
const intent = this.params[TOPIC_PARAM_STRATEGIC_INTENT];
if (title) {
return `Update topic to: "${title}"`;
}
return `Update tactical intent: "${intent || '...'}"`;
}
async execute(): Promise<ToolResult> {
const title = this.params[TOPIC_PARAM_TITLE];
const summary = this.params[TOPIC_PARAM_SUMMARY];
const strategicIntent = this.params[TOPIC_PARAM_STRATEGIC_INTENT];
const activeTopic = this.config.topicState.getTopic();
const isNewTopic = !!(
title &&
title.trim() !== '' &&
title.trim() !== activeTopic
);
this.config.topicState.setTopic(title, strategicIntent);
const currentTopic = this.config.topicState.getTopic() || '...';
const currentIntent =
strategicIntent || this.config.topicState.getIntent() || '...';
debugLogger.log(
`[TopicTool] Update: Topic="${currentTopic}", Intent="${currentIntent}", isNew=${isNewTopic}`,
);
let llmContent = '';
let returnDisplay = '';
if (isNewTopic) {
// Handle New Topic Header & Summary
llmContent = `Current topic: "${currentTopic}"\nTopic summary: ${summary || '...'}`;
returnDisplay = `## 📂 Topic: **${currentTopic}**\n\n**Summary:**\n${summary || '...'}`;
if (strategicIntent && strategicIntent.trim()) {
llmContent += `\n\nStrategic Intent: ${strategicIntent.trim()}`;
returnDisplay += `\n\n> [!STRATEGY]\n> **Intent:** ${strategicIntent.trim()}`;
}
} else {
// Tactical update only
llmContent = `Strategic Intent: ${currentIntent}`;
returnDisplay = `> [!STRATEGY]\n> **Intent:** ${currentIntent}`;
}
return {
llmContent,
returnDisplay,
};
}
}
/**
* Tool to update semantic topic context and tactical intent for UI grouping and model focus.
*/
export class UpdateTopicTool extends BaseDeclarativeTool<
UpdateTopicParams,
ToolResult
> {
constructor(
private readonly config: Config,
messageBus: MessageBus,
) {
const declaration = getUpdateTopicDeclaration();
super(
UPDATE_TOPIC_TOOL_NAME,
'Update Topic Context',
declaration.description ?? '',
Kind.Think,
declaration.parametersJsonSchema,
messageBus,
);
}
protected createInvocation(
params: UpdateTopicParams,
messageBus: MessageBus,
): UpdateTopicInvocation {
return new UpdateTopicInvocation(
params,
messageBus,
this.name,
this.config,
);
}
}