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
+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);