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
+62 -174
View File
@@ -2,10 +2,6 @@ import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
import { FaPlus, FaBan, FaDownload } from 'react-icons/fa';
import { UserMessage, AssistantMessage } from './chat';
import { AgentCore } from '../agent/core/AgentCore';
import { OpenAIProvider } from '../agent/llm/OpenAIProvider';
import { ClaudeProvider } from '../agent/llm/ClaudeProvider';
import { ClaudeOpenRouterProvider } from '../agent/llm/ClaudeOpenRouterProvider';
import { GeminiProvider } from '../agent/llm/GeminiProvider';
import { LLMProvider } from '../agent/llm/LLMProvider';
import { ConfigManager } from '../core/config/ConfigManager';
import { useProjectStore } from '../stores/projectStore';
@@ -14,10 +10,8 @@ import { clearChatHistoryAndUI, registerClearChatUICallback } from '../util/chat
import { processUserMessage } from '../util/messageFilter/UserMessageFilter';
import { useStreamProcessor } from '../hooks/useStreamProcessor';
import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils';
import { extractActionableTools, executeAllTools } from '../utils/toolExecutionUtils';
import { formatLocalDateTime } from '../util/timeUtil';
import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil';
import { wrapXmlBlocksInContent } from '../util/xmlUtil';
import KGDropdown from './common/KGDropdown';
import type { ChatMessage } from '../types/projectTypes';
@@ -26,24 +20,31 @@ import type { ChatMessage } from '../types/projectTypes';
let hasShownWelcomeOnceInRuntime = false;
/**
* Create the appropriate LLM provider based on configuration
* Create the LLM provider from current configuration
*/
const createLLMProvider = (): LLMProvider => {
const createLLMProviderFromConfig = (): LLMProvider => {
const configManager = ConfigManager.instance();
const providerType = configManager.get('general.llm_provider') as string;
let apiKey: string;
let model: string;
let baseURL: string | undefined;
switch (providerType) {
case 'claude':
return new ClaudeProvider();
case 'gemini':
return new GeminiProvider();
case 'claude_openrouter':
return new ClaudeOpenRouterProvider();
case 'openai_compatible':
case 'openai':
apiKey = configManager.get('general.openai.api_key') as string;
model = configManager.get('general.openai.model') as string;
baseURL = undefined; // Uses OpenAI default
break;
case 'openai_compatible':
default:
return new OpenAIProvider();
apiKey = configManager.get('general.openai_compatible.api_key') as string;
model = configManager.get('general.openai_compatible.model') as string;
baseURL = configManager.get('general.openai_compatible.base_url') as string || undefined;
break;
}
return new LLMProvider(apiKey, model, baseURL);
};
interface ChatBoxProps {
@@ -53,17 +54,11 @@ interface ChatBoxProps {
const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const [inputValue, setInputValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Initialize with empty messages
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [lastUserMessage, setLastUserMessage] = useState<string>('');
// Tool execution state
const [isExecutingTools, setIsExecutingTools] = useState(false);
const [, setToolResults] = useState<string>(''); // placeholder for future display/use
const [, setCurrentToolIndex] = useState<number>(0); // placeholder for future display/use
// Track if this is the first message (for system prompt logging)
const [isFirstMessage, setIsFirstMessage] = useState(true);
@@ -77,8 +72,8 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const handleExportOptionSelect = (option: string) => {
if (option === 'Export conversation as JSON') {
try {
const messages = AgentCore.instance().getAgentState().getMessages();
const exportMessages = messages.map((m) => ({
const agentMessages = AgentCore.instance().getAgentState().getMessages();
const exportMessages = agentMessages.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
@@ -93,25 +88,21 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
} else if (option === 'Export conversation as Markdown') {
(async () => {
try {
const messages = AgentCore.instance().getAgentState().getMessages();
const agentMessages = AgentCore.instance().getAgentState().getMessages();
const templateUrl = `${import.meta.env.BASE_URL}chat/export_conversation_template.md`;
const res = await fetch(templateUrl);
const template = await res.text();
const isAutomatedUserMessage = (content: string): boolean => {
return /^tool:\s.*\nsuccess:\s*(true|false)/i.test(content);
};
const sections = messages.map((m) => {
const isAutomaticUserMessage = isAutomatedUserMessage(m.content);
const roleLabel = m.role === 'assistant' ? 'Assistant' : (isAutomaticUserMessage ? 'User (Automatic)' : 'User');
const ts = formatLocalDateTime(new Date(m.timestamp));
const contentWithXml = isAutomaticUserMessage ? "```\n" + m.content + "\n```" : wrapXmlBlocksInContent(m.content);
return template
.replace('{role}', roleLabel)
.replace('{timestamp}', ts)
.replace('{content}', contentWithXml);
});
const sections = agentMessages
.filter(m => m.role === 'user' || m.role === 'assistant')
.map((m) => {
const roleLabel = m.role === 'assistant' ? 'Assistant' : 'User';
const ts = formatLocalDateTime(new Date(m.timestamp));
return template
.replace('{role}', roleLabel)
.replace('{timestamp}', ts)
.replace('{content}', m.content ?? '');
});
const markdown = sections.join('\n');
const filename = `kgstudio-conversation-${buildTimestampSuffix()}.md`;
@@ -120,8 +111,6 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
console.error('Failed to export conversation as Markdown:', err);
}
})();
} else {
console.log('Chat export selected:', option);
}
setShowExportDropdown(false);
};
@@ -135,6 +124,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
setMessages(prev => [...prev, message]);
}, []);
const handleMessageRemove = useCallback((messageId: string) => {
setMessages(prev => prev.filter(msg => msg.id !== messageId));
}, []);
const handleProcessingChange = useCallback((processing: boolean) => {
setIsProcessing(processing);
}, []);
@@ -143,17 +136,14 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const streamProcessor = useStreamProcessor({
onMessageUpdate: handleMessageUpdate,
onMessageAdd: handleMessageAdd,
onMessageRemove: handleMessageRemove,
onProcessingChange: handleProcessingChange
});
const clearChatUI = useCallback(async () => {
// Clear UI state
setMessages([]);
// Reset first message flag so system prompt will be logged again
setIsFirstMessage(true);
// Auto-show welcome message after clearing (like on app startup)
const welcomeMessage = await addWelcomeMessage();
if (welcomeMessage) {
setMessages([welcomeMessage]);
@@ -164,47 +154,37 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
useEffect(() => {
const initializeProvider = async () => {
const configManager = ConfigManager.instance();
// Ensure ConfigManager is initialized
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
const applyProviderFromConfig = () => {
const provider = createLLMProvider();
const provider = createLLMProviderFromConfig();
const agentCore = AgentCore.instance();
agentCore.setLLMProvider(provider);
console.log(`Switched to ${provider.name} provider`);
console.log('LLM provider configured');
};
// Initial apply
applyProviderFromConfig();
// Subscribe to config changes to hot-swap providers
const unsubscribe = configManager.addChangeListener((changedKeys) => {
// Hot-swap on provider change or when relevant provider config changes
if (
changedKeys.includes('general.llm_provider') ||
changedKeys.some(k => k.startsWith('general.openai.')) ||
changedKeys.some(k => k.startsWith('general.openai_compatible.')) ||
changedKeys.some(k => k.startsWith('general.claude_openrouter.')) ||
changedKeys.some(k => k.startsWith('general.gemini.')) ||
changedKeys.some(k => k.startsWith('general.claude.'))
changedKeys.some(k => k.startsWith('general.openai_compatible.'))
) {
applyProviderFromConfig();
}
});
// Cleanup subscription on unmount
return unsubscribe;
};
// Register the UI clear callback for external components to use
registerClearChatUICallback(clearChatUI);
const maybeUnsubscribePromise = initializeProvider();
// Auto-trigger welcome on first launch (guard against React StrictMode double-invoke only)
(async () => {
if (hasShownWelcomeOnceInRuntime) return;
hasShownWelcomeOnceInRuntime = true;
@@ -214,7 +194,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
setMessages([welcomeMessage]);
}
})();
// In case initializeProvider returned a cleanup, ensure we call it
return () => {
Promise.resolve(maybeUnsubscribePromise).then((cleanup) => {
if (typeof cleanup === 'function') cleanup();
@@ -226,17 +206,12 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const controller = streamProcessor.abortController;
if (controller) {
controller.abort();
// Use AgentCore to clean up the data model and get the user message content
const agentCore = AgentCore.instance();
const userMessageContent = agentCore.abortCurrentRequest();
// Remove the last user message and assistant message from UI
setMessages(prev => prev.slice(0, -2));
// Restore the user's input (use the content from AgentCore if available)
setInputValue(userMessageContent || lastUserMessage);
setIsProcessing(false);
}
};
@@ -246,101 +221,29 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
clearChatHistoryAndUI(setStatus);
};
const executeToolsFromResponse = async (response: string): Promise<boolean> => {
try {
const actionableBlocks = extractActionableTools(response);
if (actionableBlocks.length === 0) {
// No actionable tools to execute, stop the loop
return false;
}
// Start tool execution phase
setIsExecutingTools(true);
setToolResults('');
setCurrentToolIndex(0);
const { setStatus } = useProjectStore.getState();
// Execute all tools and get accumulated results
const accumulatedResults = await executeAllTools(actionableBlocks, {
onMessageAdd: handleMessageAdd,
onStatusUpdate: setStatus
});
// Store accumulated results
setToolResults(accumulatedResults);
setIsExecutingTools(false);
// Check if agent is still working on task before sending results to LLM
const agentCore = AgentCore.instance();
const isStillWorkingOnTask = agentCore.getAgentState().getIsWorkingOnTask();
if (isStillWorkingOnTask) {
// Send tool results back to LLM
setStatus('Processing tool results...');
await sendToolResultsToLLM(accumulatedResults);
} else {
// Agent is no longer working on task, ignore results and return control to user
setStatus('Tool execution completed');
}
return true; // Tools were found and executed
} catch (error) {
console.error('Error executing tools:', error);
setIsExecutingTools(false);
const { setStatus } = useProjectStore.getState();
setStatus(`Tool execution failed: ${error}`);
return false; // Tool execution failed
}
};
const sendToolResultsToLLM = async (toolResultsString: string): Promise<void> => {
// Process tool results through the stream processor
const assistantResponse = await streamProcessor.processStream(toolResultsString, 'TOOL_RESULTS');
// Check if the new response contains more tools
const hasMoreTools = await executeToolsFromResponse(assistantResponse);
// If no more tools were found, set working flag to false
if (!hasMoreTools) {
const agentCore = AgentCore.instance();
agentCore.getAgentState().setIsWorkingOnTask(false);
}
};
const handleSend = async () => {
if (inputValue.trim() && !isProcessing) {
const userMessage = inputValue.trim();
setLastUserMessage(userMessage);
setInputValue('');
// Run message through the filter system
const filterResult = await processUserMessage(userMessage);
// Conditionally show the user message bubble
if (filterResult.displayUserMessage) {
const userMsgObject = createMessage('user', userMessage);
handleMessageAdd(userMsgObject);
}
// If we have a pseudo assistant response, show it immediately
if (filterResult.pseudoAssistantResponse) {
const pseudoMessage = createMessage('assistant', filterResult.pseudoAssistantResponse);
handleMessageAdd(pseudoMessage);
}
// If we shouldn't send anything to LLM, stop here
if (!filterResult.sendToLLM || !filterResult.finalMessageForLLM) {
return;
}
// Set working on task flag when user sends a message
const agentCore = AgentCore.instance();
agentCore.getAgentState().setIsWorkingOnTask(true);
// Log system prompt only for first message or first message after clear
// Log system prompt only for first message
if (isFirstMessage) {
try {
const systemPrompt = await SystemPrompts.getSystemPromptWithContext();
@@ -350,20 +253,11 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
} catch (error) {
console.error('Failed to log system prompt:', error);
}
// Mark that we've logged the system prompt for this conversation
setIsFirstMessage(false);
}
// Process user input through the stream processor
const assistantResponse = await streamProcessor.processStream(filterResult.finalMessageForLLM, 'USER');
// Check if response contains tools to execute
const hasTools = await executeToolsFromResponse(assistantResponse);
// If no tools were found, set working flag to false and return control to user
if (!hasTools) {
agentCore.getAgentState().setIsWorkingOnTask(false);
}
// Process through stream processor — AgentCore handles the full agentic loop internally
await streamProcessor.processStream(filterResult.finalMessageForLLM, 'USER');
}
};
@@ -372,18 +266,15 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
e.preventDefault();
handleSend();
}
// Allow Shift+Enter for new lines (default textarea behavior)
};
const handleInputFocus = () => {
// Set a data attribute on the textarea to help global keyboard handler identify it
if (textareaRef.current) {
textareaRef.current.setAttribute('data-chatbox-input', 'true');
}
};
const handleInputBlur = () => {
// Remove the data attribute when losing focus
if (textareaRef.current) {
textareaRef.current.removeAttribute('data-chatbox-input');
}
@@ -403,18 +294,15 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto-focus input when it becomes visible (when processing and tool execution complete)
// Auto-focus input when processing completes
useEffect(() => {
if (!isProcessing && !isExecutingTools && textareaRef.current) {
// Use a small delay to ensure the DOM has updated
if (!isProcessing && textareaRef.current) {
setTimeout(() => {
textareaRef.current?.focus();
// Also scroll to bottom when input becomes visible after tool execution
// This ensures proper scroll position after layout changes from showing input box
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 0);
}
}, [isProcessing, isExecutingTools]);
}, [isProcessing]);
return (
<div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}>
@@ -463,14 +351,14 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
</button>
</div>
</div>
<div className="chatbox-messages">
{messages.map((message) => (
message.role === 'user' ? (
<UserMessage key={message.id} content={message.content} />
) : (
<AssistantMessage
key={message.id}
<AssistantMessage
key={message.id}
content={message.content}
isStreaming={message.isStreaming}
onAbort={message.isStreaming ? handleAbort : undefined}
@@ -479,8 +367,8 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
))}
<div ref={messagesEndRef} />
</div>
{!isProcessing && !isExecutingTools && (
{!isProcessing && (
<div className="chatbox-input-area">
<textarea
ref={textareaRef}
@@ -499,4 +387,4 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
);
};
export default memo(ChatBox);
export default memo(ChatBox);
+11 -131
View File
@@ -1,39 +1,8 @@
import React, { memo, useState } from 'react';
import React, { memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { extractXMLFromString } from '../../util/xmlUtil';
interface ToolXMLExpanderProps {
toolName: string;
xmlContent: string;
}
const ToolXMLExpander: React.FC<ToolXMLExpanderProps> = ({ toolName, xmlContent }) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="tool-xml-expander">
<div
className="tool-xml-expander-header"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="tool-xml-expander-arrow">
{isExpanded ? '▼' : '▶'}
</span>
<span className="tool-xml-expander-title">
🔧 Tool: {toolName}
</span>
</div>
{isExpanded && (
<div className="tool-xml-expander-content">
{xmlContent}
</div>
)}
</div>
);
};
interface AssistantMessageProps {
content: string;
@@ -62,95 +31,34 @@ const CodeComponent = memo(({ inline, className, children, ...props }: any) => {
});
const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreaming, onAbort }) => {
// Function to process content and replace XML blocks with expanders
const processContentWithXMLExpanders = (text: string) => {
const xmlBlocks = extractXMLFromString(text);
if (xmlBlocks.length === 0) {
// No XML blocks found, return content as-is
return text;
}
let processedContent = text;
const expanders: React.ReactElement[] = [];
let expanderIndex = 0;
// Replace each XML block with a placeholder
xmlBlocks.forEach((xmlBlock) => {
const toolNameMatch = xmlBlock.match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/);
const toolName = toolNameMatch ? toolNameMatch[1] : 'unknown_tool';
const placeholder = `__XML_EXPANDER_${expanderIndex}__`;
processedContent = processedContent.replace(xmlBlock, placeholder);
expanders[expanderIndex] = (
<ToolXMLExpander
key={`xml-expander-${expanderIndex}`}
toolName={toolName}
xmlContent={xmlBlock}
/>
);
expanderIndex++;
});
// Split content by placeholders and interleave with expanders
const parts = processedContent.split(/__XML_EXPANDER_\d+__/);
const result: (string | React.ReactElement)[] = [];
for (let i = 0; i < parts.length; i++) {
if (parts[i]) {
result.push(parts[i]);
}
if (i < expanders.length) {
result.push(expanders[i]);
}
}
return result;
};
// Handle special abort link for streaming messages
const renderContent = () => {
// Handle special abort link for streaming messages
if (isStreaming && onAbort && content.includes('click here to abort')) {
// Check if content has the processing wave HTML
const hasProcessingWave = content.includes('<span class="processing-wave">Processing...</span>');
if (hasProcessingWave) {
// Parse the content to handle both the wave animation and abort link
const parts = content.split('click here to abort');
const beforeAbort = parts[0];
const afterAbort = parts[1];
// Replace the HTML span with JSX
const processedBefore = beforeAbort.replace(
const beforeAbort = parts[0].replace(
'<span class="processing-wave">Processing...</span>',
''
);
return (
<span>
<span className="processing-wave">Processing...</span>
{processedBefore}
<button
onClick={onAbort}
className="abort-link"
>
{beforeAbort}
<button onClick={onAbort} className="abort-link">
click here to abort
</button>
{afterAbort}
{parts[1]}
</span>
);
} else {
// Original logic for non-wave processing messages
const parts = content.split('click here to abort');
return (
<span>
{parts[0]}
<button
onClick={onAbort}
className="abort-link"
>
<button onClick={onAbort} className="abort-link">
click here to abort
</button>
{parts[1]}
@@ -159,34 +67,6 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
}
}
const processedContent = processContentWithXMLExpanders(content);
// If we have mixed content (text + React elements), render them separately
if (Array.isArray(processedContent)) {
return (
<div>
{processedContent.map((item, index) => {
if (typeof item === 'string') {
return (
<ReactMarkdown
key={`text-${index}`}
remarkPlugins={[remarkGfm]}
components={{
code: CodeComponent,
}}
>
{item}
</ReactMarkdown>
);
} else {
return item; // React element (expander)
}
})}
</div>
);
}
// Plain text content, render with markdown
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
@@ -194,7 +74,7 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
code: CodeComponent,
}}
>
{processedContent as string}
{content}
</ReactMarkdown>
);
};
@@ -208,4 +88,4 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
);
};
export default memo(AssistantMessage);
export default memo(AssistantMessage);
@@ -368,7 +368,7 @@ const GeneralSettings: React.FC = () => {
<input
type="text"
className="settings-input"
placeholder="e.g. https://openrouter.ai/api/v1/chat/completions"
placeholder="e.g. https://openrouter.ai/api/v1"
value={claudeOpenRouterBaseUrl}
onChange={(e) => handleClaudeOpenRouterBaseUrlChange(e.target.value)}
/>
@@ -424,7 +424,7 @@ const GeneralSettings: React.FC = () => {
<input
type="text"
className="settings-input"
placeholder="e.g. https://openrouter.ai/api/v1/chat/completions"
placeholder="e.g. https://openrouter.ai/api/v1"
value={compatibleBaseUrl}
onChange={(e) => handleCompatibleBaseUrlChange(e.target.value)}
/>