initial public release.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user