refactor: migrate agent system from XML tool calling to OpenAI SDK with native function calling
- Replace custom XML-based tool parsing (XMLToolExecutor) with OpenAI SDK's native tool_calls via `openai` npm package (dangerouslyAllowBrowser) - Consolidate 4 LLM providers (OpenAI, Claude, Gemini, ClaudeOpenRouter) into a single OpenAI SDK-based LLMProvider compatible with any OpenAI-style API (OpenAI, OpenRouter, Ollama, vLLM) - Move agentic tool execution loop from ChatBox into AgentCore - Update Message type to support tool roles, tool_calls, and tool_call_id - Update system prompt to remove XML formatting instructions (~45% smaller) - Remove AttemptCompletionTool (replaced by stop_reason detection), ThinkTool, and ThinkingTool - Polish tool descriptions for OpenAI function calling schema compliance - Normalize base URLs by stripping /chat/completions suffix
This commit is contained in:
+135
-72
@@ -1,113 +1,188 @@
|
||||
import { LLMProvider } from '../llm/LLMProvider';
|
||||
import { AgentState } from './AgentState';
|
||||
import { SystemPrompts } from './SystemPrompts';
|
||||
import { AVAILABLE_TOOLS } from '../tools';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import type { StreamChunk } from '../llm/StreamingTypes';
|
||||
import type { ToolCall } from './AgentState';
|
||||
import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
||||
|
||||
/**
|
||||
* Main orchestrator for the AI agent system
|
||||
* Main orchestrator for the AI agent system.
|
||||
* Handles the full agentic loop: LLM streaming → tool execution → result feedback → repeat.
|
||||
*/
|
||||
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
|
||||
* Get OpenAI tool definitions for all available tools
|
||||
*/
|
||||
private getToolDefinitions(): OpenAIToolDefinition[] {
|
||||
return Object.values(AVAILABLE_TOOLS).map(ToolClass => {
|
||||
const tool = new ToolClass();
|
||||
return tool.getDefinition();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tool call and return the result
|
||||
*/
|
||||
private async executeTool(toolCall: ToolCall): Promise<{ success: boolean; result: string }> {
|
||||
const toolName = toolCall.function.name;
|
||||
const ToolClass = AVAILABLE_TOOLS[toolName as keyof typeof AVAILABLE_TOOLS];
|
||||
|
||||
if (!ToolClass) {
|
||||
return { success: false, result: `Unknown tool: ${toolName}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const params = JSON.parse(toolCall.function.arguments);
|
||||
const toolInstance = new ToolClass();
|
||||
const result = await toolInstance.execute(params);
|
||||
|
||||
// Sync UI state after successful tool execution
|
||||
if (result.success) {
|
||||
useProjectStore.getState().refreshProjectState();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { success: false, result: `Tool execution failed: ${error}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process user input and generate streaming response.
|
||||
* Handles the full agentic loop internally: if the LLM returns tool_calls,
|
||||
* execute them and feed results back until the LLM produces a final text 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
|
||||
|
||||
// Add user message to state
|
||||
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', '');
|
||||
|
||||
const tools = this.getToolDefinitions();
|
||||
|
||||
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);
|
||||
// Agentic loop: stream → check for tool calls → execute → repeat
|
||||
let continueLoop = true;
|
||||
|
||||
while (continueLoop) {
|
||||
const conversationHistory = this.agentState.getMessages();
|
||||
|
||||
// Pre-add an empty assistant message that we'll update as we stream
|
||||
this.currentAssistantMessageId = this.agentState.addMessage('assistant', '');
|
||||
|
||||
let assistantTextContent = '';
|
||||
const accumulatedToolCalls: ToolCall[] = [];
|
||||
let finishReason = 'stop';
|
||||
|
||||
for await (const chunk of this.llmProvider.generateStream(conversationHistory, systemPrompt, tools)) {
|
||||
if (chunk.type === 'text') {
|
||||
assistantTextContent += chunk.content;
|
||||
this.agentState.updateMessage(this.currentAssistantMessageId, assistantTextContent);
|
||||
yield chunk;
|
||||
} else if (chunk.type === 'tool_call' && chunk.toolCall) {
|
||||
accumulatedToolCalls.push(chunk.toolCall);
|
||||
} else if (chunk.type === 'done') {
|
||||
finishReason = chunk.finishReason ?? 'stop';
|
||||
}
|
||||
}
|
||||
|
||||
if (finishReason === 'tool_calls' && accumulatedToolCalls.length > 0) {
|
||||
// Update assistant message with tool calls
|
||||
this.agentState.updateMessage(
|
||||
this.currentAssistantMessageId,
|
||||
assistantTextContent || null,
|
||||
{ tool_calls: accumulatedToolCalls }
|
||||
);
|
||||
|
||||
// Execute each tool call and add results to conversation
|
||||
for (const toolCall of accumulatedToolCalls) {
|
||||
// Notify UI about the tool call
|
||||
yield { type: 'tool_call', content: '', toolCall };
|
||||
|
||||
const result = await this.executeTool(toolCall);
|
||||
|
||||
// Add tool result message to conversation history
|
||||
this.agentState.addMessage('tool', JSON.stringify(result), {
|
||||
tool_call_id: toolCall.id,
|
||||
});
|
||||
|
||||
// Notify UI about the tool result
|
||||
yield {
|
||||
type: 'tool_result',
|
||||
content: '',
|
||||
toolResult: {
|
||||
name: toolCall.function.name,
|
||||
success: result.success,
|
||||
result: result.result,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Clear assistant message ID before next iteration creates a new one
|
||||
this.currentAssistantMessageId = null;
|
||||
|
||||
// Continue loop — send tool results back to LLM
|
||||
} else {
|
||||
// LLM finished with text response (stop reason)
|
||||
this.agentState.updateMessage(this.currentAssistantMessageId, assistantTextContent);
|
||||
continueLoop = false;
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
|
||||
// Final update to ensure the complete response is stored
|
||||
if (assistantResponse) {
|
||||
this.agentState.updateMessage(this.currentAssistantMessageId, assistantResponse);
|
||||
}
|
||||
|
||||
yield { type: 'done', content: '', finishReason: 'stop' };
|
||||
} finally {
|
||||
// Clear the current message IDs when done (successfully or not)
|
||||
this.currentUserMessageId = null;
|
||||
this.currentAssistantMessageId = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -117,35 +192,23 @@ export class AgentCore {
|
||||
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;
|
||||
return this.currentUserMessageId !== 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,50 +2,73 @@
|
||||
* Manages the state of an agent conversation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tool call info attached to assistant messages (OpenAI function calling format)
|
||||
*/
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string; // JSON string of parameters
|
||||
};
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string | null;
|
||||
timestamp: number;
|
||||
tool_calls?: ToolCall[]; // present on assistant messages when LLM invokes tools
|
||||
tool_call_id?: string; // present on tool-result messages, links back to ToolCall.id
|
||||
}
|
||||
|
||||
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 {
|
||||
addMessage(
|
||||
role: 'user' | 'assistant' | 'tool',
|
||||
content: string | null,
|
||||
options?: { tool_calls?: ToolCall[]; tool_call_id?: string }
|
||||
): string {
|
||||
const message: Message = {
|
||||
id: this.generateMessageId(),
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
...(options?.tool_calls ? { tool_calls: options.tool_calls } : {}),
|
||||
...(options?.tool_call_id ? { tool_call_id: options.tool_call_id } : {}),
|
||||
};
|
||||
|
||||
|
||||
this.messages.push(message);
|
||||
return message.id;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the content of a message by ID
|
||||
*/
|
||||
updateMessage(messageId: string, content: string): boolean {
|
||||
updateMessage(messageId: string, content: string | null, options?: { tool_calls?: ToolCall[] }): boolean {
|
||||
const messageIndex = this.messages.findIndex(msg => msg.id === messageId);
|
||||
if (messageIndex !== -1) {
|
||||
this.messages[messageIndex].content = content;
|
||||
if (options?.tool_calls) {
|
||||
this.messages[messageIndex].tool_calls = options.tool_calls;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove a message by ID
|
||||
*/
|
||||
@@ -57,46 +80,46 @@ export class AgentState {
|
||||
}
|
||||
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)}`;
|
||||
}
|
||||
@@ -109,4 +132,4 @@ export class AgentState {
|
||||
setIsWorkingOnTask(isWorkingOnTask: boolean): void {
|
||||
this.isWorkingOnTask = isWorkingOnTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
/**
|
||||
* 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