diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 49954da8c6..82ee987eb2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1014,6 +1014,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`experimental.taskTracker`** (boolean): + - **Description:** Enable task tracker tools. + - **Default:** `false` + - **Requires restart:** Yes + - **`experimental.modelSteering`** (boolean): - **Description:** Enable model steering (user hints) to guide the model during tool execution. diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index b478d67478..4f48c696b4 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -830,6 +830,7 @@ export async function loadCliConfig( enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, + tracker: settings.experimental?.taskTracker, directWebFetch: settings.experimental?.directWebFetch, planSettings: settings.general?.plan?.directory ? settings.general.plan diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 660866c0e3..fb0520d334 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1806,6 +1806,15 @@ const SETTINGS_SCHEMA = { description: 'Enable planning features (Plan Mode and tools).', showInDialog: true, }, + taskTracker: { + type: 'boolean', + label: 'Task Tracker', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enable task tracker tools.', + showInDialog: false, + }, modelSteering: { type: 'boolean', label: 'Model Steering', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index baf6875270..cff1eb2714 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -70,6 +70,14 @@ import { StandardFileSystemService, type FileSystemService, } from '../services/fileSystemService.js'; +import { + TrackerCreateTaskTool, + TrackerUpdateTaskTool, + TrackerGetTaskTool, + TrackerListTasksTool, + TrackerAddDependencyTool, + TrackerVisualizeTool, +} from '../tools/trackerTools.js'; import { logRipgrepFallback, logFlashFallback, @@ -96,6 +104,7 @@ import { } from '../services/modelConfigService.js'; import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js'; import { ContextManager } from '../services/contextManager.js'; +import { TrackerService } from '../services/trackerService.js'; import type { GenerateContentParameters } from '@google/genai'; // Re-export OAuth config type @@ -572,6 +581,7 @@ export interface ConfigParameters { toolOutputMasking?: Partial; disableLLMCorrection?: boolean; plan?: boolean; + tracker?: boolean; planSettings?: PlanSettings; modelSteering?: boolean; onModelChange?: (model: string) => void; @@ -605,6 +615,7 @@ export class Config implements McpContext { private sessionId: string; private clientVersion: string; private fileSystemService: FileSystemService; + private trackerService?: TrackerService; private contentGeneratorConfig!: ContentGeneratorConfig; private contentGenerator!: ContentGenerator; readonly modelConfigService: ModelConfigService; @@ -783,6 +794,7 @@ export class Config implements McpContext { private readonly experimentalJitContext: boolean; private readonly disableLLMCorrection: boolean; private readonly planEnabled: boolean; + private readonly trackerEnabled: boolean; private readonly planModeRoutingEnabled: boolean; private readonly modelSteering: boolean; private contextManager?: ContextManager; @@ -873,6 +885,7 @@ export class Config implements McpContext { this.agents = params.agents ?? {}; this.disableLLMCorrection = params.disableLLMCorrection ?? true; this.planEnabled = params.plan ?? false; + this.trackerEnabled = params.tracker ?? false; this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true; this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true; this.skillsSupport = params.skillsSupport ?? true; @@ -2193,6 +2206,15 @@ export class Config implements McpContext { return this.bugCommand; } + getTrackerService(): TrackerService { + if (!this.trackerService) { + this.trackerService = new TrackerService( + this.storage.getProjectTempTrackerDir(), + ); + } + return this.trackerService; + } + getFileService(): FileDiscoveryService { if (!this.fileDiscoveryService) { this.fileDiscoveryService = new FileDiscoveryService(this.targetDir, { @@ -2260,6 +2282,10 @@ export class Config implements McpContext { return this.planEnabled; } + isTrackerEnabled(): boolean { + return this.trackerEnabled; + } + getApprovedPlanPath(): string | undefined { return this.approvedPlanPath; } @@ -2825,6 +2851,29 @@ export class Config implements McpContext { ); } + if (this.isTrackerEnabled()) { + maybeRegister(TrackerCreateTaskTool, () => + registry.registerTool(new TrackerCreateTaskTool(this, this.messageBus)), + ); + maybeRegister(TrackerUpdateTaskTool, () => + registry.registerTool(new TrackerUpdateTaskTool(this, this.messageBus)), + ); + maybeRegister(TrackerGetTaskTool, () => + registry.registerTool(new TrackerGetTaskTool(this, this.messageBus)), + ); + maybeRegister(TrackerListTasksTool, () => + registry.registerTool(new TrackerListTasksTool(this, this.messageBus)), + ); + maybeRegister(TrackerAddDependencyTool, () => + registry.registerTool( + new TrackerAddDependencyTool(this, this.messageBus), + ), + ); + maybeRegister(TrackerVisualizeTool, () => + registry.registerTool(new TrackerVisualizeTool(this, this.messageBus)), + ); + } + // Register Subagents as Tools this.registerSubAgentTools(registry); diff --git a/packages/core/src/config/trackerFeatureFlag.test.ts b/packages/core/src/config/trackerFeatureFlag.test.ts new file mode 100644 index 0000000000..c91dae517f --- /dev/null +++ b/packages/core/src/config/trackerFeatureFlag.test.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { Config } from './config.js'; +import { TRACKER_CREATE_TASK_TOOL_NAME } from '../tools/tool-names.js'; +import * as os from 'node:os'; + +describe('Config Tracker Feature Flag', () => { + const baseParams = { + sessionId: 'test-session', + targetDir: os.tmpdir(), + cwd: os.tmpdir(), + model: 'gemini-1.5-pro', + debugMode: false, + }; + + it('should not register tracker tools by default', async () => { + const config = new Config(baseParams); + await config.initialize(); + const registry = config.getToolRegistry(); + expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeUndefined(); + }); + + it('should register tracker tools when tracker is enabled', async () => { + const config = new Config({ + ...baseParams, + tracker: true, + }); + await config.initialize(); + const registry = config.getToolRegistry(); + expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeDefined(); + }); + + it('should not register tracker tools when tracker is explicitly disabled', async () => { + const config = new Config({ + ...baseParams, + tracker: false, + }); + await config.initialize(); + const registry = config.getToolRegistry(); + expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8ce5e77d81..c6353256e8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -121,6 +121,8 @@ export * from './services/chatRecordingService.js'; export * from './services/fileSystemService.js'; export * from './services/sessionSummaryUtils.js'; export * from './services/contextManager.js'; +export * from './services/trackerService.js'; +export * from './services/trackerTypes.js'; export * from './skills/skillManager.js'; export * from './skills/skillLoader.js'; @@ -167,6 +169,7 @@ export * from './tools/read-many-files.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-tool.js'; export * from './tools/write-todos.js'; +export * from './tools/trackerTools.js'; export * from './tools/activate-skill.js'; export * from './tools/ask-user.js'; diff --git a/packages/core/src/services/trackerService.ts b/packages/core/src/services/trackerService.ts index 3203b759e1..06e890175f 100644 --- a/packages/core/src/services/trackerService.ts +++ b/packages/core/src/services/trackerService.ts @@ -50,6 +50,15 @@ export class TrackerService { id, }; + if (task.parentId) { + const parentList = await this.listTasks(); + if (!parentList.find((t) => t.id === task.parentId)) { + throw new Error(`Parent task with ID ${task.parentId} not found.`); + } + } + + TrackerTaskSchema.parse(task); + await this.saveTask(task); return task; } @@ -70,7 +79,8 @@ export class TrackerService { error && typeof error === 'object' && 'code' in error && - error.code === 'ENOENT' + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (error as NodeJS.ErrnoException).code === 'ENOENT' ) { return null; } @@ -130,26 +140,48 @@ export class TrackerService { id: string, updates: Partial, ): Promise { - const task = await this.getTask(id); + const isClosing = updates.status === TaskStatus.CLOSED; + const changingDependencies = updates.dependencies !== undefined; + + let taskMap: Map | undefined; + + if (isClosing || changingDependencies) { + const allTasks = await this.listTasks(); + taskMap = new Map(allTasks.map((t) => [t.id, t])); + } + + const task = taskMap ? taskMap.get(id) : await this.getTask(id); + if (!task) { throw new Error(`Task with ID ${id} not found.`); } - const updatedTask = { ...task, ...updates }; + const updatedTask = { ...task, ...updates, id: task.id }; - // Validate status transition if closing - if ( - updatedTask.status === TaskStatus.CLOSED && - task.status !== TaskStatus.CLOSED - ) { - await this.validateCanClose(updatedTask); + if (updatedTask.parentId) { + const parentExists = taskMap + ? taskMap.has(updatedTask.parentId) + : !!(await this.getTask(updatedTask.parentId)); + if (!parentExists) { + throw new Error( + `Parent task with ID ${updatedTask.parentId} not found.`, + ); + } } - // Validate circular dependencies if dependencies changed - if (updates.dependencies) { - await this.validateNoCircularDependencies(updatedTask); + if (taskMap) { + if (isClosing && task.status !== TaskStatus.CLOSED) { + this.validateCanClose(updatedTask, taskMap); + } + + if (changingDependencies) { + taskMap.set(updatedTask.id, updatedTask); + this.validateNoCircularDependencies(updatedTask, taskMap); + } } + TrackerTaskSchema.parse(updatedTask); + await this.saveTask(updatedTask); return updatedTask; } @@ -165,9 +197,12 @@ export class TrackerService { /** * Validates that a task can be closed (all dependencies must be closed). */ - private async validateCanClose(task: TrackerTask): Promise { + private validateCanClose( + task: TrackerTask, + taskMap: Map, + ): void { for (const depId of task.dependencies) { - const dep = await this.getTask(depId); + const dep = taskMap.get(depId); if (!dep) { throw new Error(`Dependency ${depId} not found for task ${task.id}.`); } @@ -182,16 +217,10 @@ export class TrackerService { /** * Validates that there are no circular dependencies. */ - private async validateNoCircularDependencies( + private validateNoCircularDependencies( task: TrackerTask, - ): Promise { - const allTasks = await this.listTasks(); - const taskMap = new Map( - allTasks.map((t) => [t.id, t]), - ); - // Ensure the current (possibly unsaved) task state is used - taskMap.set(task.id, task); - + taskMap: Map, + ): void { const visited = new Set(); const stack = new Set(); @@ -209,10 +238,11 @@ export class TrackerService { stack.add(currentId); const currentTask = taskMap.get(currentId); - if (currentTask) { - for (const depId of currentTask.dependencies) { - check(depId); - } + if (!currentTask) { + throw new Error(`Dependency ${currentId} not found.`); + } + for (const depId of currentTask.dependencies) { + check(depId); } stack.delete(currentId); diff --git a/packages/core/src/tools/definitions/trackerTools.ts b/packages/core/src/tools/definitions/trackerTools.ts new file mode 100644 index 0000000000..e136d90d04 --- /dev/null +++ b/packages/core/src/tools/definitions/trackerTools.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ToolDefinition } from './types.js'; +import { + TRACKER_CREATE_TASK_TOOL_NAME, + TRACKER_UPDATE_TASK_TOOL_NAME, + TRACKER_GET_TASK_TOOL_NAME, + TRACKER_LIST_TASKS_TOOL_NAME, + TRACKER_ADD_DEPENDENCY_TOOL_NAME, + TRACKER_VISUALIZE_TOOL_NAME, +} from '../tool-names.js'; + +export const TRACKER_CREATE_TASK_DEFINITION: ToolDefinition = { + base: { + name: TRACKER_CREATE_TASK_TOOL_NAME, + description: 'Creates a new task in the tracker.', + parametersJsonSchema: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Short title of the task.', + }, + description: { + type: 'string', + description: 'Detailed description of the task.', + }, + type: { + type: 'string', + enum: ['epic', 'task', 'bug'], + description: 'Type of the task.', + }, + parentId: { + type: 'string', + description: 'Optional ID of the parent task.', + }, + dependencies: { + type: 'array', + items: { type: 'string' }, + description: 'Optional list of task IDs that this task depends on.', + }, + }, + required: ['title', 'description', 'type'], + }, + }, +}; + +export const TRACKER_UPDATE_TASK_DEFINITION: ToolDefinition = { + base: { + name: TRACKER_UPDATE_TASK_TOOL_NAME, + description: 'Updates an existing task in the tracker.', + parametersJsonSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The 6-character hex ID of the task to update.', + }, + title: { + type: 'string', + description: 'New title for the task.', + }, + description: { + type: 'string', + description: 'New description for the task.', + }, + status: { + type: 'string', + enum: ['open', 'in_progress', 'blocked', 'closed'], + description: 'New status for the task.', + }, + dependencies: { + type: 'array', + items: { type: 'string' }, + description: 'New list of dependency IDs.', + }, + }, + required: ['id'], + }, + }, +}; + +export const TRACKER_GET_TASK_DEFINITION: ToolDefinition = { + base: { + name: TRACKER_GET_TASK_TOOL_NAME, + description: 'Retrieves details for a specific task.', + parametersJsonSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The 6-character hex ID of the task.', + }, + }, + required: ['id'], + }, + }, +}; + +export const TRACKER_LIST_TASKS_DEFINITION: ToolDefinition = { + base: { + name: TRACKER_LIST_TASKS_TOOL_NAME, + description: + 'Lists tasks in the tracker, optionally filtered by status, type, or parent.', + parametersJsonSchema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['open', 'in_progress', 'blocked', 'closed'], + description: 'Filter by status.', + }, + type: { + type: 'string', + enum: ['epic', 'task', 'bug'], + description: 'Filter by type.', + }, + parentId: { + type: 'string', + description: 'Filter by parent task ID.', + }, + }, + }, + }, +}; + +export const TRACKER_ADD_DEPENDENCY_DEFINITION: ToolDefinition = { + base: { + name: TRACKER_ADD_DEPENDENCY_TOOL_NAME, + description: 'Adds a dependency between two tasks.', + parametersJsonSchema: { + type: 'object', + properties: { + taskId: { + type: 'string', + description: 'The ID of the task that has a dependency.', + }, + dependencyId: { + type: 'string', + description: 'The ID of the task that is being depended upon.', + }, + }, + required: ['taskId', 'dependencyId'], + }, + }, +}; + +export const TRACKER_VISUALIZE_DEFINITION: ToolDefinition = { + base: { + name: TRACKER_VISUALIZE_TOOL_NAME, + description: 'Renders an ASCII tree visualization of the task graph.', + parametersJsonSchema: { + type: 'object', + properties: {}, + }, + }, +}; diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 21a8fc9713..c539532fd1 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -154,6 +154,13 @@ export const LS_TOOL_NAME_LEGACY = 'list_directory'; // Just to be safe if anyth export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]); +export const TRACKER_CREATE_TASK_TOOL_NAME = 'tracker_create_task'; +export const TRACKER_UPDATE_TASK_TOOL_NAME = 'tracker_update_task'; +export const TRACKER_GET_TASK_TOOL_NAME = 'tracker_get_task'; +export const TRACKER_LIST_TASKS_TOOL_NAME = 'tracker_list_tasks'; +export const TRACKER_ADD_DEPENDENCY_TOOL_NAME = 'tracker_add_dependency'; +export const TRACKER_VISUALIZE_TOOL_NAME = 'tracker_visualize'; + // Tool Display Names export const WRITE_FILE_DISPLAY_NAME = 'WriteFile'; export const EDIT_DISPLAY_NAME = 'Edit'; @@ -213,11 +220,32 @@ export const ALL_BUILTIN_TOOL_NAMES = [ MEMORY_TOOL_NAME, ACTIVATE_SKILL_TOOL_NAME, ASK_USER_TOOL_NAME, + TRACKER_CREATE_TASK_TOOL_NAME, + TRACKER_UPDATE_TASK_TOOL_NAME, + TRACKER_GET_TASK_TOOL_NAME, + TRACKER_LIST_TASKS_TOOL_NAME, + TRACKER_ADD_DEPENDENCY_TOOL_NAME, + TRACKER_VISUALIZE_TOOL_NAME, GET_INTERNAL_DOCS_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME, ] as const; +/** + * Read-only tools available in Plan Mode. + * This list is used to dynamically generate the Plan Mode prompt, + * filtered by what tools are actually enabled in the current configuration. + */ +export const PLAN_MODE_TOOLS = [ + GLOB_TOOL_NAME, + GREP_TOOL_NAME, + READ_FILE_TOOL_NAME, + LS_TOOL_NAME, + WEB_SEARCH_TOOL_NAME, + ASK_USER_TOOL_NAME, + ACTIVATE_SKILL_TOOL_NAME, +] as const; + /** * Validates if a tool name is syntactically valid. * Checks against built-in tools, discovered tools, and MCP naming conventions. diff --git a/packages/core/src/tools/trackerTools.test.ts b/packages/core/src/tools/trackerTools.test.ts new file mode 100644 index 0000000000..ec0bd0e889 --- /dev/null +++ b/packages/core/src/tools/trackerTools.test.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Config } from '../config/config.js'; +import { MessageBus } from '../confirmation-bus/message-bus.js'; +import type { PolicyEngine } from '../policy/policy-engine.js'; +import { + TrackerCreateTaskTool, + TrackerListTasksTool, + TrackerUpdateTaskTool, + TrackerVisualizeTool, + TrackerAddDependencyTool, +} from './trackerTools.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +import { TaskStatus, TaskType } from '../services/trackerTypes.js'; + +describe('Tracker Tools Integration', () => { + let tempDir: string; + let config: Config; + let messageBus: MessageBus; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tracker-tools-test-')); + config = new Config({ + sessionId: 'test-session', + targetDir: tempDir, + cwd: tempDir, + model: 'gemini-3-flash', + debugMode: false, + }); + messageBus = new MessageBus(null as unknown as PolicyEngine, false); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + const getSignal = () => new AbortController().signal; + + it('creates and lists tasks', async () => { + const createTool = new TrackerCreateTaskTool(config, messageBus); + const createResult = await createTool.buildAndExecute( + { + title: 'Test Task', + description: 'Test Description', + type: TaskType.TASK, + }, + getSignal(), + ); + + expect(createResult.llmContent).toContain('Created task'); + + const listTool = new TrackerListTasksTool(config, messageBus); + const listResult = await listTool.buildAndExecute({}, getSignal()); + expect(listResult.llmContent).toContain('Test Task'); + expect(listResult.llmContent).toContain(`(${TaskStatus.OPEN})`); + }); + + it('updates task status', async () => { + const createTool = new TrackerCreateTaskTool(config, messageBus); + await createTool.buildAndExecute( + { + title: 'Update Me', + description: '...', + type: TaskType.TASK, + }, + getSignal(), + ); + + const tasks = await config.getTrackerService().listTasks(); + const taskId = tasks[0].id; + + const updateTool = new TrackerUpdateTaskTool(config, messageBus); + const updateResult = await updateTool.buildAndExecute( + { + id: taskId, + status: TaskStatus.IN_PROGRESS, + }, + getSignal(), + ); + + expect(updateResult.llmContent).toContain( + `Status: ${TaskStatus.IN_PROGRESS}`, + ); + + const task = await config.getTrackerService().getTask(taskId); + expect(task?.status).toBe(TaskStatus.IN_PROGRESS); + }); + + it('adds dependencies and visualizes the graph', async () => { + const createTool = new TrackerCreateTaskTool(config, messageBus); + + // Create Parent + await createTool.buildAndExecute( + { + title: 'Parent Task', + description: '...', + type: TaskType.TASK, + }, + getSignal(), + ); + + // Create Child + await createTool.buildAndExecute( + { + title: 'Child Task', + description: '...', + type: TaskType.TASK, + }, + getSignal(), + ); + + const tasks = await config.getTrackerService().listTasks(); + const parentId = tasks.find((t) => t.title === 'Parent Task')!.id; + const childId = tasks.find((t) => t.title === 'Child Task')!.id; + + // Add Dependency + const addDepTool = new TrackerAddDependencyTool(config, messageBus); + await addDepTool.buildAndExecute( + { + taskId: parentId, + dependencyId: childId, + }, + getSignal(), + ); + + const updatedParent = await config.getTrackerService().getTask(parentId); + expect(updatedParent?.dependencies).toContain(childId); + + // Visualize + const vizTool = new TrackerVisualizeTool(config, messageBus); + const vizResult = await vizTool.buildAndExecute({}, getSignal()); + + expect(vizResult.llmContent).toContain('Parent Task'); + expect(vizResult.llmContent).toContain('Child Task'); + expect(vizResult.llmContent).toContain(childId); + }); +}); diff --git a/packages/core/src/tools/trackerTools.ts b/packages/core/src/tools/trackerTools.ts new file mode 100644 index 0000000000..2b9b301c53 --- /dev/null +++ b/packages/core/src/tools/trackerTools.ts @@ -0,0 +1,606 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { + TRACKER_ADD_DEPENDENCY_DEFINITION, + TRACKER_CREATE_TASK_DEFINITION, + TRACKER_GET_TASK_DEFINITION, + TRACKER_LIST_TASKS_DEFINITION, + TRACKER_UPDATE_TASK_DEFINITION, + TRACKER_VISUALIZE_DEFINITION, +} from './definitions/trackerTools.js'; +import { resolveToolDeclaration } from './definitions/resolver.js'; +import { + TRACKER_ADD_DEPENDENCY_TOOL_NAME, + TRACKER_CREATE_TASK_TOOL_NAME, + TRACKER_GET_TASK_TOOL_NAME, + TRACKER_LIST_TASKS_TOOL_NAME, + TRACKER_UPDATE_TASK_TOOL_NAME, + TRACKER_VISUALIZE_TOOL_NAME, +} from './tool-names.js'; +import type { ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolErrorType } from './tool-error.js'; +import type { TrackerTask, TaskType } from '../services/trackerTypes.js'; +import { TaskStatus } from '../services/trackerTypes.js'; + +// --- tracker_create_task --- + +interface CreateTaskParams { + title: string; + description: string; + type: TaskType; + parentId?: string; + dependencies?: string[]; +} + +class TrackerCreateTaskInvocation extends BaseToolInvocation< + CreateTaskParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: CreateTaskParams, + messageBus: MessageBus, + toolName: string, + ) { + super(params, messageBus, toolName); + } + + private get service() { + return this.config.getTrackerService(); + } + getDescription(): string { + return `Creating task: ${this.params.title}`; + } + + override async execute(_signal: AbortSignal): Promise { + try { + const task = await this.service.createTask({ + title: this.params.title, + description: this.params.description, + type: this.params.type, + status: TaskStatus.OPEN, + parentId: this.params.parentId, + dependencies: this.params.dependencies ?? [], + }); + return { + llmContent: `Created task ${task.id}: ${task.title}`, + returnDisplay: `Created task ${task.id}.`, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + llmContent: `Error creating task: ${errorMessage}`, + returnDisplay: 'Failed to create task.', + error: { + message: errorMessage, + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } + } +} + +export class TrackerCreateTaskTool extends BaseDeclarativeTool< + CreateTaskParams, + ToolResult +> { + static readonly Name = TRACKER_CREATE_TASK_TOOL_NAME; + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + TrackerCreateTaskTool.Name, + 'Create Task', + TRACKER_CREATE_TASK_DEFINITION.base.description!, + Kind.Edit, + TRACKER_CREATE_TASK_DEFINITION.base.parametersJsonSchema, + messageBus, + ); + } + protected createInvocation(params: CreateTaskParams, messageBus: MessageBus) { + return new TrackerCreateTaskInvocation( + this.config, + params, + messageBus, + this.name, + ); + } + override getSchema(modelId?: string) { + return resolveToolDeclaration(TRACKER_CREATE_TASK_DEFINITION, modelId); + } +} + +// --- tracker_update_task --- + +interface UpdateTaskParams { + id: string; + title?: string; + description?: string; + status?: TaskStatus; + dependencies?: string[]; +} + +class TrackerUpdateTaskInvocation extends BaseToolInvocation< + UpdateTaskParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: UpdateTaskParams, + messageBus: MessageBus, + toolName: string, + ) { + super(params, messageBus, toolName); + } + + private get service() { + return this.config.getTrackerService(); + } + getDescription(): string { + return `Updating task ${this.params.id}`; + } + + override async execute(_signal: AbortSignal): Promise { + const { id, ...updates } = this.params; + try { + const task = await this.service.updateTask(id, updates); + return { + llmContent: `Updated task ${task.id}. Status: ${task.status}`, + returnDisplay: `Updated task ${task.id}.`, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + llmContent: `Error updating task: ${errorMessage}`, + returnDisplay: 'Failed to update task.', + error: { + message: errorMessage, + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } + } +} + +export class TrackerUpdateTaskTool extends BaseDeclarativeTool< + UpdateTaskParams, + ToolResult +> { + static readonly Name = TRACKER_UPDATE_TASK_TOOL_NAME; + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + TrackerUpdateTaskTool.Name, + 'Update Task', + TRACKER_UPDATE_TASK_DEFINITION.base.description!, + Kind.Edit, + TRACKER_UPDATE_TASK_DEFINITION.base.parametersJsonSchema, + messageBus, + ); + } + protected createInvocation(params: UpdateTaskParams, messageBus: MessageBus) { + return new TrackerUpdateTaskInvocation( + this.config, + params, + messageBus, + this.name, + ); + } + override getSchema(modelId?: string) { + return resolveToolDeclaration(TRACKER_UPDATE_TASK_DEFINITION, modelId); + } +} + +// --- tracker_get_task --- + +interface GetTaskParams { + id: string; +} + +class TrackerGetTaskInvocation extends BaseToolInvocation< + GetTaskParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: GetTaskParams, + messageBus: MessageBus, + toolName: string, + ) { + super(params, messageBus, toolName); + } + + private get service() { + return this.config.getTrackerService(); + } + getDescription(): string { + return `Retrieving task ${this.params.id}`; + } + + override async execute(_signal: AbortSignal): Promise { + const task = await this.service.getTask(this.params.id); + if (!task) { + return { + llmContent: `Task ${this.params.id} not found.`, + returnDisplay: 'Task not found.', + }; + } + return { + llmContent: JSON.stringify(task, null, 2), + returnDisplay: `Retrieved task ${task.id}.`, + }; + } +} + +export class TrackerGetTaskTool extends BaseDeclarativeTool< + GetTaskParams, + ToolResult +> { + static readonly Name = TRACKER_GET_TASK_TOOL_NAME; + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + TrackerGetTaskTool.Name, + 'Get Task', + TRACKER_GET_TASK_DEFINITION.base.description!, + Kind.Read, + TRACKER_GET_TASK_DEFINITION.base.parametersJsonSchema, + messageBus, + ); + } + protected createInvocation(params: GetTaskParams, messageBus: MessageBus) { + return new TrackerGetTaskInvocation( + this.config, + params, + messageBus, + this.name, + ); + } + override getSchema(modelId?: string) { + return resolveToolDeclaration(TRACKER_GET_TASK_DEFINITION, modelId); + } +} + +// --- tracker_list_tasks --- + +interface ListTasksParams { + status?: TaskStatus; + type?: TaskType; + parentId?: string; +} + +class TrackerListTasksInvocation extends BaseToolInvocation< + ListTasksParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: ListTasksParams, + messageBus: MessageBus, + toolName: string, + ) { + super(params, messageBus, toolName); + } + + private get service() { + return this.config.getTrackerService(); + } + getDescription(): string { + return 'Listing tasks.'; + } + + override async execute(_signal: AbortSignal): Promise { + let tasks = await this.service.listTasks(); + if (this.params.status) { + tasks = tasks.filter((t) => t.status === this.params.status); + } + if (this.params.type) { + tasks = tasks.filter((t) => t.type === this.params.type); + } + if (this.params.parentId) { + tasks = tasks.filter((t) => t.parentId === this.params.parentId); + } + + if (tasks.length === 0) { + return { + llmContent: 'No tasks found matching the criteria.', + returnDisplay: 'No matching tasks.', + }; + } + + const content = tasks + .map((t) => `- [${t.id}] ${t.title} (${t.status})`) + .join('\n'); + return { + llmContent: content, + returnDisplay: `Listed ${tasks.length} tasks.`, + }; + } +} + +export class TrackerListTasksTool extends BaseDeclarativeTool< + ListTasksParams, + ToolResult +> { + static readonly Name = TRACKER_LIST_TASKS_TOOL_NAME; + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + TrackerListTasksTool.Name, + 'List Tasks', + TRACKER_LIST_TASKS_DEFINITION.base.description!, + Kind.Search, + TRACKER_LIST_TASKS_DEFINITION.base.parametersJsonSchema, + messageBus, + ); + } + protected createInvocation(params: ListTasksParams, messageBus: MessageBus) { + return new TrackerListTasksInvocation( + this.config, + params, + messageBus, + this.name, + ); + } + override getSchema(modelId?: string) { + return resolveToolDeclaration(TRACKER_LIST_TASKS_DEFINITION, modelId); + } +} + +// --- tracker_add_dependency --- + +interface AddDependencyParams { + taskId: string; + dependencyId: string; +} + +class TrackerAddDependencyInvocation extends BaseToolInvocation< + AddDependencyParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: AddDependencyParams, + messageBus: MessageBus, + toolName: string, + ) { + super(params, messageBus, toolName); + } + + private get service() { + return this.config.getTrackerService(); + } + getDescription(): string { + return `Adding dependency: ${this.params.taskId} depends on ${this.params.dependencyId}`; + } + + override async execute(_signal: AbortSignal): Promise { + if (this.params.taskId === this.params.dependencyId) { + return { + llmContent: `Error: Task ${this.params.taskId} cannot depend on itself.`, + returnDisplay: 'Self-referential dependency rejected.', + error: { + message: 'Task cannot depend on itself', + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } + + const [task, dep] = await Promise.all([ + this.service.getTask(this.params.taskId), + this.service.getTask(this.params.dependencyId), + ]); + + if (!task) { + return { + llmContent: `Task ${this.params.taskId} not found.`, + returnDisplay: 'Task not found.', + }; + } + if (!dep) { + return { + llmContent: `Dependency task ${this.params.dependencyId} not found.`, + returnDisplay: 'Dependency not found.', + }; + } + + const newDeps = Array.from( + new Set([...task.dependencies, this.params.dependencyId]), + ); + try { + await this.service.updateTask(task.id, { dependencies: newDeps }); + return { + llmContent: `Linked ${task.id} -> ${dep.id}.`, + returnDisplay: 'Dependency added.', + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + llmContent: `Error adding dependency: ${errorMessage}`, + returnDisplay: 'Failed to add dependency.', + error: { + message: errorMessage, + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } + } +} + +export class TrackerAddDependencyTool extends BaseDeclarativeTool< + AddDependencyParams, + ToolResult +> { + static readonly Name = TRACKER_ADD_DEPENDENCY_TOOL_NAME; + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + TrackerAddDependencyTool.Name, + 'Add Dependency', + TRACKER_ADD_DEPENDENCY_DEFINITION.base.description!, + Kind.Edit, + TRACKER_ADD_DEPENDENCY_DEFINITION.base.parametersJsonSchema, + messageBus, + ); + } + protected createInvocation( + params: AddDependencyParams, + messageBus: MessageBus, + ) { + return new TrackerAddDependencyInvocation( + this.config, + params, + messageBus, + this.name, + ); + } + override getSchema(modelId?: string) { + return resolveToolDeclaration(TRACKER_ADD_DEPENDENCY_DEFINITION, modelId); + } +} + +// --- tracker_visualize --- + +class TrackerVisualizeInvocation extends BaseToolInvocation< + Record, + ToolResult +> { + constructor( + private readonly config: Config, + params: Record, + messageBus: MessageBus, + toolName: string, + ) { + super(params, messageBus, toolName); + } + + private get service() { + return this.config.getTrackerService(); + } + getDescription(): string { + return 'Visualizing the task graph.'; + } + + override async execute(_signal: AbortSignal): Promise { + const tasks = await this.service.listTasks(); + if (tasks.length === 0) { + return { + llmContent: 'No tasks to visualize.', + returnDisplay: 'Empty tracker.', + }; + } + + const statusEmojis: Record = { + open: '⭕', + in_progress: '🚧', + blocked: '🚫', + closed: '✅', + }; + + const typeLabels: Record = { + epic: '[EPIC]', + task: '[TASK]', + bug: '[BUG]', + }; + + const childrenMap = new Map(); + const roots: TrackerTask[] = []; + + for (const task of tasks) { + if (task.parentId) { + if (!childrenMap.has(task.parentId)) { + childrenMap.set(task.parentId, []); + } + childrenMap.get(task.parentId)!.push(task); + } else { + roots.push(task); + } + } + + let output = 'Task Tracker Graph:\n'; + + const renderTask = ( + task: TrackerTask, + depth: number, + visited: Set, + ) => { + if (visited.has(task.id)) { + output += `${' '.repeat(depth)}[CYCLE DETECTED: ${task.id}]\n`; + return; + } + visited.add(task.id); + + const indent = ' '.repeat(depth); + output += `${indent}${statusEmojis[task.status]} ${task.id} ${typeLabels[task.type]} ${task.title}\n`; + if (task.dependencies.length > 0) { + output += `${indent} └─ Depends on: ${task.dependencies.join(', ')}\n`; + } + const children = childrenMap.get(task.id) ?? []; + for (const child of children) { + renderTask(child, depth + 1, new Set(visited)); + } + }; + + for (const root of roots) { + renderTask(root, 0, new Set()); + } + + return { + llmContent: output, + returnDisplay: 'Graph rendered.', + }; + } +} + +export class TrackerVisualizeTool extends BaseDeclarativeTool< + Record, + ToolResult +> { + static readonly Name = TRACKER_VISUALIZE_TOOL_NAME; + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + TrackerVisualizeTool.Name, + 'Visualize Tracker', + TRACKER_VISUALIZE_DEFINITION.base.description!, + Kind.Read, + TRACKER_VISUALIZE_DEFINITION.base.parametersJsonSchema, + messageBus, + ); + } + protected createInvocation( + params: Record, + messageBus: MessageBus, + ) { + return new TrackerVisualizeInvocation( + this.config, + params, + messageBus, + this.name, + ); + } + override getSchema(modelId?: string) { + return resolveToolDeclaration(TRACKER_VISUALIZE_DEFINITION, modelId); + } +} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 6d32edecfe..a0ef69eab5 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1701,6 +1701,13 @@ "default": false, "type": "boolean" }, + "taskTracker": { + "title": "Task Tracker", + "description": "Enable task tracker tools.", + "markdownDescription": "Enable task tracker tools.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "modelSteering": { "title": "Model Steering", "description": "Enable model steering (user hints) to guide the model during tool execution.",