From 7d53a9cd82354aaa58a8f6bff3e00bd7a72db0fc Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:14:15 -0700 Subject: [PATCH] feat: add instrument listing, track creation and update tools to AI agent --- public/prompts/system.md | 27 +- src/agent/core/AgentCore.test.ts | 21 +- src/agent/tools/CreateNewTrackTool.test.ts | 77 +++++ src/agent/tools/CreateNewTrackTool.ts | 97 ++++++ .../ListAllAvailableInstrumentsTool.test.ts | 44 +++ .../tools/ListAllAvailableInstrumentsTool.ts | 40 +++ src/agent/tools/UpdateTrackTool.test.ts | 309 ++++++++++++++++++ src/agent/tools/UpdateTrackTool.ts | 177 ++++++++++ src/agent/tools/index.ts | 9 + src/agent/tools/toolTargeting.ts | 44 +++ 10 files changed, 836 insertions(+), 9 deletions(-) create mode 100644 src/agent/tools/CreateNewTrackTool.test.ts create mode 100644 src/agent/tools/CreateNewTrackTool.ts create mode 100644 src/agent/tools/ListAllAvailableInstrumentsTool.test.ts create mode 100644 src/agent/tools/ListAllAvailableInstrumentsTool.ts create mode 100644 src/agent/tools/UpdateTrackTool.test.ts create mode 100644 src/agent/tools/UpdateTrackTool.ts diff --git a/public/prompts/system.md b/public/prompts/system.md index 08b568f..a22f582 100644 --- a/public/prompts/system.md +++ b/public/prompts/system.md @@ -30,6 +30,15 @@ Read existing musical content from the project. The output is in ABC notation. I ## list_all_tracks List all MIDI tracks in the project with their `track_id`, `track_name`, and instrument name in English. Use this when you need to inspect available target tracks before choosing one. +## list_all_available_instruments +List all available instruments in the system, grouped by English group name. Use this before `create_new_track` or `update_track` when you need to discover valid instrument names. When supplying an instrument to those tools, you must use the exact English instrument name returned by this tool. + +## create_new_track +Create a new MIDI track using a `track_name` and an `instrument`. The `instrument` parameter must be the exact English instrument name returned by `list_all_available_instruments`. + +## update_track +Update an existing MIDI track by `track_id` or `track_name`. Prefer `track_id` because `track_name` may be duplicated. You can rename the track with `new_track_name` and/or change the instrument with `instrument`. The `instrument` parameter must be the exact English instrument name returned by `list_all_available_instruments`. + ## read_chord_progression Read the user-defined chord progression from the global chord track. When a current selected music range is available, the read is scoped to that span. Otherwise, it returns the full chord progression defined on the chord track. @@ -53,14 +62,15 @@ To create a melodic line, use sequential `start` values for each note. To create 2. For multi-step tasks, user-provided checklists, or work that will likely require 3 or more actions, use `update_todo_list` before major tool work begins. Keep exactly one item `in_progress` while you are actively working on it, and mark items `completed` when done. 3. Do not create a todo list for simple one-shot answers or single-tool actions that do not need progress tracking. 4. Choose the most appropriate tool for the current step. If you need to understand existing music, use `read_music` first. -5. Use `list_all_tracks` when you need to inspect available MIDI tracks before choosing a target track. -6. Use `get_user_selected_music_range_and_track` when the current selection context matters and is not already clear from the conversation. -7. Treat every new user request as potentially operating on an updated project state. The user may have created or removed tracks, changed selections, edited notes, or otherwise modified the project since the previous turn. -8. For each new request, re-check the latest relevant project information before acting. Use tools such as `list_all_tracks`, `get_user_selected_music_range_and_track`, `read_music`, and `read_chord_progression` whenever current track, selection, or score information matters. -9. After each tool call, examine the result before deciding the next action. Do not assume success — verify from the returned result. -10. If you are editing notes for the currently selected track, you do not need to pass `track_id` or `track_name`; the editing tools can use the selected track context directly. -11. If a required parameter cannot be determined from context, ask the user instead of guessing. -12. Proceed step-by-step. Each action should build on confirmed results from previous steps. +5. Use `list_all_tracks` when you need to inspect available MIDI tracks before choosing a target track. Prefer `track_id` over `track_name` when both are available. +6. Use `list_all_available_instruments` before `create_new_track` or `update_track` when you need to discover valid instruments. Those write tools require the exact English instrument name from that list. +7. Use `get_user_selected_music_range_and_track` when the current selection context matters and is not already clear from the conversation. +8. Treat every new user request as potentially operating on an updated project state. The user may have created or removed tracks, changed selections, edited notes, or otherwise modified the project since the previous turn. +9. For each new request, re-check the latest relevant project information before acting. Use tools such as `list_all_tracks`, `list_all_available_instruments`, `get_user_selected_music_range_and_track`, `read_music`, and `read_chord_progression` whenever current track, selection, score, or instrument information matters. +10. After each tool call, examine the result before deciding the next action. Do not assume success — verify from the returned result. +11. If you are editing notes for the currently selected track, you do not need to pass `track_id` or `track_name`; the editing tools can use the selected track context directly. +12. If a required parameter cannot be determined from context, ask the user instead of guessing. +13. Proceed step-by-step. Each action should build on confirmed results from previous steps. ==== @@ -126,6 +136,7 @@ CAPABILITIES - **Context Awareness**: Current project information (BPM, key signature, time signature) and the current selected music range, when available, will be provided to you in the "MUSIC INFORMATION" section. - **Selection Awareness**: Use `get_user_selected_music_range_and_track` to confirm the current selected music range and selected regular track whenever selection context is important to the task. - **Track Awareness**: Use `list_all_tracks` to inspect all available MIDI tracks and their instruments before choosing a target track. +- **Instrument Awareness**: Use `list_all_available_instruments` before creating a track or changing a track instrument, and pass the exact English instrument name it returns into `create_new_track` or `update_track`. - **Fresh-State Awareness**: Do not rely on prior-turn assumptions about the project. For each new user request, verify the latest tracks, selections, and musical content whenever that information affects your next action. - **Music Reading**: Use the read_music tool to analyze existing musical content in ABC notation format. Multiple tracks will be presented separately, and track names (e.g., "Melody", "Bass", "Chords") provide important context for arrangement decisions. - **Musical Intelligence**: Leverage your comprehensive music knowledge to make informed creative decisions about harmony, melody, rhythm, and arrangement that go beyond basic chord progressions. diff --git a/src/agent/core/AgentCore.test.ts b/src/agent/core/AgentCore.test.ts index 2cc9f7c..e384c39 100644 --- a/src/agent/core/AgentCore.test.ts +++ b/src/agent/core/AgentCore.test.ts @@ -253,6 +253,21 @@ describe('AgentCore todo integration', () => { expect(provider.systemPrompts[0]).toBe('system prompt:prompts/system.md'); }); + it('exposes the new track management tools in regular mode', async () => { + configState.set('general.agent_mode', 'regular'); + const provider = new ScriptedProvider([ + [{ type: 'done', content: '', finishReason: 'stop' }], + ]); + AgentCore.instance().setLLMProvider(provider); + + await collectChunks('Inspect available tools.'); + + const toolNames = provider.tools[0].map(tool => tool.function.name); + expect(toolNames).toContain('list_all_available_instruments'); + expect(toolNames).toContain('create_new_track'); + expect(toolNames).toContain('update_track'); + }); + it('uses the compact system prompt in efficient mode', async () => { configState.set('general.agent_mode', 'efficient'); const provider = new ScriptedProvider([ @@ -290,7 +305,11 @@ describe('AgentCore todo integration', () => { await collectChunks('Read the current region.'); - expect(provider.tools[0].map(tool => tool.function.name)).not.toContain('read_music'); + const toolNames = provider.tools[0].map(tool => tool.function.name); + expect(toolNames).not.toContain('read_music'); + expect(toolNames).not.toContain('list_all_available_instruments'); + expect(toolNames).not.toContain('create_new_track'); + expect(toolNames).not.toContain('update_track'); spy.mockRestore(); }); diff --git a/src/agent/tools/CreateNewTrackTool.test.ts b/src/agent/tools/CreateNewTrackTool.test.ts new file mode 100644 index 0000000..7e1fe83 --- /dev/null +++ b/src/agent/tools/CreateNewTrackTool.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CreateNewTrackTool } from './CreateNewTrackTool'; +import { KGCore } from '../../core/KGCore'; +import { KGProject } from '../../core/KGProject'; +import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; +import { KGMidiTrack } from '../../core/track/KGMidiTrack'; + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: { + getState: () => ({ + activeRegionId: null, + selectedRegionIds: [], + selectedTrackId: null, + }), + }, +})); + +function mockCore(project: KGProject) { + vi.spyOn(KGCore, 'instance').mockReturnValue({ + getCurrentProject: () => project, + executeCommand: (command: { execute(): void }) => command.execute(), + } as unknown as KGCore); +} + +describe('CreateNewTrackTool', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(KGAudioInterface, 'instance').mockReturnValue({ + createTrackSynth: vi.fn(), + } as unknown as KGAudioInterface); + }); + + it('creates a new track and returns the exact output shape', async () => { + const project = new KGProject('create-track-project'); + project.setTracks([new KGMidiTrack('Lead', 1, 'trumpet')]); + mockCore(project); + + const tool = new CreateNewTrackTool(); + const result = await tool.execute({ + track_name: 'Bass', + instrument: 'Electric Bass (finger)', + }); + + expect(result).toEqual({ + success: true, + result: 'New track created:\ntrack_id: 2\ntrack_name: Bass\ninstrument: Electric Bass (finger)', + }); + expect(project.getTracks()).toHaveLength(2); + expect(project.getTracks()[1]).toBeInstanceOf(KGMidiTrack); + expect((project.getTracks()[1] as KGMidiTrack).getInstrument()).toBe('electric_bass_finger'); + expect(tool.isReadOnlyTool()).toBe(false); + expect(tool.isAvailableInEfficientMode()).toBe(false); + expect(tool.buildToolResultDisplayContent({ + track_name: 'Bass', + instrument: 'Electric Bass (finger)', + }, result)).toBe( + 'New track created:\n- track_id: 2\n- track_name: Bass\n- instrument: Electric Bass (finger)', + ); + }); + + it('rejects an invalid instrument name', async () => { + const project = new KGProject('invalid-instrument-project'); + mockCore(project); + + const tool = new CreateNewTrackTool(); + const result = await tool.execute({ + track_name: 'Bass', + instrument: 'electric_bass_finger', + }); + + expect(result).toEqual({ + success: false, + result: 'Invalid instrument "electric_bass_finger". Use the exact English name from list_all_available_instruments.', + }); + expect(project.getTracks()).toHaveLength(0); + }); +}); diff --git a/src/agent/tools/CreateNewTrackTool.ts b/src/agent/tools/CreateNewTrackTool.ts new file mode 100644 index 0000000..7c29026 --- /dev/null +++ b/src/agent/tools/CreateNewTrackTool.ts @@ -0,0 +1,97 @@ +import { AddTrackCommand } from '../../core/commands/track/AddTrackCommand'; +import { BaseTool } from './BaseTool'; +import type { ToolParameter, ToolResult } from './BaseTool'; +import { + getEnglishInstrumentName, + resolveInstrumentKeyByEnglishName, +} from './toolTargeting'; + +export class CreateNewTrackTool extends BaseTool { + readonly name = 'create_new_track'; + readonly description = + 'Create a new MIDI track with a given track name and exact English instrument name. Use list_all_available_instruments first to discover valid instrument names.'; + + readonly parameters: Record = { + track_name: { + type: 'string', + description: 'Name of the new MIDI track to create.', + required: true, + }, + instrument: { + type: 'string', + description: 'Exact English instrument name from list_all_available_instruments.', + required: true, + }, + }; + + override isReadOnlyTool(): boolean { + return false; + } + + override isAvailableInEfficientMode(): boolean { + return false; + } + + override buildConfirmationContent(args: Record | null): string | undefined { + if (!args) { + return undefined; + } + + const trackName = typeof args.track_name === 'string' ? args.track_name : null; + const instrument = typeof args.instrument === 'string' ? args.instrument : null; + if (!trackName || !instrument) { + return undefined; + } + + return `Allow creating track **${trackName}** with instrument **${instrument}**?`; + } + + override buildToolResultDisplayContent(args: Record | null, toolResult: ToolResult): string | undefined { + if (!args || !toolResult.success) { + return undefined; + } + + const trackName = typeof args.track_name === 'string' ? args.track_name : null; + const instrument = typeof args.instrument === 'string' ? args.instrument : null; + if (!trackName || !instrument) { + return undefined; + } + + const trackIdMatch = toolResult.result.match(/track_id:\s*(\d+)/); + if (!trackIdMatch) { + return undefined; + } + + return [ + 'New track created:', + `- track_id: ${trackIdMatch[1]}`, + `- track_name: ${trackName}`, + `- instrument: ${instrument}`, + ].join('\n'); + } + + async execute(params: Record): Promise { + try { + this.validateParameters(params); + + const trackName = params.track_name as string; + const instrumentName = params.instrument as string; + const instrumentKey = resolveInstrumentKeyByEnglishName(instrumentName); + if (!instrumentKey) { + return this.createErrorResult(`Invalid instrument "${instrumentName}". Use the exact English name from list_all_available_instruments.`); + } + + const command = new AddTrackCommand(undefined, trackName, instrumentKey); + await this.executeCommand(command); + + return this.createSuccessResult([ + 'New track created:', + `track_id: ${command.getTrackId().toString()}`, + `track_name: ${trackName}`, + `instrument: ${getEnglishInstrumentName(instrumentKey)}`, + ].join('\n')); + } catch (error) { + return this.createErrorResult(`Failed to create track: ${error}`); + } + } +} diff --git a/src/agent/tools/ListAllAvailableInstrumentsTool.test.ts b/src/agent/tools/ListAllAvailableInstrumentsTool.test.ts new file mode 100644 index 0000000..1a9f63e --- /dev/null +++ b/src/agent/tools/ListAllAvailableInstrumentsTool.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ListAllAvailableInstrumentsTool } from './ListAllAvailableInstrumentsTool'; +import { listAvailableInstrumentsByGroup } from './toolTargeting'; + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: { + getState: () => ({ + activeRegionId: null, + selectedRegionIds: [], + selectedTrackId: null, + }), + }, +})); + +describe('ListAllAvailableInstrumentsTool', () => { + it('lists instruments grouped by English group name with blank lines between groups', async () => { + const tool = new ListAllAvailableInstrumentsTool(); + const result = await tool.execute({}); + + expect(result.success).toBe(true); + expect(result.result.startsWith( + 'Group: Piano and Keyboards\n- Acoustic Grand Piano\n- Bright Acoustic Piano', + )).toBe(true); + expect(result.result).toContain('\n\nGroup: Guitar\n- Acoustic Guitar (nylon)'); + expect(result.result).toContain('\n\nGroup: Bass\n- Acoustic Bass'); + expect(result.result).toContain('\n\nGroup: Percussion Kit\n- Standard Drum Kit'); + expect(result.result).toContain('\n\nGroup: Synthesizer\n- Lead 1 (square)'); + }); + + it('is unavailable in efficient mode', () => { + const tool = new ListAllAvailableInstrumentsTool(); + + expect(tool.isAvailableInEfficientMode()).toBe(false); + }); + + it('returns a simplified UI display message', async () => { + const tool = new ListAllAvailableInstrumentsTool(); + const result = await tool.execute({}); + const totalInstruments = listAvailableInstrumentsByGroup() + .reduce((count, group) => count + group.instruments.length, 0); + + expect(tool.buildToolResultDisplayContent({}, result)).toBe(`Listed ${totalInstruments} available instruments.`); + }); +}); diff --git a/src/agent/tools/ListAllAvailableInstrumentsTool.ts b/src/agent/tools/ListAllAvailableInstrumentsTool.ts new file mode 100644 index 0000000..8381e5a --- /dev/null +++ b/src/agent/tools/ListAllAvailableInstrumentsTool.ts @@ -0,0 +1,40 @@ +import { BaseTool } from './BaseTool'; +import type { ToolParameter, ToolResult } from './BaseTool'; +import { listAvailableInstrumentsByGroup } from './toolTargeting'; + +export class ListAllAvailableInstrumentsTool extends BaseTool { + readonly name = 'list_all_available_instruments'; + readonly description = + 'List all available instruments grouped by English instrument family names. Use this before creating a new MIDI track or changing a track instrument, because write tools require the exact English instrument name from this list.'; + + readonly parameters: Record = {}; + + override isAvailableInEfficientMode(): boolean { + return false; + } + + override buildToolResultDisplayContent(_args: Record | null, toolResult: ToolResult): string | undefined { + if (!toolResult.success) { + return undefined; + } + + const totalInstruments = listAvailableInstrumentsByGroup() + .reduce((count, group) => count + group.instruments.length, 0); + return `Listed ${totalInstruments} available instruments.`; + } + + async execute(_params: Record): Promise { + try { + const result = listAvailableInstrumentsByGroup() + .map(({ groupName, instruments }) => [ + `Group: ${groupName}`, + ...instruments.map(instrument => `- ${instrument}`), + ].join('\n')) + .join('\n\n'); + + return this.createSuccessResult(result); + } catch (error) { + return this.createErrorResult(`Failed to list available instruments: ${error}`); + } + } +} diff --git a/src/agent/tools/UpdateTrackTool.test.ts b/src/agent/tools/UpdateTrackTool.test.ts new file mode 100644 index 0000000..8fd099b --- /dev/null +++ b/src/agent/tools/UpdateTrackTool.test.ts @@ -0,0 +1,309 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UpdateTrackTool } from './UpdateTrackTool'; +import { KGCore } from '../../core/KGCore'; +import { KGProject } from '../../core/KGProject'; +import { KGMidiTrack } from '../../core/track/KGMidiTrack'; +import { KGAudioTrack } from '../../core/track/KGAudioTrack'; +import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: { + getState: () => ({ + activeRegionId: null, + selectedRegionIds: [], + selectedTrackId: null, + }), + }, +})); + +function mockCore(project: KGProject) { + vi.spyOn(KGCore, 'instance').mockReturnValue({ + getCurrentProject: () => project, + executeCommand: (command: { execute(): void }) => command.execute(), + } as unknown as KGCore); +} + +describe('UpdateTrackTool', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(KGAudioInterface, 'instance').mockReturnValue({ + setTrackInstrument: vi.fn(), + } as unknown as KGAudioInterface); + }); + + it('renames a track by track_id', async () => { + const track = new KGMidiTrack('Lead', 1, 'trumpet'); + const project = new KGProject('rename-track-project'); + project.setTracks([track]); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + track_id: '1', + new_track_name: 'Lead 2', + }); + + expect(result).toEqual({ + success: true, + result: 'Track updated:\ntrack_id: 1\ntrack_name: Lead 2\ninstrument: Trumpet', + }); + expect(track.getName()).toBe('Lead 2'); + expect(tool.buildToolResultDisplayContent({ + track_id: '1', + new_track_name: 'Lead 2', + }, result)).toBe( + 'Track updated:\n- track_id: 1\n- track_name: Lead 2\n- instrument: Trumpet', + ); + }); + + it('updates a track instrument by track_name', async () => { + const track = new KGMidiTrack('Lead', 1, 'trumpet'); + const project = new KGProject('instrument-track-project'); + project.setTracks([track]); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + track_name: 'Lead', + instrument: 'Flute', + }); + + expect(result).toEqual({ + success: true, + result: 'Track updated:\ntrack_id: 1\ntrack_name: Lead\ninstrument: Flute', + }); + expect(track.getInstrument()).toBe('flute'); + }); + + it('updates both track name and instrument', async () => { + const track = new KGMidiTrack('Lead', 1, 'trumpet'); + const project = new KGProject('rename-and-instrument-project'); + project.setTracks([track]); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + track_id: '1', + new_track_name: 'Flute Lead', + instrument: 'Flute', + }); + + expect(result).toEqual({ + success: true, + result: 'Track updated:\ntrack_id: 1\ntrack_name: Flute Lead\ninstrument: Flute', + }); + expect(track.getName()).toBe('Flute Lead'); + expect(track.getInstrument()).toBe('flute'); + }); + + it('uses track_id when both track_id and track_name are provided', async () => { + const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet'); + const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass'); + const project = new KGProject('track-id-precedence-project'); + project.setTracks([leadTrack, bassTrack]); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + track_id: '2', + track_name: 'Lead', + new_track_name: 'Bass 2', + }); + + expect(result.success).toBe(true); + expect(leadTrack.getName()).toBe('Lead'); + expect(bassTrack.getName()).toBe('Bass 2'); + }); + + it('rejects duplicate track names when track_id is omitted', async () => { + const firstLead = new KGMidiTrack('Lead', 1, 'trumpet'); + const secondLead = new KGMidiTrack('Lead', 2, 'flute'); + const project = new KGProject('duplicate-name-project'); + project.setTracks([firstLead, secondLead]); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + track_name: 'Lead', + new_track_name: 'Lead 2', + }); + + expect(result).toEqual({ + success: false, + result: 'Multiple MIDI tracks share the name "Lead". Provide track_id instead.', + }); + }); + + it('returns an error when neither identifier is provided', async () => { + const project = new KGProject('missing-id-project'); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + new_track_name: 'Lead 2', + }); + + expect(result).toEqual({ + success: false, + result: 'Either track_id or track_name must be provided.', + }); + }); + + it('returns an error when no update fields are provided', async () => { + const track = new KGMidiTrack('Lead', 1, 'trumpet'); + const project = new KGProject('missing-fields-project'); + project.setTracks([track]); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + track_id: '1', + }); + + expect(result).toEqual({ + success: false, + result: 'At least one of instrument or new_track_name must be provided.', + }); + }); + + it('treats empty string optional fields as not provided', async () => { + const track = new KGMidiTrack('Lead', 1, 'trumpet'); + const project = new KGProject('empty-string-fields-project'); + project.setTracks([track]); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + track_id: '1', + new_track_name: '', + instrument: '', + }); + + expect(result).toEqual({ + success: false, + result: 'At least one of instrument or new_track_name must be provided.', + }); + expect(tool.buildConfirmationContent({ + track_id: '1', + new_track_name: '', + instrument: '', + })).toBeUndefined(); + }); + + it('treats null optional fields as not provided', async () => { + const track = new KGMidiTrack('Lead', 1, 'trumpet'); + const project = new KGProject('null-fields-project'); + project.setTracks([track]); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + track_id: '1', + new_track_name: null, + instrument: null, + }); + + expect(result).toEqual({ + success: false, + result: 'At least one of instrument or new_track_name must be provided.', + }); + }); + + it('applies a valid change when the other optional field is empty', async () => { + const track = new KGMidiTrack('Lead', 1, 'trumpet'); + const project = new KGProject('mixed-empty-field-project'); + project.setTracks([track]); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + track_id: '1', + new_track_name: '', + instrument: 'Flute', + }); + + expect(result).toEqual({ + success: true, + result: 'Track updated:\ntrack_id: 1\ntrack_name: Lead\ninstrument: Flute', + }); + expect(track.getInstrument()).toBe('flute'); + expect(tool.buildConfirmationContent({ + track_id: '1', + new_track_name: '', + instrument: 'Flute', + })).toBe('Allow updating track ID **1** to set instrument to **Flute**?'); + }); + + it('returns an error when provided values do not change the track', async () => { + const track = new KGMidiTrack('Lead', 1, 'trumpet'); + const project = new KGProject('unchanged-values-project'); + project.setTracks([track]); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + track_id: '1', + new_track_name: 'Lead', + instrument: 'Trumpet', + }); + + expect(result).toEqual({ + success: false, + result: 'No changes to apply to the target track.', + }); + }); + + it('returns an error for an invalid instrument', async () => { + const track = new KGMidiTrack('Lead', 1, 'trumpet'); + const project = new KGProject('invalid-instrument-project'); + project.setTracks([track]); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + track_id: '1', + instrument: 'trumpet', + }); + + expect(result).toEqual({ + success: false, + result: 'Invalid instrument "trumpet". Use the exact English name from list_all_available_instruments.', + }); + }); + + it('returns an error when the track does not exist', async () => { + const project = new KGProject('missing-track-project'); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + track_id: '99', + new_track_name: 'Lead 2', + }); + + expect(result).toEqual({ + success: false, + result: 'Track with ID "99" not found or is not a MIDI track.', + }); + }); + + it('returns an error when the target is not a MIDI track', async () => { + const audioTrack = new KGAudioTrack('Vocal', 1); + const project = new KGProject('audio-track-project'); + project.setTracks([audioTrack]); + mockCore(project); + + const tool = new UpdateTrackTool(); + const result = await tool.execute({ + track_id: '1', + new_track_name: 'Vocal 2', + }); + + expect(result).toEqual({ + success: false, + result: 'Track with ID "1" not found or is not a MIDI track.', + }); + expect(tool.isReadOnlyTool()).toBe(false); + expect(tool.isAvailableInEfficientMode()).toBe(false); + }); +}); diff --git a/src/agent/tools/UpdateTrackTool.ts b/src/agent/tools/UpdateTrackTool.ts new file mode 100644 index 0000000..bf91611 --- /dev/null +++ b/src/agent/tools/UpdateTrackTool.ts @@ -0,0 +1,177 @@ +import { UpdateTrackCommand } from '../../core/commands/track/UpdateTrackCommand'; +import { KGMidiTrack, type InstrumentType } from '../../core/track/KGMidiTrack'; +import { BaseTool } from './BaseTool'; +import type { ToolParameter, ToolResult } from './BaseTool'; +import { + getEnglishInstrumentName, + getTrackDisplayName, + resolveInstrumentKeyByEnglishName, + resolveMidiTrackByExactName, + resolveMidiTrackByIdOrName, +} from './toolTargeting'; + +export class UpdateTrackTool extends BaseTool { + readonly name = 'update_track'; + readonly description = + 'Update an existing MIDI track by track_id or track_name. Supports renaming the track and/or changing its instrument to an exact English instrument name from list_all_available_instruments.'; + + readonly parameters: Record = { + track_id: { + type: 'string', + description: 'Target MIDI track ID. Preferred when available.', + required: false, + }, + track_name: { + type: 'string', + description: 'Target MIDI track name. Used only when track_id is omitted.', + required: false, + }, + instrument: { + type: 'string', + description: 'Optional exact English instrument name from list_all_available_instruments.', + required: false, + }, + new_track_name: { + type: 'string', + description: 'Optional new name for the track.', + required: false, + }, + }; + + override isReadOnlyTool(): boolean { + return false; + } + + override isAvailableInEfficientMode(): boolean { + return false; + } + + override buildConfirmationContent(args: Record | null): string | undefined { + if (!args) { + return undefined; + } + + const normalizedInstrumentName = this.normalizeOptionalString(args.instrument); + const normalizedNewTrackName = this.normalizeOptionalString(args.new_track_name); + const targetLabel = typeof args.track_id === 'string' + ? `track ID **${args.track_id}**` + : typeof args.track_name === 'string' + ? `track **${args.track_name}**` + : null; + if (!targetLabel) { + return undefined; + } + + const changes: string[] = []; + if (normalizedNewTrackName !== undefined) { + changes.push(`rename to **${normalizedNewTrackName}**`); + } + if (normalizedInstrumentName !== undefined) { + changes.push(`set instrument to **${normalizedInstrumentName}**`); + } + if (changes.length === 0) { + return undefined; + } + + return `Allow updating ${targetLabel} to ${changes.join(' and ')}?`; + } + + override buildToolResultDisplayContent(args: Record | null, toolResult: ToolResult): string | undefined { + if (!args || !toolResult.success) { + return undefined; + } + + const trackIdMatch = toolResult.result.match(/track_id:\s*(\d+)/); + const trackNameMatch = toolResult.result.match(/track_name:\s*(.+)/); + const instrumentMatch = toolResult.result.match(/instrument:\s*(.+)/); + if (!trackIdMatch || !trackNameMatch || !instrumentMatch) { + return undefined; + } + + return [ + 'Track updated:', + `- track_id: ${trackIdMatch[1]}`, + `- track_name: ${trackNameMatch[1]}`, + `- instrument: ${instrumentMatch[1]}`, + ].join('\n'); + } + + async execute(params: Record): Promise { + try { + this.validateParameters(params); + + const trackId = params.track_id as string | undefined; + const trackName = params.track_name as string | undefined; + const instrumentName = this.normalizeOptionalString(params.instrument); + const newTrackName = this.normalizeOptionalString(params.new_track_name); + + if (!trackId && !trackName) { + return this.createErrorResult('Either track_id or track_name must be provided.'); + } + + if (instrumentName === undefined && newTrackName === undefined) { + return this.createErrorResult('At least one of instrument or new_track_name must be provided.'); + } + + if (!trackId && trackName) { + const matchingTracks = resolveMidiTrackByExactName(trackName); + if (matchingTracks.length > 1) { + return this.createErrorResult(`Multiple MIDI tracks share the name "${trackName}". Provide track_id instead.`); + } + } + + const resolvedTrack = resolveMidiTrackByIdOrName(trackId, trackName); + if (!resolvedTrack) { + return this.createErrorResult( + trackId + ? `Track with ID "${trackId}" not found or is not a MIDI track.` + : `Track with name "${trackName}" not found or is not a MIDI track.`, + ); + } + + if (!(resolvedTrack instanceof KGMidiTrack)) { + return this.createErrorResult( + `Track "${getTrackDisplayName(resolvedTrack)}" is not a MIDI track.`, + ); + } + + let instrumentKey: InstrumentType | undefined; + if (instrumentName !== undefined) { + instrumentKey = resolveInstrumentKeyByEnglishName(instrumentName); + if (!instrumentKey) { + return this.createErrorResult(`Invalid instrument "${instrumentName}". Use the exact English name from list_all_available_instruments.`); + } + } + + const trackNameChanged = newTrackName !== undefined && newTrackName !== resolvedTrack.getName(); + const instrumentChanged = instrumentKey !== undefined && instrumentKey !== resolvedTrack.getInstrument(); + + if (!trackNameChanged && !instrumentChanged) { + return this.createErrorResult('No changes to apply to the target track.'); + } + + const command = new UpdateTrackCommand(resolvedTrack.getId(), { + ...(trackNameChanged ? { name: newTrackName } : {}), + ...(instrumentChanged && instrumentKey !== undefined ? { instrument: instrumentKey } : {}), + }); + await this.executeCommand(command); + + return this.createSuccessResult([ + 'Track updated:', + `track_id: ${resolvedTrack.getId().toString()}`, + `track_name: ${resolvedTrack.getName()}`, + `instrument: ${getEnglishInstrumentName(resolvedTrack.getInstrument())}`, + ].join('\n')); + } catch (error) { + return this.createErrorResult(`Failed to update track: ${error}`); + } + } + + private normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + return value === '' ? undefined : value; + } +} diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts index 1f5774b..b294e8e 100644 --- a/src/agent/tools/index.ts +++ b/src/agent/tools/index.ts @@ -11,6 +11,9 @@ import { ReadChordProgressionTool } from './ReadChordProgressionTool'; import { UpdateTodoListTool } from './UpdateTodoListTool'; import { GetUserSelectedMusicRangeAndTrackTool } from './GetUserSelectedMusicRangeAndTrackTool'; import { ListAllTracksTool } from './ListAllTracksTool'; +import { ListAllAvailableInstrumentsTool } from './ListAllAvailableInstrumentsTool'; +import { CreateNewTrackTool } from './CreateNewTrackTool'; +import { UpdateTrackTool } from './UpdateTrackTool'; export { AddNotesTool, @@ -20,6 +23,9 @@ export { UpdateTodoListTool, GetUserSelectedMusicRangeAndTrackTool, ListAllTracksTool, + ListAllAvailableInstrumentsTool, + CreateNewTrackTool, + UpdateTrackTool, }; // Tool registry for easy access @@ -31,6 +37,9 @@ export const AVAILABLE_TOOLS = { read_chord_progression: ReadChordProgressionTool, get_user_selected_music_range_and_track: GetUserSelectedMusicRangeAndTrackTool, list_all_tracks: ListAllTracksTool, + list_all_available_instruments: ListAllAvailableInstrumentsTool, + create_new_track: CreateNewTrackTool, + update_track: UpdateTrackTool, } as const; export type ToolName = keyof typeof AVAILABLE_TOOLS; diff --git a/src/agent/tools/toolTargeting.ts b/src/agent/tools/toolTargeting.ts index cddd0fc..eaf5b8b 100644 --- a/src/agent/tools/toolTargeting.ts +++ b/src/agent/tools/toolTargeting.ts @@ -4,7 +4,9 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGGlobalRegion } from '../../core/region/KGGlobalRegion'; import { KGMidiTrack } from '../../core/track/KGMidiTrack'; import { KGTrack } from '../../core/track/KGTrack'; +import type { InstrumentType } from '../../core/track/KGMidiTrack'; import { useProjectStore } from '../../stores/projectStore'; +import { FLUIDR3_INSTRUMENT_MAP, INSTRUMENT_GROUPS } from '../../constants/generalMidiConstants'; export const NO_MIDI_TARGET_RAW_MESSAGE = 'No MIDI target could be resolved. Select the MIDI region you want me to edit and retry, or tell me which MIDI track to operate on by providing its track_id.'; @@ -57,6 +59,48 @@ export function resolveMidiTrackByIdOrName( return null; } +export function resolveMidiTrackByExactName(trackName: string): KGMidiTrack[] { + const project = KGCore.instance().getCurrentProject(); + return project.getTracks().filter((track): track is KGMidiTrack => ( + track instanceof KGMidiTrack && track.getName() === trackName + )); +} + +export function resolveInstrumentKeyByEnglishName(instrumentName: string): InstrumentType | null { + for (const [instrumentKey, instrumentInfo] of Object.entries(FLUIDR3_INSTRUMENT_MAP)) { + if (instrumentInfo.displayName === instrumentName) { + return instrumentKey as InstrumentType; + } + } + + return null; +} + +export function getEnglishInstrumentName(instrumentKey: InstrumentType): string { + return FLUIDR3_INSTRUMENT_MAP[instrumentKey]?.displayName ?? String(instrumentKey); +} + +export function listAvailableInstrumentsByGroup(): Array<{ groupName: string; instruments: string[] }> { + const groupedInstruments = new Map(); + + for (const groupName of Object.values(INSTRUMENT_GROUPS)) { + groupedInstruments.set(groupName, []); + } + + for (const instrumentInfo of Object.values(FLUIDR3_INSTRUMENT_MAP)) { + const groupName = INSTRUMENT_GROUPS[instrumentInfo.group as keyof typeof INSTRUMENT_GROUPS]; + if (!groupedInstruments.has(groupName)) { + groupedInstruments.set(groupName, []); + } + groupedInstruments.get(groupName)!.push(instrumentInfo.displayName); + } + + return Array.from(groupedInstruments.entries()).map(([groupName, instruments]) => ({ + groupName, + instruments, + })); +} + export function findRegionById(regionId: string): KGRegion | null { const project = KGCore.instance().getCurrentProject();