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:
Xiaohan-Tian
2026-04-05 18:59:25 -07:00
parent 597fb9a292
commit 2cd976ff96
32 changed files with 697 additions and 2310 deletions
+135 -72
View File
@@ -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);
}
}
}
+41 -18
View File
@@ -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;
}
}
}
-389
View File
@@ -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(/&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
}
}
}