initial public release.

This commit is contained in:
Xiaohan-Tian
2025-08-11 18:37:21 -07:00
commit de51967b49
186 changed files with 32322 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
import { LLMProvider } from '../llm/LLMProvider';
import { AgentState } from './AgentState';
import { SystemPrompts } from './SystemPrompts';
import type { StreamChunk } from '../llm/StreamingTypes';
/**
* Main orchestrator for the AI agent system
*/
export class AgentCore {
private static _instance: AgentCore | null = null;
private llmProvider: LLMProvider | null = null;
private agentState: AgentState;
private currentUserMessageId: string | null = null;
private currentAssistantMessageId: string | null = null;
private constructor() {
this.agentState = new AgentState();
}
/**
* Get the singleton instance
*/
static instance(): AgentCore {
if (!AgentCore._instance) {
AgentCore._instance = new AgentCore();
}
return AgentCore._instance;
}
/**
* Set the LLM provider
*/
setLLMProvider(provider: LLMProvider): void {
this.llmProvider = provider;
}
/**
* Get the current LLM provider
*/
getLLMProvider(): LLMProvider | null {
return this.llmProvider;
}
/**
* Get the agent state
*/
getAgentState(): AgentState {
return this.agentState;
}
/**
* Process user input and generate streaming response
*/
async *processUserInput(userInput: string): AsyncIterableIterator<StreamChunk> {
if (!this.llmProvider) {
throw new Error('No LLM provider configured');
}
// Add user message to state and track its ID
this.currentUserMessageId = this.agentState.addMessage('user', userInput);
// Get system prompt with current context
const systemPrompt = await SystemPrompts.getSystemPromptWithContext();
// Get full conversation history with preserved roles
const conversationHistory = this.agentState.getMessages();
// Generate streaming response with full conversation context
let assistantResponse = '';
// Pre-add an empty assistant message that we'll update as we stream
this.currentAssistantMessageId = this.agentState.addMessage('assistant', '');
try {
for await (const chunk of this.llmProvider.generateStream(conversationHistory, systemPrompt)) {
if (chunk.type === 'text') {
assistantResponse += chunk.content;
// Update the assistant message in real-time
this.agentState.updateMessage(this.currentAssistantMessageId, assistantResponse);
}
yield chunk;
}
// Final update to ensure the complete response is stored
if (assistantResponse) {
this.agentState.updateMessage(this.currentAssistantMessageId, assistantResponse);
}
} finally {
// Clear the current message IDs when done (successfully or not)
this.currentUserMessageId = null;
this.currentAssistantMessageId = null;
}
}
/**
* Process user input and get complete response (non-streaming)
*/
async processUserInputComplete(userInput: string): Promise<string> {
if (!this.llmProvider) {
throw new Error('No LLM provider configured');
}
// Add user message to state
this.agentState.addMessage('user', userInput);
// Get system prompt with current context
const systemPrompt = await SystemPrompts.getSystemPromptWithContext();
// Get full conversation history with preserved roles
const conversationHistory = this.agentState.getMessages();
// Generate complete response with full conversation context
const response = await this.llmProvider.generateCompletion(conversationHistory, systemPrompt);
// Add assistant response to state
this.agentState.addMessage('assistant', response.content);
return response.content;
}
/**
* Abort the current streaming request and clean up messages
* Returns the content of the user message that was aborted (for restoring to input)
*/
abortCurrentRequest(): string | null {
let userMessageContent = null;
// Remove the current assistant message (the "in progress" one)
if (this.currentAssistantMessageId) {
this.agentState.removeMessage(this.currentAssistantMessageId);
this.currentAssistantMessageId = null;
}
// Remove the current user message and get its content for restoration
if (this.currentUserMessageId) {
const messages = this.agentState.getMessages();
const userMessage = messages.find(msg => msg.id === this.currentUserMessageId);
if (userMessage) {
userMessageContent = userMessage.content;
}
this.agentState.removeMessage(this.currentUserMessageId);
this.currentUserMessageId = null;
}
return userMessageContent;
}
/**
* Check if there's a current streaming request in progress
*/
isStreamingInProgress(): boolean {
return this.currentUserMessageId !== null && this.currentAssistantMessageId !== null;
}
/**
* Clear the conversation history
*/
clearConversation(): void {
this.agentState.clearMessages();
}
/**
* Get whether the agent is currently working on a task
*/
getIsWorkingOnTask(): boolean {
return this.agentState.getIsWorkingOnTask();
}
/**
* Set whether the agent is currently working on a task
*/
setIsWorkingOnTask(isWorking: boolean): void {
this.agentState.setIsWorkingOnTask(isWorking);
}
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Manages the state of an agent conversation
*/
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
}
export class AgentState {
private messages: Message[] = [];
private conversationId: string;
private isWorkingOnTask: boolean = false;
constructor(conversationId?: string, isWorkingOnTask: boolean = false) {
this.conversationId = conversationId || this.generateConversationId();
this.isWorkingOnTask = isWorkingOnTask;
}
/**
* Add a message to the conversation
*/
addMessage(role: 'user' | 'assistant', content: string): string {
const message: Message = {
id: this.generateMessageId(),
role,
content,
timestamp: Date.now()
};
this.messages.push(message);
return message.id;
}
/**
* Update the content of a message by ID
*/
updateMessage(messageId: string, content: string): boolean {
const messageIndex = this.messages.findIndex(msg => msg.id === messageId);
if (messageIndex !== -1) {
this.messages[messageIndex].content = content;
return true;
}
return false;
}
/**
* Remove a message by ID
*/
removeMessage(messageId: string): boolean {
const messageIndex = this.messages.findIndex(msg => msg.id === messageId);
if (messageIndex !== -1) {
this.messages.splice(messageIndex, 1);
return true;
}
return false;
}
/**
* Remove the last N messages
*/
removeLastMessages(count: number): void {
this.messages.splice(-count, count);
}
/**
* Get all messages in the conversation
*/
getMessages(): Message[] {
return [...this.messages];
}
/**
* Get the conversation ID
*/
getConversationId(): string {
return this.conversationId;
}
/**
* Clear all messages
*/
clearMessages(): void {
this.messages = [];
}
/**
* Get the last N messages
*/
getRecentMessages(count: number): Message[] {
return this.messages.slice(-count);
}
private generateConversationId(): string {
return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
private generateMessageId(): string {
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
// getters and setters
getIsWorkingOnTask(): boolean {
return this.isWorkingOnTask;
}
setIsWorkingOnTask(isWorkingOnTask: boolean): void {
this.isWorkingOnTask = isWorkingOnTask;
}
}
+266
View File
@@ -0,0 +1,266 @@
import { KGCore } from '../../core/KGCore';
import { KGRegion } from '../../core/region/KGRegion';
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { KGTrack } from '../../core/track/KGTrack';
import { useProjectStore } from '../../stores/projectStore';
import { ConfigManager } from '../../core/config/ConfigManager';
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
/**
* Context data structure for system prompt template replacement
*/
interface SystemPromptContext {
bpm: number;
time_signature: string;
key_signature: string;
track_instrument: string;
current_region_start: number;
current_region_end: number;
}
/**
* System prompts for the AI agent with dynamic context loading
*/
export class SystemPrompts {
private static cachedTemplate: string | null = null;
private static readonly FALLBACK_PROMPT = `You are K.G.Studio Musician Assistant Agent, a highly skilled music musician with extensive knowledge in music theory, composition, and production.`;
/**
* Load the system prompt template from the public folder
*/
private static async loadTemplate(): Promise<string> {
if (this.cachedTemplate) {
return this.cachedTemplate;
}
try {
const response = await fetch('/prompts/system.md');
if (!response.ok) {
throw new Error(`Failed to load system prompt: ${response.status}`);
}
this.cachedTemplate = await response.text();
return this.cachedTemplate;
} catch (error) {
console.error('Failed to load system prompt template:', error);
return this.FALLBACK_PROMPT;
}
}
/**
* Find a region by ID across all tracks
*/
private static findRegionById(regionId: string): KGRegion | null {
const core = KGCore.instance();
const project = core.getCurrentProject();
const tracks = project.getTracks();
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === regionId);
if (region) {
return region;
}
}
return null;
}
/**
* Find track that contains the given region
*/
private static findTrackByRegion(region: KGRegion): KGTrack | null {
const core = KGCore.instance();
const project = core.getCurrentProject();
const tracks = project.getTracks();
return tracks.find(track => track.getRegions().includes(region)) || null;
}
/**
* Extract current project context from KGCore
*/
private static extractProjectContext(): Partial<SystemPromptContext> {
const core = KGCore.instance();
const project = core.getCurrentProject();
// Get basic project info
const context: Partial<SystemPromptContext> = {
bpm: project.getBpm(),
time_signature: `${project.getTimeSignature().numerator}/${project.getTimeSignature().denominator}`,
key_signature: project.getKeySignature(),
};
// Get current track instrument using the corrected logic
let trackInstrument = 'Piano'; // Default
// Step 1: Check if there's an active piano roll region
const activeRegionId = this.getActiveRegionId();
if (activeRegionId) {
const activeRegion = this.findRegionById(activeRegionId);
if (activeRegion) {
const track = this.findTrackByRegion(activeRegion);
if (track && track instanceof KGMidiTrack) {
trackInstrument = FLUIDR3_INSTRUMENT_MAP[track.getInstrument()].displayName;
}
}
} else {
// Step 2: Check if user has selected region(s)
const selectedItems = core.getSelectedItems();
const selectedRegion = selectedItems.find(item => item instanceof KGRegion) as KGRegion;
if (selectedRegion) {
const track = this.findTrackByRegion(selectedRegion);
if (track && track instanceof KGMidiTrack) {
trackInstrument = FLUIDR3_INSTRUMENT_MAP[track.getInstrument()].displayName;
}
} else {
// Step 3: Find the first track
const tracks = project.getTracks();
const firstMidiTrack = tracks.find(track => track instanceof KGMidiTrack) as KGMidiTrack;
if (firstMidiTrack) {
trackInstrument = FLUIDR3_INSTRUMENT_MAP[firstMidiTrack.getInstrument()].displayName;
}
}
}
context.track_instrument = trackInstrument;
return context;
}
/**
* Get active region ID from project store
*/
private static getActiveRegionId(): string | null {
try {
const store = useProjectStore.getState();
return store.activeRegionId;
} catch {
return null;
}
}
/**
* Extract current region context with fallback logic
*/
private static extractRegionContext(): Partial<SystemPromptContext> {
const core = KGCore.instance();
const project = core.getCurrentProject();
// Step 1: Try active piano roll region
const activeRegionId = this.getActiveRegionId();
if (activeRegionId) {
const activeRegion = this.findRegionById(activeRegionId);
if (activeRegion) {
return {
current_region_start: activeRegion.getStartFromBeat(),
current_region_end: activeRegion.getStartFromBeat() + activeRegion.getLength(),
};
}
}
// Step 2: Try selected region
const selectedItems = core.getSelectedItems();
const selectedRegion = selectedItems.find(item => item instanceof KGRegion) as KGRegion;
if (selectedRegion) {
return {
current_region_start: selectedRegion.getStartFromBeat(),
current_region_end: selectedRegion.getStartFromBeat() + selectedRegion.getLength(),
};
}
// Step 3: Fallback to project bounds
const timeSignature = project.getTimeSignature();
const beatsPerBar = timeSignature.numerator;
const maxBars = project.getMaxBars();
return {
current_region_start: 0,
current_region_end: maxBars * beatsPerBar,
};
}
/**
* Get full context by combining project and region data
*/
private static getFullContext(): SystemPromptContext {
const projectContext = this.extractProjectContext();
const regionContext = this.extractRegionContext();
return {
bpm: projectContext.bpm || 120,
time_signature: projectContext.time_signature || '4/4',
key_signature: projectContext.key_signature || 'C major',
track_instrument: projectContext.track_instrument || 'Piano',
current_region_start: regionContext.current_region_start || 0,
current_region_end: regionContext.current_region_end || 32,
};
}
/**
* Replace template variables with actual context values
*/
static replaceTemplateVariables(template: string, context: SystemPromptContext): string {
let result = template;
// Replace all context variables
result = result.replace(/{bpm}/g, context.bpm.toString());
result = result.replace(/{time_signature}/g, context.time_signature);
result = result.replace(/{key_signature}/g, context.key_signature);
result = result.replace(/{track_instrument}/g, context.track_instrument);
result = result.replace(/{current_region_start}/g, context.current_region_start.toString());
result = result.replace(/{current_region_end}/g, context.current_region_end.toString());
return result;
}
/**
* Apply context to an arbitrary prompt string
*/
static async getPromptWithContext(prompt: string): Promise<string> {
try {
const context = this.getFullContext();
return this.replaceTemplateVariables(prompt, context);
} catch (error) {
console.error('Error generating prompt with context:', error);
return prompt;
}
}
/**
* Get the system prompt with current context applied (backward compatible)
*/
static async getSystemPromptWithContext(): Promise<string> {
try {
const template = await this.loadTemplate();
let promptWithContext = await this.getPromptWithContext(template);
// Append custom instructions from config if provided
try {
const configManager = ConfigManager.instance();
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
const customInstructions = ((configManager.get('templates.custom_instructions') as string) || '').trim();
if (customInstructions.length > 0) {
promptWithContext += `\n\n====\n\nADDITIONAL INSTRUCTIONS\n\n${customInstructions}`;
}
} catch (e) {
console.warn('Failed to load custom instructions from config:', e);
}
return promptWithContext;
} catch (error) {
console.error('Error generating system prompt with context:', error);
return this.FALLBACK_PROMPT;
}
}
/**
* Clear the cached template (useful for development/testing)
*/
static clearCache(): void {
this.cachedTemplate = null;
}
}
+389
View File
@@ -0,0 +1,389 @@
/**
* XMLToolExecutor - Bridge between XML tool invocations and the existing tool system
* Parses XML blocks from LLM responses and executes corresponding tools
*/
import { extractXMLFromString } from '../../util/xmlUtil';
import { AVAILABLE_TOOLS, type ToolName } from '../tools';
import type { BaseTool, ToolResult } from '../tools/BaseTool';
import { useProjectStore } from '../../stores/projectStore';
/**
* Main executor class for XML-based tool invocations
* Integrates with existing tool architecture and streaming types
*/
export class XMLToolExecutor {
// Private static instance for singleton pattern
private static _instance: XMLToolExecutor | null = null;
// Private constructor to prevent direct instantiation
private constructor() {}
/**
* Get the singleton instance of XMLToolExecutor
*/
public static instance(): XMLToolExecutor {
if (!XMLToolExecutor._instance) {
XMLToolExecutor._instance = new XMLToolExecutor();
}
return XMLToolExecutor._instance;
}
/**
* Execute all XML tool invocations found in the given input string
* @param input - String containing XML tool invocations (typically LLM response)
* @returns Promise resolving to array of tool results in order of appearance
*/
public async executeXMLTools(input: string): Promise<ToolResult[]> {
try {
// Extract all XML blocks from the input
const xmlBlocks = extractXMLFromString(input);
if (xmlBlocks.length === 0) {
return [];
}
// Process each XML block and collect results
const results: ToolResult[] = [];
for (const xmlBlock of xmlBlocks) {
try {
const result = await this.executeXMLBlock(xmlBlock);
results.push(result);
} catch (error) {
// Create failed result
results.push({
success: false,
result: `Failed to process XML block: ${error}`
});
}
}
return results;
} catch (error) {
return [{
success: false,
result: `Failed to execute XML tools: ${error}`
}];
}
}
/**
* Execute a single XML block as a tool invocation
* @param xmlBlock - XML string representing a tool invocation
* @returns Promise resolving to tool execution result
*/
private async executeXMLBlock(xmlBlock: string): Promise<ToolResult> {
// Parse XML to extract tool information
const parseResult = this.parseXMLBlock(xmlBlock);
if (!parseResult.success) {
return {
success: false,
result: parseResult.error || 'Failed to parse XML block'
};
}
// Check if tool exists in registry
if (!(parseResult.toolName in AVAILABLE_TOOLS)) {
return {
success: false,
result: `Unknown tool: ${parseResult.toolName}`
};
}
try {
// Create tool instance
const ToolClass = AVAILABLE_TOOLS[parseResult.toolName as ToolName];
const toolInstance: BaseTool = new ToolClass();
// Execute the tool
const toolResult = await toolInstance.execute(parseResult.parameters);
// Sync UI state if the tool execution was successful
if (toolResult.success) {
this.syncUIState();
}
return toolResult;
} catch (error) {
return {
success: false,
result: `Tool execution failed: ${error}`
};
}
}
/**
* Parse XML block to extract tool name and parameters
* @param xmlBlock - XML string to parse
* @returns Parse result with tool information or error
*/
private parseXMLBlock(xmlBlock: string): { success: boolean; toolName: string; parameters: Record<string, unknown>; error?: string } {
try {
// Special pre-processing for attempt_completion: ensure <comment> is wrapped in CDATA
const preparedXml = this.preprocessAttemptCompletionXML(xmlBlock);
// Parse XML using native DOMParser
const parser = new DOMParser();
const doc = parser.parseFromString(preparedXml, 'text/xml');
// Check for parsing errors
const parserError = doc.querySelector('parsererror');
if (parserError) {
return {
success: false,
toolName: '',
parameters: {},
error: `XML parsing error: ${parserError.textContent}`
};
}
// Get the root element (tool name)
const rootElement = doc.documentElement;
const toolName = rootElement.tagName;
// Parse XML parameters
const parameters = this.parseXMLParameters(rootElement);
return {
success: true,
toolName,
parameters
};
} catch (error) {
return {
success: false,
toolName: '',
parameters: {},
error: `Failed to parse XML: ${error}`
};
}
}
/**
* Ensure special tools have CDATA-wrapped content where appropriate.
* - attempt_completion: wrap <comment> inner text with CDATA (if not already)
* - think / thinking: wrap root inner text with CDATA (if not already)
* Decode basic XML entities before wrapping so CDATA contains human-readable text.
*/
private preprocessAttemptCompletionXML(xml: string): string {
try {
const leadingWhitespaceMatch = xml.match(/^\s*/);
const prefix = leadingWhitespaceMatch ? leadingWhitespaceMatch[0] : '';
const withoutLeading = xml.slice(prefix.length);
const rootMatch = withoutLeading.match(/^<([A-Za-z_][\w-]*)\b/);
const root = rootMatch?.[1] || '';
if (root !== 'attempt_completion' && root !== 'think' && root !== 'thinking') return xml;
// Helper to decode entities
const decodeEntities = (text: string): string =>
text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'");
if (root === 'attempt_completion') {
// Find first <comment>...</comment>
const commentRegex = /<comment>([\s\S]*?)<\/comment>/i;
const match = xml.match(commentRegex);
if (!match) return xml;
const inner = match[1];
if (/<!\[CDATA\[/.test(inner)) {
// Already wrapped
return xml;
}
const decoded = decodeEntities(inner);
const replacement = `<comment><![CDATA[${decoded}]]></comment>`;
return xml.replace(commentRegex, replacement);
}
// Handle <think>...</think> or <thinking>...</thinking>
const rootRegex = new RegExp(`<${root}>([\\s\\S]*?)</${root}>`, 'i');
const rootMatchContent = xml.match(rootRegex);
if (!rootMatchContent) return xml;
const innerRoot = rootMatchContent[1];
if (/<!\[CDATA\[/.test(innerRoot)) {
return xml; // Already wrapped
}
const decodedRoot = decodeEntities(innerRoot);
const replacementRoot = `<${root}><![CDATA[${decodedRoot}]]></${root}>`;
return xml.replace(rootRegex, replacementRoot);
} catch {
// On any error, return original XML to avoid breaking flow
return xml;
}
}
/**
* Parse XML element into tool parameters object
* Converts XML structure to JavaScript object that matches tool parameter schema
* @param element - Root XML element containing tool parameters
* @returns Parameters object for tool execution
*/
private parseXMLParameters(element: Element): Record<string, unknown> {
const parameters: Record<string, unknown> = {};
// Special handling for thinking tool: if no child elements, use text content directly
if (element.tagName === 'thinking' && element.children.length === 0) {
const textContent = element.textContent?.trim() || '';
parameters.content = textContent;
return parameters;
}
// Process all child elements
for (const child of element.children) {
const paramName = child.tagName;
const paramValue = this.parseXMLValue(child);
// Handle arrays (multiple elements with same tag name)
if (parameters[paramName] !== undefined) {
// Convert to array if not already
if (!Array.isArray(parameters[paramName])) {
parameters[paramName] = [parameters[paramName]];
}
(parameters[paramName] as unknown[]).push(paramValue);
} else {
parameters[paramName] = paramValue;
}
}
// Apply array wrapper flattening
return this.flattenArrayWrappers(parameters);
}
/**
* Flatten array wrapper patterns in parsed parameters
* Converts structures like {notes: {note: [...]}} to {notes: [...]}
* @param parameters - Parsed parameters object
* @returns Parameters with flattened array wrappers
*/
private flattenArrayWrappers(parameters: Record<string, unknown>): Record<string, unknown> {
const flattened: Record<string, unknown> = {};
for (const [key, value] of Object.entries(parameters)) {
if (this.isArrayWrapperCandidate(key, value)) {
// This is an array wrapper - flatten it
const wrapperObj = value as Record<string, unknown>;
const innerKeys = Object.keys(wrapperObj);
if (innerKeys.length === 1) {
const innerKey = innerKeys[0];
const innerValue = wrapperObj[innerKey];
// Check if inner key is singular form of outer key
if (this.isSingularOf(innerKey, key)) {
// Flatten: {notes: {note: [...]}} → {notes: [...]}
flattened[key] = innerValue;
continue;
}
}
}
// No flattening needed, keep as is
flattened[key] = value;
}
return flattened;
}
/**
* Check if a value is a candidate for array wrapper flattening
* @param key - Parameter key (e.g., "notes")
* @param value - Parameter value to check
* @returns True if this looks like an array wrapper pattern
*/
private isArrayWrapperCandidate(key: string, value: unknown): boolean {
// Must be an object (not array, not primitive)
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
const obj = value as Record<string, unknown>;
const innerKeys = Object.keys(obj);
// Must have exactly one property
if (innerKeys.length !== 1) {
return false;
}
const innerKey = innerKeys[0];
const innerValue = obj[innerKey];
// Inner value should be an array or could become an array
// (single items are often converted to arrays by the parser)
return this.isSingularOf(innerKey, key) &&
(Array.isArray(innerValue) || typeof innerValue === 'object');
}
/**
* Check if one word is the singular form of another (simple heuristic)
* @param singular - Potential singular form (e.g., "note")
* @param plural - Potential plural form (e.g., "notes")
* @returns True if singular appears to be singular form of plural
*/
private isSingularOf(singular: string, plural: string): boolean {
// Simple heuristics for common English pluralization
if (plural === singular + 's') return true; // note → notes
if (plural === singular + 'es') return true; // box → boxes
if (plural.endsWith('ies') && singular.endsWith('y')) { // entry → entries
return plural === singular.slice(0, -1) + 'ies';
}
// Add more rules as needed for your specific use cases
return false;
}
/**
* Parse a single XML element value, handling different data types and structures
* @param element - XML element to parse
* @returns Parsed value (string, number, boolean, object, or array)
*/
private parseXMLValue(element: Element): unknown {
// If element has children, parse as object
if (element.children.length > 0) {
return this.parseXMLParameters(element);
}
// Get text content
const textContent = element.textContent?.trim() || '';
// Try to parse as number
if (/^-?\d+(\.\d+)?$/.test(textContent)) {
return parseFloat(textContent);
}
// Try to parse as boolean
if (textContent === 'true') return true;
if (textContent === 'false') return false;
// Return as string
return textContent;
}
/**
* Synchronize UI state after successful tool execution
* Uses the centralized refresh method from the project store
*/
private syncUIState(): void {
try {
// Use the centralized refresh method from the store
const storeActions = useProjectStore.getState();
if (storeActions.refreshProjectState) {
storeActions.refreshProjectState();
}
} catch (error) {
console.warn('Failed to sync UI state after XML tool execution:', error);
// Don't throw - UI sync failure shouldn't break tool execution
}
}
}