| <!DOCTYPE html> |
| <html> |
| <head> |
| <title>Tool-Calling Agent With Local LLM</title> |
| <script src="https://cdn.jsdelivr.net/pyodide/v0.27.7/full/pyodide.js"></script> |
| <script src="config.js"></script> |
| <meta content="text/html;charset=utf-8" http-equiv="Content-Type"> |
| <meta content="utf-8" http-equiv="encoding"> |
| <style> |
| body { |
| font-family: Arial, sans-serif; |
| max-width: 800px; |
| margin: 0 auto; |
| padding: 20px; |
| background-color: #f5f5f5; |
| } |
| .container { |
| background: white; |
| padding: 30px; |
| border-radius: 10px; |
| box-shadow: 0 2px 10px rgba(0,0,0,0.1); |
| } |
| h1 { |
| color: #333; |
| text-align: center; |
| margin-bottom: 30px; |
| } |
| |
| .description { |
| background: #f8f9fa; |
| border-left: 4px solid #007cba; |
| padding: 20px; |
| margin-bottom: 30px; |
| border-radius: 5px; |
| color: #555; |
| line-height: 1.6; |
| } |
| .description h2 { |
| color: #333; |
| margin-top: 0; |
| margin-bottom: 15px; |
| font-size: 20px; |
| } |
| .description p { |
| margin-bottom: 12px; |
| } |
| .description ul { |
| margin-bottom: 0; |
| padding-left: 20px; |
| } |
| .description li { |
| margin-bottom: 8px; |
| } |
| .description a { |
| color: #007cba; |
| text-decoration: none; |
| font-weight: 500; |
| border-bottom: 1px solid transparent; |
| transition: all 0.2s ease; |
| } |
| .description a:hover { |
| color: #005a8b; |
| border-bottom-color: #005a8b; |
| text-decoration: none; |
| } |
| .description a:visited { |
| color: #007cba; |
| } |
| |
| .input-group { |
| margin-bottom: 20px; |
| } |
| .server-config { |
| background: #f8f9fa; |
| border: 1px solid #dee2e6; |
| border-radius: 5px; |
| padding: 15px; |
| margin-bottom: 15px; |
| } |
| .server-config h3 { |
| margin: 0 0 10px 0; |
| color: #495057; |
| font-size: 16px; |
| } |
| .slim-input { |
| height: 40px !important; |
| font-size: 13px; |
| } |
| label { |
| display: block; |
| margin-bottom: 5px; |
| font-weight: bold; |
| color: #555; |
| } |
| input[type="text"], input[type="password"], textarea { |
| width: 100%; |
| padding: 12px; |
| border: 2px solid #ddd; |
| border-radius: 5px; |
| font-size: 14px; |
| box-sizing: border-box; |
| } |
| textarea { |
| height: 100px; |
| resize: vertical; |
| font-family: inherit; |
| } |
| button { |
| background: #007cba; |
| color: white; |
| border: none; |
| padding: 12px 24px; |
| border-radius: 5px; |
| cursor: pointer; |
| font-size: 16px; |
| margin: 5px; |
| } |
| button:hover { background: #005a8b; } |
| button:disabled { |
| background: #ccc; |
| cursor: not-allowed; |
| } |
| #initOutput, #agentOutput { |
| background: #f8f9fa; |
| border: 2px solid #e9ecef; |
| border-radius: 5px; |
| padding: 15px; |
| margin-top: 20px; |
| min-height: 150px; |
| font-family: 'Courier New', monospace; |
| font-size: 14px; |
| white-space: pre-wrap; |
| max-height: 300px; |
| overflow-y: auto; |
| } |
| |
| #initOutput { |
| margin-bottom: 20px; |
| } |
| .status { |
| padding: 10px; |
| border-radius: 5px; |
| margin: 10px 0; |
| } |
| .success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; } |
| .error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } |
| .info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; } |
| .warning { background: #fff3cd; color: #856404; border: 1px solid #ffeaa7; } |
| |
| .example-prompts, .server-presets { |
| margin: 10px 0; |
| } |
| .example-prompts button, .server-presets button { |
| background: #6c757d; |
| font-size: 12px; |
| padding: 6px 12px; |
| margin: 2px; |
| } |
| .example-prompts button:hover, .server-presets button:hover { |
| background: #5a6268; |
| } |
| |
| .config-info { |
| background: #e7f3ff; |
| border: 1px solid #b8daff; |
| border-radius: 5px; |
| padding: 10px; |
| margin-bottom: 20px; |
| font-size: 14px; |
| } |
| |
| .running-indicator { |
| display: none; |
| background: #fff3cd; |
| border: 1px solid #ffeaa7; |
| border-radius: 5px; |
| padding: 15px; |
| margin: 15px 0; |
| font-weight: bold; |
| color: #856404; |
| text-align: center; |
| font-size: 16px; |
| animation: pulse 1.5s infinite; |
| box-shadow: 0 2px 5px rgba(0,0,0,0.1); |
| } |
| |
| @keyframes pulse { |
| 0%, 100% { opacity: 0.7; } |
| 50% { opacity: 1; } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1>π€ Tool-Calling Agent With Local LLM</h1> |
|
|
| <div class="description"> |
| <p> |
| This interactive web application allows you to experiment with AI agents in your browser, using |
| <a href="https://openai.github.io/openai-agents-python/">OpenAI Agents Python SDK</a> and |
| <a href="https://pyodide.org/en/stable/">Pyodide</a>. |
| You can customize agent behavior, test different prompts, and see responses in real-time. |
| Check the <a href="https://github.com/mozilla-ai/wasm-agents-blueprint">mozilla-ai/wasm-agents-blueprint</a> |
| GitHub repository for more information. |
| </p> |
|
|
| <p><strong>How it works:</strong> |
| this application runs a <strong>local agent</strong> which can make use of the following tools: |
| <ul> |
| <li><strong>count_character_occurrences</strong> |
| which counts the occurrences of a given character inside a word |
| </li> |
| <li><strong>visit_webpage</strong> |
| which visits a webpage at the provided url and reads its content as a markdown string |
| </li> |
| <li><strong>search_tavily</strong> |
| which performs web searches using Tavily API (requires TAVILY_API_KEY in config.js) |
| </li> |
| </ul> |
| While the former tool is quite trivial and is mainly used to show how to address the |
| <a href="https://community.openai.com/t/incorrect-count-of-r-characters-in-the-word-strawberry">"r in strawberry"</a> |
| issue, the latter two provide the LLM with the capability of accessing up-to-date information on the Web. |
| </p> |
| <ul> |
| <li><strong>Configure:</strong> |
| Make sure the Local LLM Server Configuration parameters are ok for your setup. In particular, |
| the default expects you to have <a href="https://ollama.com/">Ollama</a> running on your system |
| with the <a href="https://ollama.com/library/qwen3:8b">qwen3:8b</a> model installed. You |
| can also click the <strong>LM Studio</strong> preset button if you are using |
| <a href="https://lmstudio.ai/">LM Studio</a>, and make sure to update your model name accordingly. |
| Optionally, add your TAVILY_API_KEY to config.js to enable web search functionality. |
| <br/><strong>NOTE</strong>: if you are using LM Studio with a thinking model and are getting tool |
| calls directly in the model's response, disable thinking in the "Edit model default parameters" section. |
| </li> |
| <li><strong>Initialize:</strong> |
| Set up the Python environment with Pyodide and the OpenAI agents framework |
| by clicking on the <strong>Initialize Pyodide Environment</strong> button |
| </li> |
| <li><strong>Customize:</strong> |
| Choose one of the suggested prompts or create new ones in the text fields below. |
| (<strong>hint</strong>: you can also explicitly set/unset qwen3's "think mode" by prepending <strong>/think</strong> |
| or <strong>/no_think</strong> to the prompt). |
| </li> |
| <li><strong>Run:</strong> |
| Click on the <strong>Run Agent</strong> button to send your prompt to the agent and see what happens |
| </li> |
| </ul> |
| </div> |
|
|
| <div class="config-info"> |
| <strong>π Configuration:</strong> Config loaded from config.js |
| <span id="configStatus"></span> |
| </div> |
|
|
| <button onclick="initializePyodide()" id="initBtn">βοΈ Initialize Pyodide Environment</button> |
|
|
| <div id="initOutput">Click "Initialize Pyodide Environment" to set up the Python environment...</div> |
|
|
| <div class="server-config"> |
| <h3>π Local LLM Server Configuration</h3> |
| <div class="input-group"> |
| <label for="baseUrl">Base URL:</label> |
| <input type="text" id="baseUrl" class="slim-input" value="http://localhost:1234/v1" placeholder="Enter server base URL"> |
| </div> |
| <div class="input-group"> |
| <label for="apiKey">API Key:</label> |
| <input type="text" id="apiKey" class="slim-input" value="lmstudio" placeholder="Enter API key"> |
| </div> |
| <div class="input-group"> |
| <label for="modelName">Model Name:</label> |
| <input type="text" id="modelName" class="slim-input" value="qwen/qwen3-8b" placeholder="Enter model name"> |
| </div> |
|
|
| <div class="server-presets"> |
| <small>Quick presets:</small> |
| <button onclick="setOllamaDefaults()">Ollama</button> |
| <button onclick="setLMStudioDefaults()">LM Studio</button> |
| </div> |
| </div> |
|
|
| <div class="input-group"> |
| <label for="prompt">Custom Prompt:</label> |
| <textarea id="prompt" placeholder="Enter your prompt here...">How many times does the letter r occur in the word strawrberrry?</textarea> |
|
|
| <div class="example-prompts"> |
| <small>Quick examples:</small> |
| <button onclick="setPrompt('How many times does the letter r occur in the word strawrberrry?')">Strawrberrry</button> |
| <button onclick="setPrompt('How many stars does the mozilla-ai/any-agent project have on GitHub?')">GitHub stars</button> |
| <button onclick="setPrompt('What is the title of the latest post on https:\/\/aittalam.github.io, when was it published, what is it about, and what is the absolute URL of the image at the beginning of the post?\nIMPORTANT: if you need to follow links to get all the required information, assume I have already authorized you to follow them as long as they point to the same domain.')">Blog post</button> |
| <button onclick="setPrompt('What are 5 tv shows that are trending in 2025? Please provide the name of the show, the exact release date, the genre, and a brief description of the show.\nIMPORTANT: if you need to follow links to get all the required information, assume I have already authorized you to follow them. Always download full webpage contents.')">Trending TV Shows</button> |
| </div> |
| </div> |
|
|
| <button onclick="runAgent()" id="runBtn" disabled>π Run Agent</button> |
| <button onclick="clearAgentOutput()" id="clearAgentBtn" disabled>ποΈ Clear Agent Output</button> |
|
|
| <div class="running-indicator" id="runningIndicator">π Running Python code...</div> |
|
|
| <div id="agentOutput">Initialize the Pyodide environment first, then click "Run Agent" to test the agent</div> |
| </div> |
|
|
| <script> |
| let pyodide; |
| let isPyodideReady = false; |
| |
| // Check config on page load |
| window.addEventListener('load', function() { |
| checkConfig(); |
| }); |
| |
| function showRunning(message = "Python running") { |
| const indicator = document.getElementById('runningIndicator'); |
| indicator.style.display = 'block'; |
| console.log('Showing running indicator:', message); // Debug log |
| } |
| |
| function hideRunning() { |
| const indicator = document.getElementById('runningIndicator'); |
| indicator.style.display = 'none'; |
| console.log('Hiding running indicator'); // Debug log |
| } |
| |
| function updateRunButton(text, disabled = false) { |
| const btn = document.getElementById('runBtn'); |
| btn.textContent = text; |
| btn.disabled = disabled; |
| } |
| |
| function checkConfig() { |
| const configStatus = document.getElementById('configStatus'); |
| let statusParts = []; |
| |
| if (typeof window.APP_CONFIG === 'undefined') { |
| configStatus.innerHTML = ' - <span style="color: #dc3545;">β Config not loaded</span>'; |
| return { configLoaded: false, tavilyAvailable: false }; |
| } |
| |
| // Check if Tavily API key is available |
| const tavilyAvailable = window.APP_CONFIG.TAVILY_API_KEY && |
| window.APP_CONFIG.TAVILY_API_KEY !== 'your-tavily-api-key-here'; |
| |
| if (tavilyAvailable) { |
| statusParts.push('<span style="color: #28a745;">β
Tavily API key configured</span>'); |
| } else { |
| statusParts.push('<span style="color: #ffc107;">β οΈ Tavily API key not set (search_tavily tool will be disabled)</span>'); |
| } |
| |
| configStatus.innerHTML = ' - ' + statusParts.join(', '); |
| return { configLoaded: true, tavilyAvailable: tavilyAvailable }; |
| } |
| |
| function setOllamaDefaults() { |
| document.getElementById('baseUrl').value = 'http://localhost:11434/v1'; |
| document.getElementById('apiKey').value = 'ollama'; |
| document.getElementById('modelName').value = 'qwen3:8b'; |
| } |
| |
| function setLMStudioDefaults() { |
| document.getElementById('baseUrl').value = 'http://localhost:1234/v1'; |
| document.getElementById('apiKey').value = 'lmstudio'; |
| document.getElementById('modelName').value = 'mistralai/devstral-small-2507'; |
| } |
| |
| function logToElement(message, type = 'info', element_id) { |
| const output = document.getElementById(element_id); |
| const timestamp = new Date().toLocaleTimeString(); |
| |
| let prefix = ''; |
| switch(type) { |
| case 'success': prefix = 'β
'; break; |
| case 'error': prefix = 'β'; break; |
| case 'warning': prefix = 'β οΈ'; break; |
| case 'info': prefix = 'βΉοΈ'; break; |
| } |
| |
| output.textContent += `\n[${timestamp}] ${prefix} ${message}`; |
| output.scrollTop = output.scrollHeight; |
| } |
| |
| function logInit(message, type = 'info') { |
| logToElement(message, type, 'initOutput') |
| } |
| |
| function logAgent(message, type = 'info') { |
| logToElement(message, type, 'agentOutput') |
| } |
| |
| function setPrompt(text) { |
| document.getElementById('prompt').value = text; |
| } |
| |
| function clearAgentOutput() { |
| document.getElementById('agentOutput').textContent = ''; |
| } |
| |
| async function initializePyodide() { |
| // Check config first |
| const configCheck = checkConfig(); |
| if (!configCheck.configLoaded) { |
| logInit("Config not loaded, but proceeding anyway", 'warning'); |
| } |
| |
| // Disable init button during setup |
| document.getElementById('initBtn').disabled = true; |
| |
| try { |
| logInit("π Loading Pyodide..."); |
| pyodide = await loadPyodide(); |
| logInit("Pyodide loaded successfully", 'success'); |
| |
| logInit("π¦ Loading micropip..."); |
| await pyodide.loadPackage("micropip"); |
| logInit("micropip loaded", 'success'); |
| |
| logInit("π¦ Installing openai-agents (this may take a moment)..."); |
| await pyodide.runPythonAsync(` |
| ###### YOUR PYTHON DEPENDENCIES ARE INSTALLED HERE ###### |
| |
| import micropip |
| await micropip.install("typing-extensions>=4.12.2") |
| await micropip.install("openai==1.99.9") |
| await micropip.install("mcp==1.12.4") |
| await micropip.install("openai-agents==0.2.6") |
| await micropip.install("sqlite3==1.0.0") |
| await micropip.install("markdownify==1.1.0") |
| await micropip.install("tavily-python==0.7.10") |
| `); |
| logInit("openai-agents installed successfully", 'success'); |
| |
| logInit("π« Disabling tracing to avoid threading issues..."); |
| await pyodide.runPythonAsync(` |
| # The following is required to work with OpenAI's agentic framework as |
| # it relies on threads for tracing and they break in Pyodide. Other |
| # frameworks (e.g. smolagents) that use asyncio for tracing work properly |
| # (well... better) here. |
| |
| from agents import set_tracing_disabled |
| set_tracing_disabled(True) |
| `); |
| logInit("Tracing disabled", 'success'); |
| |
| logInit("β
Pyodide environment ready!", 'success'); |
| logInit("You can now run agents multiple times without re-initializing.", 'info'); |
| |
| isPyodideReady = true; |
| document.getElementById('runBtn').disabled = false; |
| document.getElementById('clearAgentBtn').disabled = false; |
| document.getElementById('initBtn').textContent = "β
Environment Ready"; |
| |
| } catch (error) { |
| logInit(`Initialization failed: ${error}`, 'error'); |
| console.error('Full error:', error); |
| document.getElementById('initBtn').disabled = false; |
| } |
| } |
| |
| async function runAgent() { |
| if (!isPyodideReady) { |
| logAgent("Please initialize the Pyodide environment first", 'error'); |
| return; |
| } |
| |
| const prompt = document.getElementById('prompt').value.trim(); |
| const baseUrl = document.getElementById('baseUrl').value.trim(); |
| const apiKey = document.getElementById('apiKey').value.trim(); |
| const modelName = document.getElementById('modelName').value.trim(); |
| |
| if (!prompt) { |
| logAgent("Please enter a prompt", 'error'); |
| return; |
| } |
| |
| if (!baseUrl || !apiKey || !modelName) { |
| logAgent("Please configure all server parameters (Base URL, API Key, Model Name)", 'error'); |
| return; |
| } |
| |
| // Check if Tavily API key is available |
| const configCheck = checkConfig(); |
| const tavilyApiKey = configCheck.tavilyAvailable ? window.APP_CONFIG.TAVILY_API_KEY : null; |
| |
| // Disable button during execution |
| document.getElementById('runBtn').disabled = true; |
| showRunning("Running Python code"); |
| |
| try { |
| logAgent("π€ Setting up agent and running..."); |
| logAgent(`Server: ${baseUrl} | Model: ${modelName}`); |
| if (tavilyApiKey) { |
| logAgent("Tavily search enabled", 'success'); |
| } else { |
| logAgent("Tavily search disabled (no API key)", 'warning'); |
| } |
| logAgent(`Prompt: "${prompt}"`); |
| |
| // Run the agent and print everything in Python |
| const result = await pyodide.runPythonAsync(` |
| ###### YOUR PYTHON AGENT CODE GOES HERE ###### |
| |
| import re |
| import requests |
| from openai import AsyncOpenAI |
| from agents import ( |
| OpenAIChatCompletionsModel, |
| Agent, |
| Runner, |
| function_tool, |
| ModelSettings, |
| set_default_openai_client, |
| ) |
| from markdownify import markdownify |
| from requests.exceptions import RequestException |
| from tavily.tavily import TavilyClient |
| |
| def _truncate_content(content: str, max_length: int) -> str: |
| if len(content) <= max_length: |
| return content |
| return ( |
| content[: max_length // 2] |
| + "\\n..._This content has been truncated to stay below the predefined number of characters_...\\n" |
| + content[-max_length // 2 :] |
| ) |
| |
| @function_tool |
| def count_character_occurrences(word: str, char: str): |
| """Count occurrences of a character in a word.""" |
| return word.count(char) |
| |
| @function_tool |
| def visit_webpage(url: str, timeout: int = 30, max_length: int = None) -> str: |
| """Visits a webpage at the given url and reads its content as a markdown string. Use this to browse webpages. |
| |
| Args: |
| url: The url of the webpage to visit. |
| timeout: The timeout in seconds for the request. |
| max_length: The maximum number of characters of text that can be returned. |
| If not provided, the full webpage is returned. |
| |
| """ |
| try: |
| response = requests.get(url, timeout=timeout) |
| response.raise_for_status() |
| |
| markdown_content = markdownify(response.text).strip() |
| |
| markdown_content = re.sub(r"\\n{2,}", "\\n", markdown_content) |
| |
| if max_length: |
| return _truncate_content(markdown_content, max_length) |
| |
| return str(markdown_content) |
| |
| except RequestException as e: |
| return f"Error fetching the webpage: {e!s}" |
| except Exception as e: |
| return f"An unexpected error occurred: {e!s}" |
| |
| |
| @function_tool |
| def search_tavily(query: str, include_images: bool = False) -> str: |
| """Perform a Tavily web search based on your query and return the top search results. |
| |
| See https://blog.tavily.com/getting-started-with-the-tavily-search-api for more information. |
| |
| Args: |
| query (str): The search query to perform. |
| include_images (bool): Whether to include images in the results. |
| |
| Returns: |
| The top search results as a formatted string. |
| |
| """ |
| api_key='${tavilyApiKey ? tavilyApiKey.replace(/'/g, "\\'") : ""}' |
| |
| if not api_key: |
| return "TAVILY_API_KEY not configured in config.js." |
| try: |
| client = TavilyClient(api_key) |
| response = client.search(query, include_images=include_images) |
| results = response.get("results", []) |
| output = [] |
| for result in results: |
| output.append( |
| f"[{result.get('title', 'No Title')}]({result.get('url', '#')})\\n{result.get('content', '')}" |
| ) |
| if include_images and "images" in response: |
| output.append("\\nImages:") |
| for image in response["images"]: |
| output.append(image) |
| return "\\n\\n".join(output) if output else "No results found." |
| except Exception as e: |
| return f"Error performing Tavily search: {e!s}" |
| |
| |
| async def test_agent(): |
| print("=== STARTING AGENT TEST ===") |
| try: |
| # Create agent |
| print("Creating agent...") |
| external_client = AsyncOpenAI( |
| base_url = '${baseUrl.replace(/'/g, "\\'")}', |
| api_key='${apiKey.replace(/'/g, "\\'")}', # required, but may be unused depending on server |
| ) |
| set_default_openai_client(external_client) |
| |
| # Build tools list conditionally |
| tools = [count_character_occurrences, visit_webpage] |
| ${tavilyApiKey ? 'tools.append(search_tavily)' : '# search_tavily tool not added (no API key)'} |
| |
| agent = Agent( |
| name="Tool caller", |
| instructions="You are a helpful agent. Use the available tools to answer the questions.", |
| tools=tools, |
| model=OpenAIChatCompletionsModel( |
| model="${modelName.replace(/'/g, "\\'")}", |
| openai_client=external_client, |
| ), |
| model_settings=ModelSettings( |
| extra_args={"timeout": 90} |
| ) |
| ) |
| print(f"Agent created: {agent}") |
| print(f"Using server: ${baseUrl} with model: ${modelName}") |
| |
| # Run the agent |
| print("Running agent...") |
| result = await Runner.run(agent, """${prompt.replace(/"/g, '\\"')}""", max_turns=20) |
| print(f"Agent run completed!") |
| print(f"Result type: {type(result)}") |
| print(f"Result: {result}") |
| |
| # Try to access final_output |
| if hasattr(result, 'final_output'): |
| print(f"Final output: {result.final_output}") |
| print(f"Final output type: {type(result.final_output)}") |
| return str(result.final_output) |
| else: |
| print("No final_output attribute found") |
| print(f"Available attributes: {dir(result)}") |
| return "" |
| print("=== AGENT TEST COMPLETED ===") |
| |
| except Exception as e: |
| print(f"=== AGENT TEST FAILED ===") |
| print(f"Error: {e}") |
| import traceback |
| print("Traceback:") |
| print(traceback.format_exc()) |
| |
| # Run the test |
| final_result = await test_agent() |
| final_result |
| `); |
| |
| // Display the result |
| if (typeof result === 'string' && result.length > 0 && !result.startsWith('Error:')) { |
| const formattedResult = result.replace(/\\n/g, '\n'); |
| logAgent("π Agent code ran successfully! Check console for Python output", 'success'); |
| logAgent("", 'info'); |
| logAgent("π AGENT RESPONSE:", 'info'); |
| logAgent("β".repeat(50), 'info'); |
| logAgent("\n"+formattedResult, 'info'); |
| logAgent("β".repeat(50), 'info'); |
| } else { |
| logAgent("β Agent execution failed or returned empty result", 'error'); |
| logAgent(`Result: ${result}`, 'error'); |
| } |
| |
| } catch (error) { |
| logAgent(`Agent execution failed: ${error}`, 'error'); |
| console.error('Full error:', error); |
| } finally { |
| document.getElementById('runBtn').disabled = false; |
| hideRunning(); |
| } |
| } |
| </script> |
| </body> |
| </html> |
|
|