diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0723a1d320..b9401ed5eb 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1125,12 +1125,7 @@ describe('mergeExcludeTools', () => { ]); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); - const config = await loadCliConfig( - settings, - - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']), ); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 99688eead5..14ac3b7cf1 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4604b1ecbc..efa2080743 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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)), ); 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/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index b144f3c679..91b3db666a 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -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" diff --git a/packages/core/src/policy/policies/read-only.toml b/packages/core/src/policy/policies/read-only.toml index 8435e49d0b..66aa4c33ce 100644 --- a/packages/core/src/policy/policies/read-only.toml +++ b/packages/core/src/policy/policies/read-only.toml @@ -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 \ No newline at end of file diff --git a/packages/core/src/policy/topic-policy.test.ts b/packages/core/src/policy/topic-policy.test.ts new file mode 100644 index 0000000000..91450af056 --- /dev/null +++ b/packages/core/src/policy/topic-policy.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/prompts/promptProvider.test.ts b/packages/core/src/prompts/promptProvider.test.ts index d749a41058..a611cc7435 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 { 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).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(`\`${UPDATE_TOPIC_TOOL_NAME}\``); + }); + }); }); diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index d97e636993..3425809583 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -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( diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 27c1fa60a1..a16ef59461 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -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: : \` 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. +- 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: : \` 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: : 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.`; +`; } function mandateExplainBeforeActing(): string { diff --git a/packages/core/src/scheduler/scheduler.test.ts b/packages/core/src/scheduler/scheduler.test.ts index 25b7f3f01a..54562933a8 100644 --- a/packages/core/src/scheduler/scheduler.test.ts +++ b/packages/core/src/scheduler/scheduler.test.ts @@ -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', () => { diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index ea308a26f6..45bc2f82a7 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -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, diff --git a/packages/core/src/tools/definitions/base-declarations.ts b/packages/core/src/tools/definitions/base-declarations.ts index c7c4223546..b4f6732097 100644 --- a/packages/core/src/tools/definitions/base-declarations.ts +++ b/packages/core/src/tools/definitions/base-declarations.ts @@ -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'; diff --git a/packages/core/src/tools/definitions/coreTools.ts b/packages/core/src/tools/definitions/coreTools.ts index 85fc9906e6..d77cc45a7d 100644 --- a/packages/core/src/tools/definitions/coreTools.ts +++ b/packages/core/src/tools/definitions/coreTools.ts @@ -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) // ============================================================================ diff --git a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts index 530f908977..59b1bf7479 100644 --- a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts +++ b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts @@ -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], + }, + }; +} 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 7543adc2ae..b19c157f22 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, + 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(), }; diff --git a/packages/core/src/tools/definitions/types.ts b/packages/core/src/tools/definitions/types.ts index 30cffe5474..42c0cc7028 100644 --- a/packages/core/src/tools/definitions/types.ts +++ b/packages/core/src/tools/definitions/types.ts @@ -50,4 +50,5 @@ export interface CoreToolSet { enter_plan_mode: FunctionDeclaration; exit_plan_mode: () => FunctionDeclaration; activate_skill: (skillNames: string[]) => FunctionDeclaration; + update_topic?: FunctionDeclaration; } diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 1bd97aca9c..f18680cea0 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -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; diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 291f43d908..3d27171ad1 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -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', () => { diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index c91e4ca7e3..a059c964d0 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -30,6 +30,7 @@ import { getToolAliases, WRITE_FILE_TOOL_NAME, EDIT_TOOL_NAME, + UPDATE_TOPIC_TOOL_NAME, } from './tool-names.js'; type ToolParams = Record; @@ -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) { diff --git a/packages/core/src/tools/topicTool.test.ts b/packages/core/src/tools/topicTool.test.ts new file mode 100644 index 0000000000..7c6497a2de --- /dev/null +++ b/packages/core/src/tools/topicTool.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/tools/topicTool.ts b/packages/core/src/tools/topicTool.ts new file mode 100644 index 0000000000..51c7999fba --- /dev/null +++ b/packages/core/src/tools/topicTool.ts @@ -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 { + 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, + ); + } +}