跳转至

local_llm_serving

第2章 · 上下文工程 · 配套项目 chapter2/local_llm_serving

项目说明

Universal Tool Calling Demo

A cross-platform demonstration of LLM tool calling using standard OpenAI-compatible APIs. Works seamlessly on Windows, macOS, and Linux by automatically selecting the best backend for your system.

🌟 Features

  • Universal Compatibility: Single entry point (main.py) that works on all platforms
  • Automatic Backend Selection:
  • Uses vLLM on Linux/Windows with NVIDIA GPU
  • Uses Ollama on macOS, Windows, or Linux without GPU
  • Standard Tool Calling: Only uses OpenAI-compatible tool calling format
  • Built-in Tools: Weather, calculator, time, and easy to add custom tools
  • Interactive & Example Modes: Test with examples or chat interactively
  • 🆕 Streaming Support: Real-time display of thinking process, tool calls, and responses

🚀 Quick Start

# 1. Clone the repository
git clone <repository>
cd chapter2/local_llm_serving

# 2. Install dependencies
pip install -r requirements.txt

# 3. Check your system compatibility
python check_compatibility.py

# 4. Run the main script (auto-detects best backend)
python main.py

That's it! The script automatically detects your platform and uses the appropriate backend.

📋 Prerequisites

All Platforms

  • Python 3.10+
  • pip install -r requirements.txt

Platform-Specific Setup

🍎 macOS

# Install Ollama
brew install ollama

# Start Ollama service (in separate terminal)
ollama serve

# Download a model
ollama pull qwen3:0.6b

🪟 Windows

With NVIDIA GPU: - CUDA toolkit installed - NVIDIA drivers 452.39+ - vLLM will be used automatically

Without GPU:

# Download and install Ollama
# From: https://ollama.com/download/windows

# Pull a model
ollama pull qwen3:0.6b

🐧 Linux

With NVIDIA GPU: - CUDA toolkit installed - vLLM will be used automatically

Without GPU:

# Install Ollama
curl -fsSL https://ollama.com/install.sh | sh

# Start service
systemctl start ollama

# Pull a model
ollama pull qwen3:0.6b

🎮 Usage

Basic Usage

# Run with auto-detection (recommended)
python main.py

# Run examples only
python main.py --mode examples

# Run interactive mode only
python main.py --mode interactive

# Force specific backend
python main.py --backend ollama  # Force Ollama
python main.py --backend vllm    # Force vLLM (requires GPU)

# Show system info
python main.py --info

Using in Your Code

from main import ToolCallingAgent

# Initialize (auto-detects best backend)
agent = ToolCallingAgent()

# Send a message
response = agent.chat("What's the weather in Tokyo?")
print(response)

# Disable tools for a query
response = agent.chat("Tell me a joke", use_tools=False)

# Reset conversation
agent.reset_conversation()

Adding Custom Tools

from tools import ToolRegistry

# Get the tool registry
registry = ToolRegistry()

# Define your tool function
def my_custom_tool(param1: str, param2: int) -> str:
    return f"Processed {param1} with {param2}"

# Register it
registry.register_tool(
    name="my_custom_tool",
    function=my_custom_tool,
    description="My custom tool description",
    parameters={
        "type": "object",
        "properties": {
            "param1": {"type": "string", "description": "First parameter"},
            "param2": {"type": "integer", "description": "Second parameter"}
        },
        "required": ["param1", "param2"]
    }
)

📁 Project Structure

local_llm_serving/
├── main.py              # Main entry point (auto-detects backend)
├── benchmark.py         # Serving benchmark: throughput / TTFT / KV cache / batching
├── agent.py             # vLLM agent implementation
├── ollama_native.py     # Ollama native tool calling
├── tools.py             # Tool implementations
├── config.py            # Configuration settings
├── server.py            # vLLM server manager
├── check_compatibility.py # System compatibility checker
├── requirements.txt     # Python dependencies
├── env.example         # Environment variables template
└── README.md           # This file

🛠️ Available Tools

  1. get_current_temperature: Get real-time weather information using Open-Meteo API (no API key required)
  2. get_current_time: Get current time in different timezones
  3. convert_currency: Convert between different currencies (simulated rates)
  4. parse_pdf: Parse PDF documents from URL or local file
  5. code_interpreter: Execute Python code for complex calculations and data processing

🎬 Streaming Mode

The agents now support streaming responses, which displays: - 🧠 Internal thinking process (shown in gray) - 🔧 Tool calls as they happen - ✓ Tool results in real-time - 📝 Final response streamed character by character

Using Streaming

Interactive Mode (Default)

# Streaming is enabled by default
python main.py

# Disable streaming
python main.py --no-stream

# Toggle streaming during chat with /stream command

Programmatic Usage

from main import ToolCallingAgent

# Initialize agent
agent = ToolCallingAgent()

# Stream response
for chunk in agent.chat("What's the weather in Tokyo?", stream=True):
    chunk_type = chunk.get("type")
    content = chunk.get("content", "")

    if chunk_type == "thinking":
        print(f"Thinking: {content}")
    elif chunk_type == "tool_call":
        print(f"Tool: {content['name']}")
    elif chunk_type == "tool_result":
        print(f"Result: {content}")
    elif chunk_type == "content":
        print(content, end="", flush=True)

Test Streaming

# Run streaming demo
python demo_streaming.py

# Compare streaming vs regular mode
python test_streaming.py --mode compare

📈 Serving Benchmark (benchmark.py)

benchmark.py 是实验 2-1 的配套基准,用于测量本地部署的小模型在 服务(serving) 层面的核心指标,帮助建立对吞吐 / 延迟 / 批处理 / KV Cache 的直觉。它通过 OpenAI 兼容接口工作,vLLM 与 Ollama 均可。

所有数字都来自真实服务端的实测,脚本本身不产生任何合成数据。 如果服务端尚未启动,可用 --dry-run 离线查看每个场景将要发出的请求配置。

场景(--scenario

场景 说明 对应书中要点
throughput 单流解码吞吐(tok/s)与首 token 延迟(TTFT) 实验 2-1 第 2 点:M2 上 >100 tok/s
kv-cache 前缀缓存 命中 vs 未命中 的 TTFT 对比 实验 2-1 第 5 点:改动系统提示词开头 → 缓存失效、需重算整个前缀
batching 不同并发度下的聚合吞吐(批处理权衡) 连续批处理如何提升系统吞吐
all 依次运行以上全部场景(默认)

用法

# 1. 先启动服务端(二选一)
python server.py                            # vLLM(需要 NVIDIA GPU)
ollama serve && ollama pull qwen3:0.6b      # Ollama(Mac / 无 GPU)

# 2. 运行基准
python benchmark.py --scenario all --output results.json   # 跑全部并保存
python benchmark.py --scenario kv-cache --backend ollama    # 只看 KV Cache TTFT 对比
python benchmark.py --scenario batching --concurrency 1,2,4,8   # 批处理吞吐扫描

# 离线查看计划(不访问服务端),可用于验证参数
python benchmark.py --dry-run
python benchmark.py --help

主要参数

  • --backend {vllm,ollama}:推断默认地址与模型名(vLLM Qwen3-0.6B @ :8000/v1,Ollama qwen3:0.6b @ :11434/v1
  • --base-url / --model / --api-key:覆盖默认连接配置
  • --repeatsthroughput / kv-cache 的重复次数(默认 5)
  • --max-tokens / --temperature:生成参数
  • --prefix-tokenskv-cache 场景共享前缀的近似长度,越长缓存效果越明显(默认 1024)
  • --concurrencybatching 场景的并发度列表,逗号分隔(默认 1,2,4,8
  • --output:将结果写入 JSON 文件

说明:kv-cache 依赖服务端的前缀缓存(vLLM 的 automatic prefix caching 默认开启)。命中组保持系统提示词逐字节不变;未命中组每次只在系统提示词开头插入一个唯一计数串,前缀被改写导致缓存全部失效——这正是书中「系统提示词一旦定下来就不要改」的实测演示。

🔧 Configuration

Copy env.example to .env and customize:

# For vLLM (if you have GPU)
MODEL_NAME=Qwen/Qwen3-0.6B
VLLM_HOST=localhost
VLLM_PORT=8000

# Logging
LOG_LEVEL=INFO

📊 Tool Calling Format

This project uses standard OpenAI-compatible tool calling:

{
  "tool_calls": [{
    "id": "call_123",
    "type": "function",
    "function": {
      "name": "get_weather",
      "arguments": {"location": "Tokyo"}
    }
  }]
}

No ad-hoc parsing or custom formats - just the standard that works across platforms.

🐛 Troubleshooting

"Ollama not found"

  • Mac: brew install ollama && ollama serve
  • Windows: Download from ollama.com
  • Linux: curl -fsSL https://ollama.com/install.sh | sh

"No models installed"

ollama pull qwen3:0.6b  # Default model used by this project

"CUDA not available" (Linux/Windows)

  • Install NVIDIA drivers and CUDA toolkit
  • Or the script will automatically use Ollama instead

Check System Compatibility

python check_compatibility.py

🤝 Supported Models

Default Model:

  • Qwen3 (0.6B) - Default model used by this project. Small size with decent tool calling support.

Other Compatible Models for Tool Calling:

  • Qwen3 (8B+) - Good tool support
  • Llama 3.1/3.2 (8B+) - Good tool support
  • Mistral Nemo - Great tool calling

For vLLM:

  • Uses Qwen3-0.6B by default
  • Any model supported by vLLM can be configured

📚 How It Works

  1. Platform Detection: main.py detects your OS and GPU availability
  2. Backend Selection:
  3. Has NVIDIA GPU? → Uses vLLM for best performance
  4. No GPU or on Mac? → Uses Ollama for local inference
  5. Tool Execution: Both backends use standard OpenAI tool calling format
  6. Response Generation: Tools are executed and results fed back to the model

🔗 References

📄 License

This demo project is provided as-is for educational purposes.

源代码

agent.py

"""
vLLM Tool Calling Agent Implementation
Demonstrates how to use vLLM with Qwen3 for tool calling
"""

import json
import uuid
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import List, Dict, Any, Optional, Tuple
from openai import OpenAI
from tools import ToolRegistry
from config import OPENAI_API_BASE, OPENAI_API_KEY, LOG_LEVEL

# Set up logging
logging.basicConfig(level=LOG_LEVEL)
logger = logging.getLogger(__name__)


class VLLMToolAgent:
    """Agent that uses vLLM for tool calling with Qwen3 model"""

    def __init__(self, api_base: str = OPENAI_API_BASE, api_key: str = OPENAI_API_KEY):
        """
        Initialize the agent with vLLM server connection

        Args:
            api_base: Base URL for vLLM server
            api_key: API key (not required for vLLM, use "EMPTY")
        """
        self.client = OpenAI(
            api_key=api_key,
            base_url=api_base
        )
        self.tool_registry = ToolRegistry()
        self.conversation_history = []
        logger.info(f"Initialized VLLMToolAgent with server at {api_base}")

    def _format_system_prompt_with_tools(self) -> str:
        """
        Format the system prompt with available tools in Qwen3 format
        """
        tools_json = json.dumps(self.tool_registry.get_tool_schemas(), indent=2)

        system_prompt = f"""# Tools

You may call one or more functions to assist with the user query.

You are provided with function signatures within <tools></tools> XML tags:
<tools>
{tools_json}
</tools>

For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{{"name": <function-name>, "arguments": <args-json-object>}}
</tool_call>

You are a helpful assistant that can use tools to answer questions and perform tasks.
When you need to use a tool, generate the appropriate tool call.
After receiving tool results, use them to provide a comprehensive answer to the user."""

        return system_prompt

    def _parse_tool_calls(self, content: str) -> List[Dict[str, Any]]:
        """
        Parse tool calls from model output
        Extracts content between <tool_call> tags
        """
        tool_calls = []

        # Find all tool call blocks
        import re
        pattern = r'<tool_call>(.*?)</tool_call>'
        matches = re.findall(pattern, content, re.DOTALL)

        for match in matches:
            try:
                tool_call = json.loads(match.strip())
                if "name" in tool_call and "arguments" in tool_call:
                    tool_calls.append({
                        "id": str(uuid.uuid4())[:8],  # Generate short ID
                        "type": "function",
                        "function": {
                            "name": tool_call["name"],
                            "arguments": tool_call["arguments"]
                        }
                    })
                    logger.debug(f"Parsed tool call: {tool_call['name']}")
            except json.JSONDecodeError as e:
                logger.warning(f"Failed to parse tool call JSON: {e}")
                logger.debug(f"Content was: {match}")

        return tool_calls

    def _execute_tool_calls(self, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        """
        Execute tool calls and return results.
        Multiple tool calls in the same turn are executed in parallel (they are
        independent by construction, since the model generated all of them
        without seeing any result). Ensures that error messages from failed
        tool executions are properly formatted.
        """
        def run_one(tool_call: Dict[str, Any]) -> Dict[str, Any]:
            tool_name = tool_call["function"]["name"]
            tool_args = tool_call["function"]["arguments"]
            tool_id = tool_call["id"]

            logger.info(f"Executing tool: {tool_name} with args: {tool_args}")

            # Execute the tool
            result = self.tool_registry.execute_tool(tool_name, tool_args)

            # Check if the result indicates an error
            try:
                result_dict = json.loads(result) if isinstance(result, str) else result
                if isinstance(result_dict, dict) and not result_dict.get("success", True):
                    # Tool execution failed - format error message clearly
                    error_msg = f"❌ Tool '{tool_name}' execution failed:\n"
                    if "error" in result_dict:
                        error_msg += f"Error: {result_dict['error']}\n"
                    if "error_type" in result_dict:
                        error_msg += f"Type: {result_dict['error_type']}\n"
                    if "traceback" in result_dict:
                        error_msg += f"Traceback:\n{result_dict['traceback']}\n"

                    logger.error(f"Tool {tool_name} failed: {result_dict.get('error', 'Unknown error')}")
                    result = error_msg
                else:
                    logger.debug(f"Tool {tool_name} returned: {result}")
            except (json.JSONDecodeError, TypeError):
                # Result is not JSON, just pass it through
                logger.debug(f"Tool {tool_name} returned: {result}")

            # Format the result
            return {
                "role": "tool",
                "tool_call_id": tool_id,
                "name": tool_name,
                "content": result if isinstance(result, str) else str(result)
            }

        if len(tool_calls) <= 1:
            return [run_one(tc) for tc in tool_calls]

        # Independent tool calls run concurrently; executor.map preserves order
        with ThreadPoolExecutor(max_workers=len(tool_calls)) as executor:
            return list(executor.map(run_one, tool_calls))

    def _execute_single_tool(self, tool_data: Dict[str, Any]) -> Tuple[str, bool]:
        """
        Execute one parsed tool call ({"name": ..., "arguments": ...}).
        Returns (result_text, is_error) with error messages formatted clearly.
        """
        tool_name = tool_data["name"]
        try:
            result = self.tool_registry.execute_tool(tool_name, tool_data["arguments"])
        except Exception as e:
            logger.error(f"Tool execution error: {e}")
            return f"❌ Tool execution exception: {str(e)}", True

        # Check if the result indicates an error
        try:
            result_dict = json.loads(result) if isinstance(result, str) else result
            if isinstance(result_dict, dict) and not result_dict.get("success", True):
                # Tool execution failed - format error message clearly
                error_msg = f"❌ Tool '{tool_name}' execution failed:\n"
                if "error" in result_dict:
                    error_msg += f"Error: {result_dict['error']}\n"
                if "error_type" in result_dict:
                    error_msg += f"Type: {result_dict['error_type']}\n"
                if "traceback" in result_dict:
                    error_msg += f"Traceback:\n{result_dict['traceback']}\n"

                logger.error(f"Tool {tool_name} failed: {result_dict.get('error', 'Unknown error')}")
                return error_msg, True
        except (json.JSONDecodeError, TypeError):
            # Result is not JSON, just pass it through
            pass
        return result, False

    def chat(self, message: str, use_tools: bool = True,  
             temperature: float = 0.3, max_tokens: int = 2048, 
             stream: bool = False) -> str:
        """
        Send a message to the model and handle tool calls in a ReAct loop

        Args:
            message: User message
            use_tools: Whether to enable tool calling
            temperature: Sampling temperature
            max_tokens: Maximum tokens to generate
            stream: Whether to stream the response

        Returns:
            Final response from the model (or generator if streaming)
        """
        if stream:
            return self.chat_stream(message, use_tools, temperature, max_tokens)

        # Add user message to history
        self.conversation_history.append({"role": "user", "content": message})

        # Prepare messages with system prompt if using tools
        messages = []
        if use_tools:
            messages.append({
                "role": "system",
                "content": self._format_system_prompt_with_tools()
            })
        else:
            messages.append({
                "role": "system",
                "content": "You are a helpful assistant."
            })

        # Add conversation history
        messages.extend(self.conversation_history)

        # Prepare tools for the API call
        tools = self.tool_registry.get_tool_schemas() if use_tools else None

        # ReAct loop - keep going until no more tool calls are needed
        max_iterations = 10  # Prevent infinite loops
        iteration = 0
        final_response = ""

        while iteration < max_iterations:
            iteration += 1
            logger.info(f"ReAct iteration {iteration}")

            # Prepare messages for this iteration
            messages = []
            if use_tools:
                messages.append({
                    "role": "system",
                    "content": self._format_system_prompt_with_tools()
                })
            else:
                messages.append({
                    "role": "system",
                    "content": "You are a helpful assistant."
                })
            messages.extend(self.conversation_history)

            # Call the model
            response = self.client.chat.completions.create(
                model="Qwen3-0.6B",
                messages=messages,
                tools=tools,
                tool_choice="auto" if use_tools else None,
                temperature=temperature,
                max_tokens=max_tokens
            )

            assistant_message = response.choices[0].message
            content = assistant_message.content or ""

            # Check for tool calls in the response
            tool_calls = []
            if use_tools and content:
                tool_calls = self._parse_tool_calls(content)

            if tool_calls:
                logger.info(f"Model requested {len(tool_calls)} tool call(s)")

                # Add assistant message with tool calls to history
                # (arguments must be a JSON string per the OpenAI API spec)
                self.conversation_history.append({
                    "role": "assistant",
                    "content": content,
                    "tool_calls": [
                        {
                            **tc,
                            "function": {
                                **tc["function"],
                                "arguments": tc["function"]["arguments"]
                                if isinstance(tc["function"]["arguments"], str)
                                else json.dumps(tc["function"]["arguments"])
                            }
                        }
                        for tc in tool_calls
                    ]
                })

                # Execute tool calls
                tool_results = self._execute_tool_calls(tool_calls)

                # Add tool results to conversation
                for result in tool_results:
                    # Format tool response for Qwen3
                    tool_response = f'<tool_response>\n{result["content"]}\n</tool_response>'
                    self.conversation_history.append({
                        "role": "user",  # Tool responses are treated as user messages in Qwen3
                        "content": tool_response,
                        "name": result.get("name", "tool")
                    })

                # Continue the ReAct loop
                continue
            else:
                # No tool calls - we have a final response
                self.conversation_history.append({
                    "role": "assistant",
                    "content": content
                })
                final_response = content
                break

        # Check if we hit max iterations
        if iteration >= max_iterations:
            logger.warning("Maximum iterations reached in ReAct loop")
            final_response = "I've reached the maximum number of reasoning steps. " + final_response

        return final_response

    def reset_conversation(self):
        """Reset the conversation history"""
        self.conversation_history = []
        logger.info("Conversation history reset")

    def chat_stream(self, message: str, use_tools: bool = True,
                    temperature: float = 0.3, max_tokens: int = 2048):
        """
        Stream a message to the model and handle tool calls in a ReAct loop

        Yields chunks that include:
        - type: 'thinking', 'tool_call', 'tool_result', 'content'
        - content: The actual content
        """
        # Add user message to history
        self.conversation_history.append({"role": "user", "content": message})

        # Prepare tools for the API call
        tools = self.tool_registry.get_tool_schemas() if use_tools else None

        # ReAct loop - keep going until no more tool calls are needed
        max_iterations = 10  # Prevent infinite loops
        iteration = 0

        while iteration < max_iterations:
            iteration += 1
            logger.info(f"ReAct stream iteration {iteration}")

            # Prepare messages for this iteration
            messages = []
            if use_tools:
                messages.append({
                    "role": "system",
                    "content": self._format_system_prompt_with_tools()
                })
            else:
                messages.append({
                    "role": "system",
                    "content": "You are a helpful assistant."
                })
            messages.extend(self.conversation_history)

            # Stream response from model
            stream_response = self.client.chat.completions.create(
                model="Qwen3-0.6B",
                messages=messages,
                tools=tools,
                tool_choice="auto" if use_tools else None,
                temperature=temperature,
                max_tokens=max_tokens,
                stream=True
            )

            collected_content = []
            tool_calls_buffer = ""
            tool_calls_detected = False
            pending_tool_calls = []

            # Process the stream
            for chunk in stream_response:
                if chunk.choices and chunk.choices[0].delta:
                    delta = chunk.choices[0].delta
                    if delta.content:
                        content_chunk = delta.content
                        collected_content.append(content_chunk)
                        buffered_this_chunk = False

                        # Check if this is internal thinking (between <think> tags)
                        if '<think>' in content_chunk or tool_calls_buffer:
                            tool_calls_buffer += content_chunk
                            buffered_this_chunk = True
                            if '</think>' in tool_calls_buffer:
                                # Extract and yield thinking
                                import re
                                thinking_match = re.search(r'<think>(.*?)</think>', tool_calls_buffer, re.DOTALL)
                                if thinking_match:
                                    # Stream thinking character by character
                                    for char in thinking_match.group(1).strip():
                                        yield {"type": "thinking", "content": char}
                                tool_calls_buffer = re.sub(r'<think>.*?</think>', '', tool_calls_buffer, flags=re.DOTALL)

                        # Check for tool calls
                        if '<tool_call>' in content_chunk or tool_calls_buffer:
                            if not buffered_this_chunk:
                                tool_calls_buffer += content_chunk
                            # Collect all complete tool calls; they are executed
                            # in parallel once the stream finishes
                            while '</tool_call>' in tool_calls_buffer:
                                tool_calls_detected = True
                                import re
                                tool_match = re.search(r'<tool_call>(.*?)</tool_call>', tool_calls_buffer, re.DOTALL)
                                if not tool_match:
                                    break
                                try:
                                    tool_data = json.loads(tool_match.group(1).strip())
                                    pending_tool_calls.append(tool_data)
                                    yield {"type": "tool_call", "content": tool_data}
                                except Exception as e:
                                    error_msg = f"❌ Tool call parse exception: {str(e)}"
                                    logger.error(f"Tool call parse error: {e}")
                                    yield {"type": "tool_error", "content": error_msg}
                                    # Add error to history so agent can see it
                                    self.conversation_history.append({
                                        "role": "user",
                                        "content": f'<tool_response>\n{error_msg}\n</tool_response>',
                                        "name": "unknown"
                                    })
                                # Keep any trailing partial tool call for the next chunk
                                tool_calls_buffer = tool_calls_buffer[tool_match.end():]
                        else:
                            # Regular content
                            yield {"type": "content", "content": content_chunk}

            # Execute all tool calls from this turn in parallel (they are
            # independent by construction, since the model generated all of
            # them without seeing any result)
            if pending_tool_calls:
                if len(pending_tool_calls) == 1:
                    outcomes = [self._execute_single_tool(pending_tool_calls[0])]
                else:
                    with ThreadPoolExecutor(max_workers=len(pending_tool_calls)) as executor:
                        outcomes = list(executor.map(self._execute_single_tool, pending_tool_calls))

                for tool_data, (result, is_error) in zip(pending_tool_calls, outcomes):
                    if is_error:
                        yield {"type": "tool_error", "content": result}
                    else:
                        yield {"type": "tool_result", "content": result}

                    # Add to history
                    self.conversation_history.append({
                        "role": "user",
                        "content": f'<tool_response>\n{result}\n</tool_response>',
                        "name": tool_data["name"]
                    })

            # Save complete response to history
            complete_response = ''.join(collected_content)

            if tool_calls_detected:
                # Add assistant's message to history
                self.conversation_history.append({
                    "role": "assistant",
                    "content": complete_response
                })
                # Continue the ReAct loop - let the model decide what to do next
                continue
            else:
                # No tool calls - we have a final response
                self.conversation_history.append({
                    "role": "assistant",
                    "content": complete_response
                })
                # Exit the ReAct loop
                break

        # Check if we hit max iterations
        if iteration >= max_iterations:
            yield {"type": "error", "content": "Maximum iterations reached in ReAct loop"}

    def get_conversation_history(self) -> List[Dict[str, Any]]:
        """Get the current conversation history"""
        return self.conversation_history

    def add_custom_tool(self, name: str, function: callable, 
                       description: str, parameters: Dict):
        """
        Add a custom tool to the registry

        Args:
            name: Tool name
            function: Callable function
            description: Tool description
            parameters: OpenAI-style parameter schema
        """
        self.tool_registry.register_tool(name, function, description, parameters)
        logger.info(f"Added custom tool: {name}")


def demonstrate_tool_calling():
    """Demonstrate the tool calling functionality"""
    print("=" * 60)
    print("vLLM Tool Calling Demo with Qwen3")
    print("=" * 60)

    # Initialize agent
    agent = VLLMToolAgent()

    # Test cases
    test_queries = [
        "What's the current temperature in Paris, France?",
        "Calculate 15 * 23 + sqrt(144)",
        "What time is it in Tokyo (JST)?",
        "Search for information about vLLM tool calling",
        "What's the weather in Dubai and what's 100 fahrenheit in celsius?",
    ]

    for i, query in enumerate(test_queries, 1):
        print(f"\n--- Test {i} ---")
        print(f"User: {query}")

        response = agent.chat(query)
        print(f"Assistant: {response}")

        # Reset conversation for next test
        agent.reset_conversation()
        print("-" * 40)


if __name__ == "__main__":
    # Run demonstration
    demonstrate_tool_calling()

benchmark.py

#!/usr/bin/env python3
"""
本地 LLM 服务性能基准(实验 2-1 配套)

本脚本通过 OpenAI 兼容接口(vLLM 或 Ollama 均可)测量本地部署的小模型在
「服务(serving)」层面的三个核心指标,帮助读者建立对吞吐 / 延迟 / 批处理 /
KV Cache 的直觉:

  1. throughput —— 单流解码吞吐(tokens/s)与首 token 延迟(TTFT)
  2. kv-cache  —— 前缀缓存命中 vs 未命中的 TTFT 对比
                (对应实验 2-1 第 5 点:系统提示词不变时缓存命中更快,
                 修改系统提示词开头几个字符导致缓存失效、需重算整个前缀)
  3. batching  —— 不同并发度下的聚合吞吐,直观展示批处理带来的吞吐提升

所有数字均来自真实服务端的实测,脚本本身不产生任何合成数据。
如果尚未启动服务端,可用 --dry-run 离线查看每个场景将要发出的请求配置。

示例:
    # 先启动服务端(二选一)
    python server.py                    # vLLM(需要 NVIDIA GPU)
    ollama serve && ollama pull qwen3:0.6b   # Ollama(Mac / 无 GPU)

    # 跑全部场景并保存结果
    python benchmark.py --scenario all --output results.json

    # 只看 KV Cache 命中 / 未命中的 TTFT 对比
    python benchmark.py --scenario kv-cache --backend ollama

    # 批处理吞吐扫描
    python benchmark.py --scenario batching --concurrency 1,2,4,8
"""

import argparse
import json
import logging
import statistics
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger("benchmark")

# 各后端的默认 OpenAI 兼容地址
BACKEND_DEFAULTS = {
    "vllm": {"base_url": "http://localhost:8000/v1", "model": "Qwen3-0.6B"},
    "ollama": {"base_url": "http://localhost:11434/v1", "model": "qwen3:0.6b"},
}

# 一段确定性的填充文本,用于把共享前缀撑长,让 KV Cache 的效果更明显
_FILLER_SENTENCE = (
    "You are a meticulous assistant that follows the operating manual precisely. "
)


def build_padded_system_prompt(target_tokens: int) -> str:
    """构造一个约含 target_tokens 个 token 的系统提示词(用重复句子填充)。

    这里用「4 字符 ≈ 1 token」的粗略估计来控制长度,只需保证前缀足够长、
    可复现即可,不追求精确的 token 数。
    """
    header = (
        "# Operating Manual\n"
        "You are a helpful local assistant deployed for the AI Agent book experiment.\n\n"
    )
    approx_chars = max(0, target_tokens * 4 - len(header))
    repeats = approx_chars // len(_FILLER_SENTENCE) + 1
    body = _FILLER_SENTENCE * repeats
    return header + body


def make_client(base_url: str, api_key: str):
    """创建 OpenAI 兼容客户端。"""
    try:
        from openai import OpenAI
    except ImportError:
        logger.error("缺少依赖 openai,请先执行:pip install openai")
        sys.exit(1)
    return OpenAI(base_url=base_url, api_key=api_key)


def stream_once(
    client,
    model: str,
    messages: List[Dict[str, str]],
    max_tokens: int,
    temperature: float,
) -> Dict[str, float]:
    """发起一次流式请求,返回 TTFT、总时长、输出 token 数与解码吞吐。

    - ttft:从发起请求到收到第一个内容分片的时间(秒)
    - total:整个响应的墙钟时间(秒)
    - output_tokens:优先取服务端返回的 usage.completion_tokens,
      否则用收到的内容分片数量作为近似
    - decode_tps:解码阶段吞吐 = 输出 token / (总时长 - TTFT)
    """
    start = time.perf_counter()
    ttft: Optional[float] = None
    chunk_count = 0
    usage_tokens: Optional[int] = None

    stream = client.chat.completions.create(
        model=model,
        messages=messages,
        max_tokens=max_tokens,
        temperature=temperature,
        stream=True,
        stream_options={"include_usage": True},
    )

    for chunk in stream:
        # 最后一个分片可能只携带 usage 而没有 choices
        if getattr(chunk, "usage", None) is not None:
            try:
                usage_tokens = chunk.usage.completion_tokens
            except AttributeError:
                pass
        if not chunk.choices:
            continue
        delta = chunk.choices[0].delta
        if getattr(delta, "content", None):
            if ttft is None:
                ttft = time.perf_counter() - start
            chunk_count += 1

    total = time.perf_counter() - start
    if ttft is None:
        ttft = total
    output_tokens = usage_tokens if usage_tokens is not None else chunk_count
    decode_time = max(total - ttft, 1e-6)
    decode_tps = output_tokens / decode_time if output_tokens else 0.0

    return {
        "ttft": ttft,
        "total": total,
        "output_tokens": float(output_tokens),
        "decode_tps": decode_tps,
    }


# --------------------------------------------------------------------------- #
# 场景实现
# --------------------------------------------------------------------------- #
def scenario_throughput(client, model, args) -> Dict[str, Any]:
    """单流吞吐 + TTFT:连续发起若干次解码密集的请求并汇总统计。"""
    messages = [
        {"role": "system", "content": "You are a helpful assistant."},
        {
            "role": "user",
            "content": "Write a detailed explanation of how KV Cache works in transformer inference.",
        },
    ]
    runs = []
    for i in range(args.repeats):
        r = stream_once(client, model, messages, args.max_tokens, args.temperature)
        runs.append(r)
        logger.info(
            "throughput 第 %d/%d 次: TTFT=%.3fs, 解码=%.1f tok/s, 输出=%d tok",
            i + 1, args.repeats, r["ttft"], r["decode_tps"], int(r["output_tokens"]),
        )
    return {
        "scenario": "throughput",
        "repeats": args.repeats,
        "ttft_mean_s": statistics.fmean(x["ttft"] for x in runs),
        "decode_tps_mean": statistics.fmean(x["decode_tps"] for x in runs),
        "output_tokens_mean": statistics.fmean(x["output_tokens"] for x in runs),
        "runs": runs,
    }


def scenario_kv_cache(client, model, args) -> Dict[str, Any]:
    """KV Cache 命中 vs 未命中的 TTFT 对比(实验 2-1 第 5 点)。

    - 命中组:系统提示词逐字节不变,重复发送同一请求,服务端前缀缓存命中,
      prefill 几乎可以跳过 → TTFT 明显更低。
    - 未命中组:每次只在系统提示词「开头」插入一个不同的计数串,前缀被改写,
      缓存全部失效,服务端必须重算整个前缀 → TTFT 明显更高。
    两组的提示词长度基本一致,因此差异主要来自前缀缓存是否命中。
    """
    base_prompt = build_padded_system_prompt(args.prefix_tokens)
    user_msg = {"role": "user", "content": "In one short sentence, say hello."}

    # 预热:先发一次把缓存写入(这一次一定是冷启动,不计入统计)
    warm_msgs = [{"role": "system", "content": base_prompt}, user_msg]
    stream_once(client, model, warm_msgs, args.max_tokens, args.temperature)

    hit_ttfts, miss_ttfts = [], []
    for i in range(args.repeats):
        # 命中:完全相同的前缀
        hit = stream_once(client, model, warm_msgs, args.max_tokens, args.temperature)
        hit_ttfts.append(hit["ttft"])

        # 未命中:在开头插入唯一前缀,使缓存失效
        mutated = f"[req-{i}-{time.time_ns()}] " + base_prompt
        miss_msgs = [{"role": "system", "content": mutated}, user_msg]
        miss = stream_once(client, model, miss_msgs, args.max_tokens, args.temperature)
        miss_ttfts.append(miss["ttft"])

        logger.info(
            "kv-cache 第 %d/%d 次: 命中 TTFT=%.3fs, 未命中 TTFT=%.3fs",
            i + 1, args.repeats, hit["ttft"], miss["ttft"],
        )

    hit_mean = statistics.fmean(hit_ttfts)
    miss_mean = statistics.fmean(miss_ttfts)
    return {
        "scenario": "kv-cache",
        "prefix_tokens_approx": args.prefix_tokens,
        "repeats": args.repeats,
        "ttft_hit_mean_s": hit_mean,
        "ttft_miss_mean_s": miss_mean,
        "speedup": (miss_mean / hit_mean) if hit_mean > 0 else None,
        "ttft_hit_s": hit_ttfts,
        "ttft_miss_s": miss_ttfts,
    }


def scenario_batching(client, model, args) -> Dict[str, Any]:
    """批处理:在不同并发度下并发发起请求,测量聚合吞吐。

    连续批处理(continuous batching)是本地 serving 的核心优化:并发越高,
    GPU 利用率越充分,系统聚合吞吐(所有请求合计 tok/s)通常显著上升,
    但单个请求的延迟可能上升。此场景把这个权衡直接量化出来。
    """
    messages = [
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Explain what a large language model is."},
    ]

    levels = args.concurrency
    rows = []
    for level in levels:
        start = time.perf_counter()
        with ThreadPoolExecutor(max_workers=level) as pool:
            futures = [
                pool.submit(
                    stream_once, client, model, messages, args.max_tokens, args.temperature
                )
                for _ in range(level)
            ]
            results = [f.result() for f in futures]
        wall = time.perf_counter() - start
        total_tokens = sum(r["output_tokens"] for r in results)
        agg_tps = total_tokens / wall if wall > 0 else 0.0
        per_req_tps = agg_tps / level if level else 0.0
        rows.append(
            {
                "concurrency": level,
                "wall_s": wall,
                "total_output_tokens": total_tokens,
                "aggregate_tps": agg_tps,
                "per_request_tps": per_req_tps,
                "ttft_mean_s": statistics.fmean(r["ttft"] for r in results),
            }
        )
        logger.info(
            "batching 并发=%d: 聚合吞吐=%.1f tok/s, 单请求=%.1f tok/s, 墙钟=%.2fs",
            level, agg_tps, per_req_tps, wall,
        )
    return {"scenario": "batching", "levels": rows}


# --------------------------------------------------------------------------- #
# 结果表格
# --------------------------------------------------------------------------- #
def print_report(results: List[Dict[str, Any]]) -> None:
    print("\n" + "=" * 68)
    print("本地 LLM 服务基准结果")
    print("=" * 68)
    for res in results:
        s = res["scenario"]
        if s == "throughput":
            print("\n[throughput] 单流吞吐 / 首 token 延迟")
            print(f"  次数           : {res['repeats']}")
            print(f"  平均 TTFT      : {res['ttft_mean_s']:.3f} s")
            print(f"  平均解码吞吐   : {res['decode_tps_mean']:.1f} tok/s")
            print(f"  平均输出长度   : {res['output_tokens_mean']:.0f} tok")
        elif s == "kv-cache":
            print("\n[kv-cache] 前缀缓存命中 vs 未命中(TTFT)")
            print(f"  前缀长度(约)   : {res['prefix_tokens_approx']} tok")
            print(f"  命中平均 TTFT  : {res['ttft_hit_mean_s']:.3f} s")
            print(f"  未命中平均TTFT : {res['ttft_miss_mean_s']:.3f} s")
            if res.get("speedup"):
                print(f"  缓存加速比     : {res['speedup']:.2f}x")
        elif s == "batching":
            print("\n[batching] 并发度对聚合吞吐的影响")
            print(f"  {'并发':>4} | {'聚合tok/s':>10} | {'单请求tok/s':>12} | {'平均TTFT(s)':>11} | {'墙钟(s)':>8}")
            print(f"  {'-'*4}-+-{'-'*10}-+-{'-'*12}-+-{'-'*11}-+-{'-'*8}")
            for row in res["levels"]:
                print(
                    f"  {row['concurrency']:>4} | {row['aggregate_tps']:>10.1f} | "
                    f"{row['per_request_tps']:>12.1f} | {row['ttft_mean_s']:>11.3f} | {row['wall_s']:>8.2f}"
                )
    print("\n" + "=" * 68)


def describe_dry_run(args) -> None:
    """离线打印将要执行的场景配置,不访问服务端。"""
    print("=" * 68)
    print("DRY RUN —— 仅打印计划,不访问服务端")
    print("=" * 68)
    print(f"后端         : {args.backend}")
    print(f"base_url     : {args.base_url}")
    print(f"模型         : {args.model}")
    print(f"重复次数     : {args.repeats}")
    print(f"max_tokens   : {args.max_tokens}")
    print(f"temperature  : {args.temperature}")
    scenarios = ["throughput", "kv-cache", "batching"] if args.scenario == "all" else [args.scenario]
    print(f"待运行场景   : {', '.join(scenarios)}")
    if "kv-cache" in scenarios:
        prompt = build_padded_system_prompt(args.prefix_tokens)
        print(f"  kv-cache   : 填充前缀约 {args.prefix_tokens} tok(实际 {len(prompt)} 字符)")
    if "batching" in scenarios:
        print(f"  batching   : 并发扫描 {args.concurrency}")
    print("=" * 68)


def parse_concurrency(value: str) -> List[int]:
    try:
        levels = [int(x) for x in value.split(",") if x.strip()]
    except ValueError:
        raise argparse.ArgumentTypeError("--concurrency 需为逗号分隔的正整数,例如 1,2,4,8")
    if not levels or any(x <= 0 for x in levels):
        raise argparse.ArgumentTypeError("--concurrency 中的并发度必须为正整数")
    return levels


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="本地 LLM 服务性能基准:吞吐 / 延迟 / KV Cache / 批处理(实验 2-1 配套)",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "场景说明:\n"
            "  throughput  单流解码吞吐(tok/s)与首 token 延迟(TTFT)\n"
            "  kv-cache    前缀缓存命中 vs 未命中的 TTFT 对比\n"
            "  batching    不同并发度下的聚合吞吐(批处理权衡)\n"
            "  all         依次运行以上全部场景\n"
        ),
    )
    parser.add_argument(
        "--scenario",
        choices=["throughput", "kv-cache", "batching", "all"],
        default="all",
        help="要运行的基准场景(默认: all)",
    )
    parser.add_argument(
        "--backend",
        choices=["vllm", "ollama"],
        default="vllm",
        help="服务端类型,用于推断默认地址与模型名(默认: vllm)",
    )
    parser.add_argument(
        "--base-url",
        type=str,
        default=None,
        help="OpenAI 兼容接口地址,覆盖后端默认值(如 http://localhost:8000/v1)",
    )
    parser.add_argument(
        "--model",
        type=str,
        default=None,
        help="模型名,覆盖后端默认值(vLLM 默认 Qwen3-0.6B,Ollama 默认 qwen3:0.6b)",
    )
    parser.add_argument(
        "--api-key",
        type=str,
        default="EMPTY",
        help="API Key,本地服务端一般无需真实值(默认: EMPTY)",
    )
    parser.add_argument(
        "--repeats",
        type=int,
        default=5,
        help="throughput / kv-cache 场景的重复次数(默认: 5)",
    )
    parser.add_argument(
        "--max-tokens",
        type=int,
        default=256,
        help="每次请求的最大生成 token 数(默认: 256)",
    )
    parser.add_argument(
        "--temperature",
        type=float,
        default=0.7,
        help="采样温度(默认: 0.7)",
    )
    parser.add_argument(
        "--prefix-tokens",
        type=int,
        default=1024,
        help="kv-cache 场景中共享前缀的近似 token 长度,越长缓存效果越明显(默认: 1024)",
    )
    parser.add_argument(
        "--concurrency",
        type=parse_concurrency,
        default=[1, 2, 4, 8],
        help="batching 场景的并发度列表,逗号分隔(默认: 1,2,4,8)",
    )
    parser.add_argument(
        "--output",
        type=str,
        default=None,
        help="将结果以 JSON 写入指定文件",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="离线打印计划而不访问服务端,用于验证配置",
    )
    return parser


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    # 用后端默认值补全 base_url / model
    defaults = BACKEND_DEFAULTS[args.backend]
    if args.base_url is None:
        args.base_url = defaults["base_url"]
    if args.model is None:
        args.model = defaults["model"]

    print("=" * 68)
    print("🚀 本地 LLM 服务性能基准(实验 2-1)")
    print("=" * 68)

    if args.dry_run:
        describe_dry_run(args)
        return 0

    client = make_client(args.base_url, args.api_key)
    logger.info("连接服务端: %s(模型: %s)", args.base_url, args.model)

    scenarios = (
        ["throughput", "kv-cache", "batching"]
        if args.scenario == "all"
        else [args.scenario]
    )
    dispatch = {
        "throughput": scenario_throughput,
        "kv-cache": scenario_kv_cache,
        "batching": scenario_batching,
    }

    results: List[Dict[str, Any]] = []
    try:
        for name in scenarios:
            logger.info("开始场景: %s", name)
            results.append(dispatch[name](client, args.model, args))
    except Exception as e:  # noqa: BLE001
        logger.error("基准执行失败: %s", e)
        logger.info(
            "请确认服务端已启动:vLLM 用 `python server.py`,"
            "Ollama 用 `ollama serve` 并已 `ollama pull %s`",
            args.model,
        )
        return 1

    print_report(results)

    if args.output:
        payload = {
            "backend": args.backend,
            "base_url": args.base_url,
            "model": args.model,
            "results": results,
        }
        with open(args.output, "w", encoding="utf-8") as f:
            json.dump(payload, f, ensure_ascii=False, indent=2)
        logger.info("结果已写入: %s", args.output)

    return 0


if __name__ == "__main__":
    sys.exit(main())

check_compatibility.py

#!/usr/bin/env python3
"""
Check system compatibility for running vLLM tool calling demo
"""

import sys
import platform
import subprocess
import shutil


def check_system():
    """Check system compatibility"""
    print("="*60)
    print("🔍 System Compatibility Check")
    print("="*60)

    # Get system info
    system = platform.system()
    machine = platform.machine()
    python_version = sys.version_info

    print(f"\n📊 System Information:")
    print(f"  OS: {system} ({platform.platform()})")
    print(f"  Architecture: {machine}")
    print(f"  Python: {python_version.major}.{python_version.minor}.{python_version.micro}")

    # Check for CUDA
    cuda_available = False
    gpu_info = None

    print(f"\n🎮 GPU Check:")

    if system == "Darwin":  # macOS
        print("  ❌ macOS detected - No CUDA support available")
        print("  ℹ️  Macs use Metal (Apple Silicon) or AMD/Intel GPUs")
        return False, "darwin"

    # Check for NVIDIA GPU
    if shutil.which("nvidia-smi"):
        try:
            result = subprocess.run(
                ["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader"],
                capture_output=True,
                text=True
            )
            if result.returncode == 0:
                gpu_info = result.stdout.strip()
                print(f"  ✅ NVIDIA GPU found: {gpu_info}")
                cuda_available = True
            else:
                print("  ⚠️  nvidia-smi found but couldn't query GPU")
        except Exception as e:
            print(f"  ⚠️  Error checking GPU: {e}")
    else:
        print("  ❌ No NVIDIA GPU detected (nvidia-smi not found)")

    # Check PyTorch CUDA
    print(f"\n🔥 PyTorch CUDA Check:")
    try:
        import torch
        if torch.cuda.is_available():
            print(f"  ✅ PyTorch CUDA is available")
            print(f"  CUDA version: {torch.version.cuda}")
            print(f"  Number of GPUs: {torch.cuda.device_count()}")
            if torch.cuda.device_count() > 0:
                print(f"  GPU 0: {torch.cuda.get_device_name(0)}")
        else:
            print("  ❌ PyTorch CUDA is not available")
            cuda_available = False
    except ImportError:
        print("  ⚠️  PyTorch not installed")

    return cuda_available, system.lower()


def provide_recommendations(cuda_available, system):
    """Provide recommendations based on system"""

    print("\n" + "="*60)
    print("💡 Recommendations")
    print("="*60)

    if cuda_available:
        print("\n✅ Your system supports vLLM!")
        print("\nNext steps:")
        print("1. Install requirements: pip install -r requirements.txt")
        print("2. Run the main script: python main.py")
        print("3. The script will automatically use vLLM")

    elif system == "darwin" or system.lower() == "darwin":  # macOS
        print("\n🍎 You're on macOS - will use Ollama")
        print("\n📋 Setup steps:\n")

        print("1️⃣  Install Ollama:")
        print("   brew install ollama")
        print("   ollama serve  # Run in separate terminal\n")

        print("2️⃣  Install a model with tool support:")
        print("   ollama pull qwen3:0.6b  # Default model for this project\n")

        print("3️⃣  Run the main script:")
        print("   python main.py")
        print("   # Will automatically use Ollama")

    elif system.lower() == "windows":  # Windows
        if not cuda_available:
            print("\n🪟 You're on Windows without CUDA - will use Ollama")
            print("\n📋 Setup steps:\n")

            print("1️⃣  Install Ollama:")
            print("   Download from: https://ollama.com/download/windows")
            print("   Run OllamaSetup.exe\n")

            print("2️⃣  Install a model:")
            print("   ollama pull qwen3:0.6b  # Default model for this project\n")

            print("3️⃣  Run the main script:")
            print("   python main.py")
            print("   # Will automatically use Ollama")

    else:  # Linux without CUDA
        print("\n🐧 You're on Linux without CUDA - will use Ollama")
        print("\n📋 Setup steps:\n")

        print("1️⃣  Install Ollama:")
        print("   curl -fsSL https://ollama.com/install.sh | sh")
        print("   systemctl start ollama  # Or: ollama serve\n")

        print("2️⃣  Install a model:")
        print("   ollama pull qwen3:0.6b  # Default model for this project\n")

        print("3️⃣  Run the main script:")
        print("   python main.py")
        print("   # Will automatically use Ollama")


def main():
    """Main compatibility check"""
    cuda_available, system = check_system()
    provide_recommendations(cuda_available, system)

    print("\n" + "="*60)
    print("For more details, see README.md")
    print("="*60)


if __name__ == "__main__":
    main()

config.py

"""
Configuration for vLLM Tool Calling Demo
"""
import os
from pathlib import Path
from dotenv import load_dotenv

# Load environment variables
load_dotenv()

# Model Configuration
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen3-0.6B")  # Can use ModelScope path or HuggingFace
MODEL_PATH = os.getenv("MODEL_PATH", None)  # Optional: local model path
VLLM_PORT = int(os.getenv("VLLM_PORT", 8000))
VLLM_HOST = os.getenv("VLLM_HOST", "localhost")

# vLLM Server Configuration
VLLM_SERVER_CONFIG = {
    "model": MODEL_NAME,
    "port": VLLM_PORT,
    "host": VLLM_HOST,
    "enable_auto_tool_choice": True,
    "tool_call_parser": "hermes",
    "chat_template": "tool_use",  # Use tool-specific chat template
    "max_model_len": 8192,
    "gpu_memory_utilization": 0.9,
    "dtype": "auto",
    "enforce_eager": False,  # Set to True if you encounter issues
}

# OpenAI Client Configuration (for connecting to vLLM)
OPENAI_API_BASE = f"http://{VLLM_HOST}:{VLLM_PORT}/v1"
OPENAI_API_KEY = "EMPTY"  # vLLM doesn't require a real key

# Tool Configuration
ENABLE_WEATHER_TOOL = True
ENABLE_CALCULATOR_TOOL = True
ENABLE_SEARCH_TOOL = True

# Logging
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
LOG_FILE = Path("logs") / "vllm_tool_demo.log"

demo_streaming.py

#!/usr/bin/env python3
"""
Simple demo showing how to use streaming with the chat template agents
"""

import sys
import time


def print_with_typing_effect(text, delay=0.03):
    """Print text with a typing effect"""
    for char in text:
        print(char, end="", flush=True)
        time.sleep(delay)
    print()


def demo_vllm_streaming():
    """Demo streaming with vLLM backend"""
    from agent import VLLMToolAgent
    from config import OPENAI_API_BASE, OPENAI_API_KEY

    print("="*60)
    print("🚀 vLLM Streaming Demo")
    print("="*60)

    try:
        agent = VLLMToolAgent(
            api_base=OPENAI_API_BASE,
            api_key=OPENAI_API_KEY
        )

        query = "What's the weather in New York and calculate 32°F in Celsius?"
        print(f"\n📝 Query: {query}\n")
        print("Streaming response:\n")
        print("-"*40)

        for chunk in agent.chat_stream(query):
            chunk_type = chunk.get("type")
            content = chunk.get("content", "")

            if chunk_type == "thinking":
                print(f"\n💭 [Thinking]: \033[90m{content}\033[0m")
            elif chunk_type == "tool_call":
                print(f"\n🔧 [Tool Call]: {content['name']}({content['arguments']})")
            elif chunk_type == "tool_result":
                print(f"   ✓ Result: {content}")
            elif chunk_type == "content":
                # Stream content character by character
                print(content, end="", flush=True)

        print("\n" + "-"*40)

    except Exception as e:
        print(f"Error: {e}")
        print("Make sure vLLM server is running!")


def demo_ollama_streaming():
    """Demo streaming with Ollama backend"""
    from ollama_native import OllamaNativeAgent
    import ollama

    print("="*60)
    print("🦙 Ollama Streaming Demo")
    print("="*60)

    try:
        # Check available models
        client = ollama.Client()
        models = [m['name'] for m in client.list()['models']]

        # Use qwen3:0.6b as the default model
        model = "qwen3:0.6b"

        if model not in models:
            print(f"⚠️ Recommended model {model} not found")
            print("Install with: ollama pull qwen3:0.6b")
            if models:
                model = models[0]
                print(f"Using fallback model: {model}")
            else:
                print("❌ No Ollama models found. Install with: ollama pull qwen3:0.6b")
                return

        print(f"Using model: {model}")
        agent = OllamaNativeAgent(model=model)

        query = "What's 15 * 23? Also get the current time in Tokyo."
        print(f"\n📝 Query: {query}\n")
        print("Streaming response:\n")
        print("-"*40)

        for chunk in agent.chat_stream(query):
            chunk_type = chunk.get("type")
            content = chunk.get("content", "")

            if chunk_type == "thinking":
                print(f"\n💭 [Thinking]: \033[90m{content}\033[0m")
            elif chunk_type == "tool_call":
                print(f"\n🔧 [Tool Call]: {content['name']}({content['arguments']})")
            elif chunk_type == "tool_result":
                print(f"   ✓ Result: {content}")
            elif chunk_type == "content":
                # Stream content
                print(content, end="", flush=True)

        print("\n" + "-"*40)

    except Exception as e:
        print(f"Error: {e}")
        print("Make sure Ollama is running: ollama serve")


def demo_unified_streaming():
    """Demo with unified ToolCallingAgent that auto-selects backend"""
    from main import ToolCallingAgent

    print("="*60)
    print("🎯 Unified Streaming Demo (Auto-detect Backend)")
    print("="*60)

    # Initialize agent (auto-detects best backend)
    print("\n⚙️  Initializing agent...")
    agent = ToolCallingAgent()
    print(f"✅ Using {agent.backend_type} backend\n")

    # Example queries
    queries = [
        "Calculate the compound interest on $1000 at 5% for 3 years",
        "What's the weather in London and what time is it there?",
        "Convert 50 EUR to USD and JPY"
    ]

    for i, query in enumerate(queries, 1):
        print(f"\n{'='*60}")
        print(f"Query {i}: {query}")
        print("-"*60)

        # Track what sections we've shown
        sections_shown = set()
        last_chunk_type = None

        for chunk in agent.chat(query, stream=True):
            chunk_type = chunk.get("type")
            content = chunk.get("content", "")

            if chunk_type == "thinking":
                if "thinking" not in sections_shown:
                    print("\n💭 Thinking: ", end="", flush=True)
                    sections_shown.add("thinking")
                # Stream thinking character by character in gray
                print(f"\033[90m{content}\033[0m", end="", flush=True)

            elif chunk_type == "tool_call":
                if "tools" not in sections_shown:
                    print("\n🔧 Tool Calls:")
                    sections_shown.add("tools")
                print(f"  → {content['name']}: {content['arguments']}")
                # Remove response section so it shows again after tools
                sections_shown.discard("response")

            elif chunk_type == "tool_result":
                result_str = str(content)
                print(f"    ✓ {result_str}")
                # Remove response section so it shows again after tools
                sections_shown.discard("response")

            elif chunk_type == "content":
                if "response" not in sections_shown:
                    if last_chunk_type in ["tool_result", "tool_call"]:
                        print("\n📝 Response (after tools):")
                    else:
                        print("\n📝 Response:")
                    sections_shown.add("response")
                    print("  ", end="")
                print(content, end="", flush=True)

            last_chunk_type = chunk_type

        print()  # New line after response

        # Reset for next query
        agent.reset_conversation()

    print("\n" + "="*60)
    print("✅ Demo completed!")
    print("="*60)


def main():
    """Main demo function"""
    import argparse

    parser = argparse.ArgumentParser(description="Streaming Demo for Chat Template Agents")
    parser.add_argument(
        "--backend",
        choices=["vllm", "ollama", "auto"],
        default="auto",
        help="Backend to use for demo"
    )

    args = parser.parse_args()

    if args.backend == "vllm":
        demo_vllm_streaming()
    elif args.backend == "ollama":
        demo_ollama_streaming()
    else:
        demo_unified_streaming()


if __name__ == "__main__":
    main()

main.py

#!/usr/bin/env python3
"""
Main Entry Point for Tool Calling Demo
Automatically selects the best backend based on your platform:
- Linux/Windows with NVIDIA GPU: Uses vLLM
- Mac/Windows/Linux without GPU: Uses Ollama
"""

import os
import sys
import platform
import logging
from typing import Optional, Dict, Any, List
import json

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)


class ToolCallingAgent:
    """
    Universal tool calling agent that works on all platforms
    Automatically selects vLLM (if GPU available) or Ollama
    """

    def __init__(self, backend: Optional[str] = None):
        """
        Initialize with automatic backend detection

        Args:
            backend: Force a specific backend ('vllm', 'ollama', or None for auto)
        """
        self.agent = None
        self.backend_type = backend or self._detect_best_backend()

        logger.info(f"Initializing on {platform.system()} with {self.backend_type}")
        self._initialize_backend()

    def _detect_best_backend(self) -> str:
        """Detect the best backend for current platform"""
        system = platform.system()

        # Check for CUDA support (Linux/Windows with NVIDIA GPU)
        if system in ["Linux", "Windows"]:
            try:
                import torch
                if torch.cuda.is_available():
                    logger.info("CUDA detected - will use vLLM")
                    return "vllm"
            except ImportError:
                pass

        # Default to Ollama for Mac or systems without CUDA
        logger.info(f"Using Ollama on {system}")
        return "ollama"

    def _initialize_backend(self):
        """Initialize the selected backend"""
        if self.backend_type == "vllm":
            self._init_vllm()
        else:
            self._init_ollama()

    def _init_vllm(self):
        """Initialize vLLM backend"""
        try:
            # Check if vLLM server is running
            import requests
            from config import VLLM_HOST, VLLM_PORT

            server_url = f"http://{VLLM_HOST}:{VLLM_PORT}/health"

            try:
                response = requests.get(server_url, timeout=1)
                if response.status_code != 200:
                    raise ConnectionError("vLLM server not responding")
            except:
                # Try to start the server
                logger.info("Starting vLLM server...")
                from server import VLLMServer
                server = VLLMServer()
                server.start(wait_for_ready=True)

            # Initialize vLLM agent
            from agent import VLLMToolAgent
            from config import OPENAI_API_BASE, OPENAI_API_KEY

            self.agent = VLLMToolAgent(
                api_base=OPENAI_API_BASE,
                api_key=OPENAI_API_KEY
            )
            logger.info("✅ vLLM agent initialized")

        except Exception as e:
            logger.warning(f"Failed to initialize vLLM: {e}")
            logger.info("Falling back to Ollama")
            self.backend_type = "ollama"
            self._init_ollama()

    def _init_ollama(self):
        """Initialize Ollama backend"""
        try:
            import ollama
            from ollama_native import OllamaNativeAgent

            # Check if Ollama is running
            client = ollama.Client()
            try:
                models_response = client.list()
                available_models = []
                if hasattr(models_response, 'models'):
                    available_models = [m.model for m in models_response.models]

                if not available_models:
                    logger.error("No Ollama models installed")
                    logger.info("Install a model with: ollama pull qwen3:0.6b")
                    sys.exit(1)

                # Use qwen3:0.6b as the default model
                model = "qwen3:0.6b"

                # Check if qwen3:0.6b is available
                if model not in available_models:
                    logger.warning(f"Recommended model {model} not found in available models")
                    logger.info("Install with: ollama pull qwen3:0.6b")
                    # Fall back to first available model if qwen3:0.6b is not installed
                    model = available_models[0]
                    logger.info(f"Using fallback model: {model}")

                logger.info(f"Using Ollama model: {model}")
                self.agent = OllamaNativeAgent(model=model)

            except Exception as e:
                logger.error(f"Ollama is not running: {e}")
                logger.info("\nPlease start Ollama:")

                system = platform.system()
                if system == "Darwin":  # Mac
                    logger.info("  brew services start ollama")
                    logger.info("  or: ollama serve")
                elif system == "Windows":
                    logger.info("  Start Ollama from the system tray")
                    logger.info("  or run: ollama serve")
                else:  # Linux
                    logger.info("  systemctl start ollama")
                    logger.info("  or: ollama serve")

                sys.exit(1)

        except ImportError:
            logger.error("Ollama not installed")
            logger.info("Install with: pip install ollama")
            sys.exit(1)

    def chat(self, message: str, use_tools: bool = True, stream: bool = False, **kwargs) -> str:
        """
        Send a message to the agent

        Args:
            message: User message
            use_tools: Whether to enable tool calling
            stream: Whether to stream the response
            **kwargs: Additional backend-specific parameters

        Returns:
            Agent response (or generator if streaming)
        """
        if not self.agent:
            raise RuntimeError("Agent not initialized")

        return self.agent.chat(message, use_tools=use_tools, stream=stream, **kwargs)

    def reset_conversation(self):
        """Reset conversation history"""
        if hasattr(self.agent, 'reset_conversation'):
            self.agent.reset_conversation()


def get_sample_tasks() -> List[Dict[str, str]]:
    """Get sample tasks for demonstration"""
    return [
        {
            "name": "🕐 Current Time Check",
            "description": "Get the current time in a specific city",
            "task": "What is the current time in Vancouver?"
        },
        {
            "name": "☀️ Simple Weather Check",
            "description": "Get current weather for a single city",
            "task": "What's the weather like in Vancouver right now?"
        },
        {
            "name": "☀️ Time and Weather Check",
            "description": "Get current time and weather for a single city",
            "task": "What's the current time and weather like in Vancouver right now?"
        },
        {
            "name": "💵 Compound Interest Calculation",
            "description": "Calculate compound interest using code interpreter",
            "task": "Calculate the compound interest on $5,000 invested at 6% annual interest rate for 30 years, compounded monthly."
        },

        {
            "name": "🌡️ Multi-City Weather Analysis",
            "description": "Compare weather across multiple cities using real-time data",
            "task": """Get the current weather for Tokyo, New York, London, Sydney, and Dubai. 
Then:
1. Which city has the highest temperature?
2. Which city has the lowest humidity?
3. Convert all temperatures to Fahrenheit for comparison
4. Calculate the average temperature across all cities"""
        },
        {
            "name": "💰 Complex Financial Analysis",
            "description": "Multi-step financial calculation with currency conversion",
            "task": """A company has the following quarterly revenues:
- Q1: $2,500,000 USD
- Q2: €2,100,000 EUR
- Q3: £1,800,000 GBP
- Q4: ¥380,000,000 JPY

Please:
1. Convert all revenues to USD
2. Calculate the total annual revenue in USD
3. Determine the average quarterly revenue
4. Find which quarter had the highest revenue
5. If the company has a 20% profit margin, calculate the annual profit in USD"""
        },
        {
            "name": "⏰ Global Time Zone Coordination",
            "description": "Coordinate meeting times across time zones",
            "task": """We need to schedule a global meeting with offices in:
- San Francisco (PST)
- New York (EST)
- London (GMT/BST)
- Tokyo (JST)
- Sydney (AEST)

If the meeting is at 2 PM London time:
1. What time would it be in each city?
2. Is this during normal business hours (9 AM - 5 PM) for each location?
3. Suggest a better time that works for most offices"""
        },
    ]


def run_single_task(agent: ToolCallingAgent, task: str, stream: bool = True):
    """Run a single task with optional streaming"""
    print("\n" + "="*60)
    print("TASK EXECUTION")
    print("="*60)
    print(f"\n📋 Task: {task}")
    print("-"*60)

    try:
        if stream:
            print("\n⏳ Processing (streaming)...\n")

            response_chunks = []
            thinking_shown = False
            tools_shown = False
            response_started = False
            last_chunk_type = None

            for chunk in agent.chat(task, stream=True):
                chunk_type = chunk.get("type")
                content = chunk.get("content", "")

                if chunk_type == "thinking":
                    if not thinking_shown:
                        print("🧠 Thinking: ", end="", flush=True)
                        thinking_shown = True
                    # Stream thinking character by character in gray
                    print(f"\033[90m{content}\033[0m", end="", flush=True)

                elif chunk_type == "tool_call":
                    if not tools_shown:
                        print("\n\n🔧 Tool Calls:")
                        tools_shown = True
                    # Display tool call info
                    tool_info = content
                    print(f"  → {tool_info.get('name', 'unknown')}: {tool_info.get('arguments', {})}")
                    # Reset response_started flag after tool calls
                    response_started = False

                elif chunk_type == "tool_result":
                    # Display tool result
                    result_str = str(content)
                    print(f"    ✓ {result_str}")
                    # Reset response_started flag after tool results
                    response_started = False

                elif chunk_type == "content":
                    if not response_started:
                        # Check if this is content after tool execution
                        if last_chunk_type in ["tool_result", "tool_call"]:
                            print("\n🤖 Assistant: ", end="", flush=True)
                        elif thinking_shown or tools_shown:
                            print("\n\n🤖 Assistant: ", end="", flush=True)
                        else:
                            print("🤖 Assistant: ", end="", flush=True)
                        response_started = True
                    # Stream the actual response content
                    print(content, end="", flush=True)
                    response_chunks.append(content)

                elif chunk_type == "error":
                    print(f"\n❌ Error: {content}")

                last_chunk_type = chunk_type

            print("\n" + "-"*40)

        else:
            print("\n⏳ Processing...")
            response = agent.chat(task, stream=False)

            print("\n✅ Response:")
            print("-"*40)
            print(response)
            print("-"*40)

    except Exception as e:
        print(f"\n❌ Error: {e}")
        logger.exception("Task execution failed")


def interactive_mode(agent: ToolCallingAgent, stream: bool = True):
    """Run interactive chat mode with optional streaming"""
    print("\n" + "="*60)
    print("💬 INTERACTIVE MODE" + (" (STREAMING)" if stream else ""))
    print("="*60)
    print("\nYou can now chat with the AI agent. It has access to various tools:")

    # Show available tools
    from tools import ToolRegistry
    registry = ToolRegistry()
    tools = registry.get_tool_schemas()

    print("\n📦 Available Tools:")
    for i, tool in enumerate(tools, 1):
        func = tool["function"]
        print(f"  {i}. {func['name']}: {func['description']}")

    print("\n💡 Commands:")
    print("  /reset      - Reset conversation")
    print("  /tools      - Show available tools")
    print("  /samples    - Show sample tasks")
    print("  /sample <n> - Run sample task number n")
    print("  /stream     - Toggle streaming mode")
    print("  /help       - Show this help")
    print("  /exit       - Exit the program")
    print("-"*60)

    streaming_enabled = stream

    while True:
        try:
            user_input = input("\n👤 You: ").strip()

            if not user_input:
                continue

            # Handle commands
            if user_input.lower() == "/exit" or user_input.lower() == "quit":
                print("👋 Goodbye!")
                break

            elif user_input.lower() == "/reset":
                agent.reset_conversation()
                print("✅ Conversation reset")
                continue

            elif user_input.lower() == "/tools":
                print("\n📦 Available Tools:")
                for i, tool in enumerate(tools, 1):
                    func = tool["function"]
                    print(f"  {i}. {func['name']}: {func['description']}")
                continue

            elif user_input.lower() == "/samples":
                print("\n📋 Sample Tasks:")
                sample_tasks = get_sample_tasks()
                for i, sample in enumerate(sample_tasks, 1):
                    print(f"  {i}. {sample['name']}")
                    # Show first 100 chars of task for readability
                    task_preview = sample['task'].replace('\n', ' ')[:100]
                    if len(sample['task']) > 100:
                        task_preview += "..."
                    print(f"     {task_preview}")
                print("\n💡 Tip: Use /sample <n> to run a specific sample (e.g., /sample 1)")
                continue

            elif user_input.lower().startswith("/sample "):
                # Extract the sample number
                try:
                    sample_num = int(user_input.split()[1])
                    sample_tasks = get_sample_tasks()

                    if 1 <= sample_num <= len(sample_tasks):
                        selected_sample = sample_tasks[sample_num - 1]
                        print(f"\n🎯 Running Sample: {selected_sample['name']}")
                        print("-"*60)
                        print(f"Task: {selected_sample['task']}")
                        print("-"*60)

                        # Process the sample task as regular input
                        user_input = selected_sample['task']
                        # Don't continue - let it fall through to normal processing
                    else:
                        print(f"❌ Invalid sample number. Please choose between 1 and {len(sample_tasks)}")
                        print("Use /samples to see available samples")
                        continue
                except (ValueError, IndexError):
                    print("❌ Invalid format. Use: /sample <number> (e.g., /sample 1)")
                    continue

            elif user_input.lower() == "/help":
                print("\n💡 Commands:")
                print("  /reset      - Reset conversation")
                print("  /tools      - Show available tools")
                print("  /samples    - Show sample tasks")
                print("  /sample <n> - Run sample task number n")
                print("  /stream     - Toggle streaming mode")
                print("  /help       - Show this help")
                print("  /exit       - Exit the program")
                continue

            elif user_input.lower() == "/stream":
                streaming_enabled = not streaming_enabled
                print(f"✅ Streaming {'enabled' if streaming_enabled else 'disabled'}")
                continue

            # Process user input
            if streaming_enabled:
                print("\n⏳ Processing (streaming)...\n")

                response_chunks = []
                thinking_shown = False
                tools_shown = False
                response_started = False
                last_chunk_type = None

                for chunk in agent.chat(user_input, stream=True):
                    chunk_type = chunk.get("type")
                    content = chunk.get("content", "")

                    if chunk_type == "thinking":
                        if not thinking_shown:
                            print("🧠 Thinking: ", end="", flush=True)
                            thinking_shown = True
                        # Stream thinking character by character in gray
                        print(f"\033[90m{content}\033[0m", end="", flush=True)

                    elif chunk_type == "tool_call":
                        if not tools_shown:
                            print("\n🔧 Tool Calls:")
                            tools_shown = True
                        tool_info = content
                        print(f"  → {tool_info.get('name', 'unknown')}: {tool_info.get('arguments', {})}")
                        # Reset response_started flag after tool calls so the next content gets a label
                        response_started = False

                    elif chunk_type == "tool_result":
                        result_str = str(content)
                        print(f"    ✓ {result_str}")
                        # Reset response_started flag after tool results
                        response_started = False

                    elif chunk_type == "content":
                        # If we're starting a new content section after tool results
                        if not response_started:
                            if last_chunk_type in ["tool_result", "tool_call"]:
                                # This is a response after tool execution
                                print("\n🤖 Assistant: ", end="", flush=True)
                            elif thinking_shown or tools_shown:
                                print("\n🤖 Assistant: ", end="", flush=True)
                            else:
                                print("🤖 Assistant: ", end="", flush=True)
                            response_started = True
                        print(content, end="", flush=True)
                        response_chunks.append(content)

                    elif chunk_type == "error":
                        print(f"\n❌ Error: {content}")

                    last_chunk_type = chunk_type

                print()  # New line after streaming
            else:
                print("\n⏳ Processing...")
                response = agent.chat(user_input, stream=False)

                print(f"🤖 Assistant: {response}")

        except KeyboardInterrupt:
            print("\n\n👋 Goodbye!")
            break
        except Exception as e:
            print(f"❌ Error: {e}")
            logger.exception("Error in interactive mode")


def main():
    """Main function"""
    import argparse

    parser = argparse.ArgumentParser(
        description="Universal Tool Calling Agent - Works on all platforms"
    )
    parser.add_argument(
        "--mode",
        choices=["single", "interactive"],
        default="interactive",
        help="Execution mode (default: interactive)"
    )
    parser.add_argument(
        "--task",
        type=str,
        help="Task to execute (for single mode)"
    )
    parser.add_argument(
        "--backend",
        choices=["vllm", "ollama", "auto"],
        default="auto",
        help="Backend to use (default: auto-detect)"
    )
    parser.add_argument(
        "--info",
        action="store_true",
        help="Show system information and exit"
    )
    parser.add_argument(
        "--stream",
        action="store_true",
        default=True,
        help="Enable streaming mode (default: True)"
    )
    parser.add_argument(
        "--no-stream",
        action="store_true",
        help="Disable streaming mode"
    )

    args = parser.parse_args()

    # Header
    print("="*60)
    print("🚀 Universal Tool Calling Agent")
    print("="*60)

    # Show system info if requested
    if args.info:
        print("\n📊 System Information:")
        print(f"  Platform: {platform.system()} {platform.release()}")
        print(f"  Architecture: {platform.machine()}")
        print(f"  Python: {sys.version.split()[0]}")

        # Check CUDA
        try:
            import torch
            cuda_available = torch.cuda.is_available()
            if cuda_available:
                print(f"  CUDA: ✅ Available (GPU: {torch.cuda.get_device_name(0)})")
            else:
                print("  CUDA: ❌ Not available")
        except ImportError:
            print("  CUDA: ❌ PyTorch not installed")

        # Check Ollama
        try:
            import ollama
            print("  Ollama: ✅ Package installed")
        except ImportError:
            print("  Ollama: ❌ Package not installed")

        return 0

    # Initialize agent
    print("\n⚙️  Initializing agent...")

    backend = None if args.backend == "auto" else args.backend

    try:
        agent = ToolCallingAgent(backend=backend)
    except SystemExit:
        return 1
    except Exception as e:
        print(f"❌ Failed to initialize: {e}")
        return 1

    print(f"✅ Agent ready! Using {agent.backend_type} backend")

    # Execute based on mode
    if args.mode == "single":
        if not args.task:
            # Show sample tasks for selection
            print("\n" + "="*60)
            print("SINGLE TASK MODE - No task provided")
            print("="*60)

            sample_tasks = get_sample_tasks()
            print("\n📋 Available sample tasks:")
            for i, sample in enumerate(sample_tasks, 1):
                print(f"\n{i}. {sample['name']}")
                print(f"   {sample['description']}")

            print("\n" + "="*60)
            try:
                choice = input(f"\nSelect a task number (1-{len(sample_tasks)}) or 'q' to quit: ").strip()
                if choice.lower() == 'q':
                    return 0

                task_num = int(choice)
                if 1 <= task_num <= len(sample_tasks):
                    selected_task = sample_tasks[task_num - 1]
                    print(f"\n✅ Selected: {selected_task['name']}")
                    print("\nTask details:")
                    print("-"*40)
                    print(selected_task['task'])
                    print("-"*40)

                    confirm = input("\nRun this task? (y/n): ").strip().lower()
                    if confirm == 'y':
                        stream_enabled = not args.no_stream if hasattr(args, 'no_stream') else True
                        run_single_task(agent, selected_task['task'], stream=stream_enabled)
                    else:
                        print("Task cancelled.")
                else:
                    print(f"Invalid selection. Please choose 1-{len(sample_tasks)}")
                    return 1
            except (ValueError, KeyboardInterrupt):
                print("\nExiting...")
                return 0
        else:
            stream_enabled = not args.no_stream if hasattr(args, 'no_stream') else True
            run_single_task(agent, args.task, stream=stream_enabled)

    else:  # interactive mode
        stream_enabled = not args.no_stream if hasattr(args, 'no_stream') else True
        interactive_mode(agent, stream=stream_enabled)

    return 0


if __name__ == "__main__":
    sys.exit(main())

ollama_native.py

"""
Ollama Native Tool Calling Implementation
Uses Ollama's standard tool calling API (requires compatible models)
"""

import json
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import List, Dict, Any, Optional
import ollama
from tools import ToolRegistry

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class OllamaNativeAgent:
    """Agent using Ollama's native tool calling support"""

    def __init__(self, model: str = "qwen3:0.6b"):
        """
        Initialize with a model that supports tool calling
        """
        self.model = model
        self.client = ollama.Client()
        self.tool_registry = ToolRegistry()
        self.conversation_history = []

        # Check if Ollama is running
        try:
            self.client.list()
            logger.info(f"✅ Connected to Ollama with model: {model}")
        except Exception as e:
            logger.error(f"❌ Failed to connect to Ollama: {e}")
            logger.info("Please start Ollama with: ollama serve")

    def _convert_tools_to_ollama_format(self) -> List[Dict]:
        """Convert tool registry to Ollama's expected format"""
        tools = []
        for tool_def in self.tool_registry.get_tool_schemas():
            # Ollama expects the same format as OpenAI
            tools.append(tool_def)
        return tools

    def _execute_tool_calls(self, tool_calls: List[Dict[str, Any]]) -> List[str]:
        """
        Execute tool calls and return results in order.
        Multiple tool calls in the same turn are executed in parallel (they are
        independent by construction, since the model generated all of them
        without seeing any result).
        """
        def run_one(tool_call: Dict[str, Any]) -> str:
            function = tool_call.get('function', {})
            tool_name = function.get('name')
            tool_args = function.get('arguments')

            # Parse arguments if they're a string
            if isinstance(tool_args, str):
                try:
                    tool_args = json.loads(tool_args)
                except json.JSONDecodeError:
                    logger.error(f"Failed to parse tool arguments: {tool_args}")
                    tool_args = {}

            # Execute the tool
            logger.info(f"Executing tool: {tool_name} with args: {tool_args}")
            return self.tool_registry.execute_tool(tool_name, tool_args)

        if len(tool_calls) <= 1:
            return [run_one(tc) for tc in tool_calls]

        # Independent tool calls run concurrently; executor.map preserves order
        with ThreadPoolExecutor(max_workers=len(tool_calls)) as executor:
            return list(executor.map(run_one, tool_calls))

    def chat(self, message: str, use_tools: bool = True,  
             temperature: float = 0.3, stream: bool = False) -> str:
        """
        Send a message using Ollama's native tool calling

        Args:
            message: User message
            use_tools: Whether to enable tool calling
            temperature: Sampling temperature
            stream: Whether to stream the response

        Returns:
            Final response from the model (or generator if streaming)
        """
        if stream:
            return self.chat_stream(message, use_tools, temperature)

        # Original non-streaming implementation continues below...
        # Add user message to history
        self.conversation_history.append({
            "role": "user",
            "content": message
        })

        # Prepare tools if enabled
        tools = self._convert_tools_to_ollama_format() if use_tools else None

        try:
            # Call Ollama with tools
            response = self.client.chat(
                model=self.model,
                messages=self.conversation_history,
                tools=tools,
                options={"temperature": temperature}
            )

            # Check if model made tool calls
            message_content = response.get('message', {})

            # Handle tool calls if present
            if 'tool_calls' in message_content:
                tool_calls = message_content['tool_calls']
                logger.info(f"Model requested {len(tool_calls)} tool call(s)")

                # Add assistant's message with tool calls to history
                self.conversation_history.append({
                    "role": "assistant",
                    "content": message_content.get('content', ''),
                    "tool_calls": tool_calls
                })

                # Execute the tool calls (independent calls run in parallel)
                results = self._execute_tool_calls(tool_calls)

                # Add tool results to conversation
                for result in results:
                    self.conversation_history.append({
                        "role": "tool",
                        "content": result
                    })

                # Get final response with tool results (still include tools!)
                final_response = self.client.chat(
                    model=self.model,
                    messages=self.conversation_history,
                    tools=tools,  # IMPORTANT: Keep tools available
                    options={"temperature": temperature}
                )

                final_content = final_response.get('message', {}).get('content', '')

                # Clean response (remove <think> tags if present)
                import re
                final_content = re.sub(r'<think>.*?</think>', '', final_content, flags=re.DOTALL).strip()

                # Add final response to history
                self.conversation_history.append({
                    "role": "assistant",
                    "content": final_content
                })

                return final_content

            else:
                # No tool calls, just return the response
                content = message_content.get('content', '')
                self.conversation_history.append({
                    "role": "assistant",
                    "content": content
                })
                return content

        except Exception as e:
            logger.error(f"Error in chat: {e}")
            return f"Error: {e}"

    def chat_stream(self, message: str, use_tools: bool = True,
                    temperature: float = 0.3):
        """
        Stream a message to the model and handle tool calls in a ReAct loop

        Yields chunks that include:
        - type: 'thinking', 'tool_call', 'tool_result', 'content'
        - content: The actual content
        """
        # Add user message to history
        self.conversation_history.append({
            "role": "user",
            "content": message
        })

        # Prepare tools if enabled
        tools = self._convert_tools_to_ollama_format() if use_tools else None

        # ReAct loop - keep going until no more tool calls are needed
        max_iterations = 10  # Prevent infinite loops
        iteration = 0

        while iteration < max_iterations:
            iteration += 1

            try:
                # Get response from model
                stream_response = self.client.chat(
                    model=self.model,
                    messages=self.conversation_history,
                    tools=tools,
                    options={"temperature": temperature},
                    stream=True
                )

                collected_content = []
                tool_calls_detected = False
                pending_tool_calls = []
                thinking_buffer = ""
                in_thinking = False

                # Process the stream
                for chunk in stream_response:
                    # Extract message content from chunk
                    message_chunk = chunk.get('message', {})
                    content_chunk = message_chunk.get('content', '')

                    if content_chunk:
                        collected_content.append(content_chunk)

                        # Handle thinking content
                        if '<think>' in content_chunk:
                            in_thinking = True
                            thinking_buffer = content_chunk
                            # Extract any content before <think>
                            import re
                            before_think = content_chunk.split('<think>')[0]
                            if before_think:
                                yield {"type": "content", "content": before_think}
                            # Extract thinking content from this chunk
                            if '</think>' in content_chunk:
                                # Complete thinking in one chunk
                                thinking_match = re.search(r'<think>(.*?)</think>', content_chunk, re.DOTALL)
                                if thinking_match:
                                    thinking_content = thinking_match.group(1).strip()
                                    # Stream thinking content character by character
                                    for char in thinking_content:
                                        yield {"type": "thinking", "content": char}
                                # Check for content after </think>
                                after_think = content_chunk.split('</think>')[-1]
                                if after_think:
                                    yield {"type": "content", "content": after_think}
                                in_thinking = False
                                thinking_buffer = ""
                            else:
                                # Partial thinking, extract what we have so far
                                partial_thinking = content_chunk.split('<think>')[-1]
                                for char in partial_thinking:
                                    yield {"type": "thinking", "content": char}
                        elif in_thinking:
                            thinking_buffer += content_chunk
                            if '</think>' in content_chunk:
                                # End of thinking
                                before_end = content_chunk.split('</think>')[0]
                                for char in before_end:
                                    yield {"type": "thinking", "content": char}
                                # Check for content after </think>
                                after_think = content_chunk.split('</think>')[-1]
                                if after_think:
                                    yield {"type": "content", "content": after_think}
                                in_thinking = False
                                thinking_buffer = ""
                            else:
                                # Continue streaming thinking
                                for char in content_chunk:
                                    yield {"type": "thinking", "content": char}
                        else:
                            # Regular content - yield as-is
                            yield {"type": "content", "content": content_chunk}

                    # Check for tool calls in the chunk
                    if 'tool_calls' in message_chunk:
                        tool_calls_detected = True

                        for tool_call in message_chunk['tool_calls']:
                            function = tool_call.get('function', {})
                            tool_name = function.get('name')
                            tool_args = function.get('arguments')

                            # Parse arguments if they're a string
                            if isinstance(tool_args, str):
                                try:
                                    tool_args = json.loads(tool_args)
                                except json.JSONDecodeError:
                                    tool_args = {}

                            # Collect the tool call; execution happens after the
                            # stream finishes so calls can run in parallel.
                            # Skip duplicates: some servers stream the accumulated
                            # tool_calls list in every chunk.
                            if not any(
                                tc.get('function', {}).get('name') == tool_name
                                and tc.get('function', {}).get('arguments') == function.get('arguments')
                                for tc in pending_tool_calls
                            ):
                                pending_tool_calls.append(tool_call)
                                yield {"type": "tool_call", "content": {"name": tool_name, "arguments": tool_args}}

                # Execute all tool calls from this turn in parallel
                if pending_tool_calls:
                    results = self._execute_tool_calls(pending_tool_calls)
                    for result in results:
                        # Yield tool result
                        yield {"type": "tool_result", "content": result}

                        # Add tool result to conversation
                        self.conversation_history.append({
                            "role": "tool",
                            "content": result
                        })

                # Save complete response to history
                complete_response = ''.join(collected_content)

                if tool_calls_detected:
                    # Add assistant's message to history
                    self.conversation_history.append({
                        "role": "assistant",
                        "content": complete_response if complete_response else ""
                    })
                    # Continue the ReAct loop - let the model decide what to do next
                    # The loop will continue and get the next response
                else:
                    # No tool calls - we have a final response
                    self.conversation_history.append({
                        "role": "assistant",
                        "content": complete_response
                    })
                    # Exit the ReAct loop
                    break

            except Exception as e:
                logger.error(f"Error in chat stream: {e}")
                yield {"type": "error", "content": str(e)}
                break

        # Check if we hit max iterations
        if iteration >= max_iterations:
            yield {"type": "error", "content": "Maximum iterations reached in ReAct loop"}

    def reset_conversation(self):
        """Reset the conversation history"""
        self.conversation_history = []
        logger.info("Conversation history reset")


class OllamaOpenAICompatible:
    """Use Ollama through its OpenAI-compatible endpoint"""

    def __init__(self, model: str = "qwen3:0.6b", 
                 base_url: str = "http://localhost:11434/v1"):
        """
        Initialize using Ollama's OpenAI-compatible API

        This provides better compatibility with tool calling
        """
        from openai import OpenAI

        self.model = model
        self.client = OpenAI(
            base_url=base_url,
            api_key="ollama"  # Ollama doesn't need a real key
        )
        self.tool_registry = ToolRegistry()
        self.conversation_history = []

        logger.info(f"✅ Initialized Ollama OpenAI-compatible client with {model}")

    def chat(self, message: str, use_tools: bool = True,
             temperature: float = 0.3) -> str:
        """
        Chat using OpenAI-compatible endpoint
        """
        # Add user message
        self.conversation_history.append({
            "role": "user",
            "content": message
        })

        # Prepare tools
        tools = self.tool_registry.get_tool_schemas() if use_tools else None

        try:
            # Call with tools
            response = self.client.chat.completions.create(
                model=self.model,
                messages=self.conversation_history,
                tools=tools,
                tool_choice="auto" if tools else None,
                temperature=temperature
            )

            assistant_message = response.choices[0].message

            # Check for tool calls
            if assistant_message.tool_calls:
                logger.info(f"Model requested {len(assistant_message.tool_calls)} tool(s)")

                # Add assistant message to history
                self.conversation_history.append({
                    "role": "assistant",
                    "content": assistant_message.content or "",
                    "tool_calls": [
                        {
                            "id": tc.id,
                            "type": tc.type,
                            "function": {
                                "name": tc.function.name,
                                "arguments": tc.function.arguments
                            }
                        } for tc in assistant_message.tool_calls
                    ]
                })

                # Execute tool calls (independent calls run in parallel;
                # executor.map preserves order)
                def run_one(tool_call):
                    # Parse arguments
                    try:
                        args = json.loads(tool_call.function.arguments)
                    except json.JSONDecodeError:
                        args = {}

                    # Execute tool
                    return self.tool_registry.execute_tool(
                        tool_call.function.name,
                        args
                    )

                tool_calls_list = list(assistant_message.tool_calls)
                if len(tool_calls_list) <= 1:
                    results = [run_one(tc) for tc in tool_calls_list]
                else:
                    with ThreadPoolExecutor(max_workers=len(tool_calls_list)) as executor:
                        results = list(executor.map(run_one, tool_calls_list))

                # Add tool results
                for tool_call, result in zip(tool_calls_list, results):
                    self.conversation_history.append({
                        "role": "tool",
                        "tool_call_id": tool_call.id,
                        "content": result
                    })

                # Get final response
                final_response = self.client.chat.completions.create(
                    model=self.model,
                    messages=self.conversation_history,
                    tools=tools,   # IMPORTANT: Keep tools available
                    temperature=temperature
                )

                final_content = final_response.choices[0].message.content

                # Add to history
                self.conversation_history.append({
                    "role": "assistant",
                    "content": final_content
                })

                return final_content

            else:
                # No tool calls
                content = assistant_message.content
                self.conversation_history.append({
                    "role": "assistant",
                    "content": content
                })
                return content

        except Exception as e:
            logger.error(f"Error: {e}")
            return f"Error: {e}"

    def reset_conversation(self):
        """Reset conversation history"""
        self.conversation_history = []
        logger.info("Conversation reset")


def test_native_tools():
    """Test Ollama's native tool calling"""
    print("="*60)
    print("🔧 Testing Ollama Native Tool Calling")
    print("="*60)

    # Test with default model
    models_to_test = [
        "qwen3:0.6b",  # Default model for this project
    ]

    for model_name in models_to_test:
        print(f"\n📦 Testing with {model_name}")
        print("-"*40)

        try:
            # Check if model is available
            client = ollama.Client()
            available_models = [m['name'] for m in client.list()['models']]

            if not any(model_name in m for m in available_models):
                print(f"⚠️  Model {model_name} not installed")
                print(f"   Install with: ollama pull {model_name}")
                continue

            # Test the model
            agent = OllamaNativeAgent(model=model_name)

            test_queries = [
                "What's 15 * 23?",
                "What's the weather in London?",
            ]

            for query in test_queries:
                print(f"\n👤 User: {query}")
                response = agent.chat(query)
                print(f"🤖 Assistant: {response[:200]}...")  # Truncate long responses
                agent.reset_conversation()

        except Exception as e:
            print(f"❌ Error testing {model_name}: {e}")

    print("\n" + "="*60)
    print("💡 Note:")
    print("This project uses qwen3:0.6b as the default model.")
    print("Install with: ollama pull qwen3:0.6b")
    print("="*60)


def demo():
    """Interactive demo with proper tool calling"""
    print("="*60)
    print("🎯 Ollama Standard Tool Calling Demo")
    print("="*60)

    # Let user choose implementation
    print("\nChoose implementation:")
    print("1. Native Ollama API (recommended)")
    print("2. OpenAI-compatible API")

    choice = input("\nEnter choice (1 or 2): ").strip()

    if choice == "2":
        print("\nUsing OpenAI-compatible endpoint...")
        agent = OllamaOpenAICompatible()
    else:
        print("\nUsing native Ollama API...")
        # Check for best available model
        try:
            client = ollama.Client()
            models = [m['name'] for m in client.list()['models']]

            # Use qwen3:0.6b as the default model
            model = "qwen3:0.6b"

            if model in models:
                print(f"Using recommended model: {model}")
            else:
                print(f"Recommended model {model} not found")
                print("Install with: ollama pull qwen3:0.6b")
                # Fall back to first available model
                model = models[0] if models else "qwen3:0.6b"
                print(f"Using fallback model: {model}")

            agent = OllamaNativeAgent(model=model)

        except Exception as e:
            print(f"Error: {e}")
            return

    # Interactive loop
    print("\n💬 Chat with the assistant (type 'exit' to quit)")
    print("-"*40)

    while True:
        user_input = input("\n👤 You: ").strip()
        if user_input.lower() in ['exit', 'quit']:
            break

        response = agent.chat(user_input)
        print(f"🤖 Assistant: {response}")

    print("\n👋 Goodbye!")


if __name__ == "__main__":
    import sys

    if len(sys.argv) > 1 and sys.argv[1] == "test":
        test_native_tools()
    else:
        demo()

quick_test_streaming.py


server.py

"""
vLLM Server Launcher for Qwen3 with Tool Calling Support
"""

import os
import sys
import subprocess
import time
import requests
from pathlib import Path
from config import VLLM_SERVER_CONFIG, VLLM_HOST, VLLM_PORT
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class VLLMServer:
    """Manager for vLLM server process"""

    def __init__(self, config: dict = None):
        """Initialize server manager with configuration"""
        self.config = config or VLLM_SERVER_CONFIG
        self.process = None
        self.server_url = f"http://{self.config['host']}:{self.config['port']}"

    def _build_command(self) -> list:
        """Build vLLM server command with arguments"""
        cmd = [
            sys.executable, "-m", "vllm.entrypoints.openai.api_server",
            "--model", self.config["model"],
            "--port", str(self.config["port"]),
            "--host", self.config["host"],
        ]

        # Add tool-specific arguments
        if self.config.get("enable_auto_tool_choice"):
            cmd.append("--enable-auto-tool-choice")

        if self.config.get("tool_call_parser"):
            cmd.extend(["--tool-call-parser", self.config["tool_call_parser"]])

        if self.config.get("chat_template"):
            cmd.extend(["--chat-template", self.config["chat_template"]])

        # Add performance arguments
        if self.config.get("max_model_len"):
            cmd.extend(["--max-model-len", str(self.config["max_model_len"])])

        if self.config.get("gpu_memory_utilization"):
            cmd.extend(["--gpu-memory-utilization", str(self.config["gpu_memory_utilization"])])

        if self.config.get("dtype"):
            cmd.extend(["--dtype", self.config["dtype"]])

        if self.config.get("enforce_eager"):
            cmd.append("--enforce-eager")

        # Add tensor parallel size if multiple GPUs
        if self.config.get("tensor_parallel_size"):
            cmd.extend(["--tensor-parallel-size", str(self.config["tensor_parallel_size"])])

        return cmd

    def start(self, wait_for_ready: bool = True, timeout: int = 120):
        """
        Start the vLLM server

        Args:
            wait_for_ready: Wait for server to be ready
            timeout: Maximum time to wait for server startup
        """
        if self.is_running():
            logger.info("vLLM server is already running")
            return

        # Create logs directory
        log_dir = Path("logs")
        log_dir.mkdir(exist_ok=True)

        # Build command
        cmd = self._build_command()
        logger.info(f"Starting vLLM server with command: {' '.join(cmd)}")

        # Start server process
        log_file = log_dir / "vllm_server.log"
        with open(log_file, "w") as f:
            self.process = subprocess.Popen(
                cmd,
                stdout=f,
                stderr=subprocess.STDOUT,
                env=os.environ.copy()
            )

        logger.info(f"vLLM server process started with PID: {self.process.pid}")
        logger.info(f"Server logs are being written to: {log_file}")

        if wait_for_ready:
            self._wait_for_ready(timeout)

    def _wait_for_ready(self, timeout: int = 120):
        """Wait for server to be ready"""
        start_time = time.time()
        health_url = f"{self.server_url}/health"

        logger.info(f"Waiting for vLLM server to be ready at {health_url}...")

        while time.time() - start_time < timeout:
            try:
                response = requests.get(health_url, timeout=1)
                if response.status_code == 200:
                    logger.info("vLLM server is ready!")

                    # Test model availability
                    models_url = f"{self.server_url}/v1/models"
                    models_response = requests.get(models_url)
                    if models_response.status_code == 200:
                        models = models_response.json()
                        logger.info(f"Available models: {models}")
                    return
            except requests.exceptions.RequestException:
                pass

            # Check if process is still running
            if self.process and self.process.poll() is not None:
                raise RuntimeError(f"vLLM server process died with code: {self.process.returncode}")

            time.sleep(2)

        raise TimeoutError(f"vLLM server did not start within {timeout} seconds")

    def stop(self):
        """Stop the vLLM server"""
        if self.process:
            logger.info("Stopping vLLM server...")
            self.process.terminate()
            try:
                self.process.wait(timeout=10)
            except subprocess.TimeoutExpired:
                logger.warning("Server did not stop gracefully, forcing kill...")
                self.process.kill()
                self.process.wait()

            self.process = None
            logger.info("vLLM server stopped")

    def is_running(self) -> bool:
        """Check if server is running"""
        if not self.process:
            return False

        # Check if process is still alive
        if self.process.poll() is not None:
            return False

        # Try to connect to health endpoint
        try:
            response = requests.get(f"{self.server_url}/health", timeout=1)
            return response.status_code == 200
        except requests.exceptions.RequestException:
            return False

    def restart(self):
        """Restart the server"""
        logger.info("Restarting vLLM server...")
        self.stop()
        time.sleep(2)
        self.start()


def download_model_from_modelscope():
    """
    Download Qwen3-0.6B model from ModelScope
    This is optional - vLLM can download from HuggingFace automatically
    """
    try:
        from modelscope import snapshot_download

        model_dir = snapshot_download(
            'Qwen/Qwen3-0.6B',
            cache_dir='./models'
        )
        logger.info(f"Model downloaded to: {model_dir}")
        return model_dir
    except ImportError:
        logger.warning("ModelScope not installed. Install with: pip install modelscope")
        logger.info("vLLM will download from HuggingFace instead")
        return None


def main():
    """Main function to start vLLM server"""
    import argparse

    parser = argparse.ArgumentParser(description="Start vLLM server with Qwen3 model")
    parser.add_argument("--download", action="store_true", 
                       help="Download model from ModelScope first")
    parser.add_argument("--model", type=str, default=None,
                       help="Model name or path (overrides config)")
    parser.add_argument("--port", type=int, default=None,
                       help="Server port (overrides config)")
    parser.add_argument("--host", type=str, default=None,
                       help="Server host (overrides config)")

    args = parser.parse_args()

    # Download model if requested
    if args.download:
        model_path = download_model_from_modelscope()
        if model_path:
            VLLM_SERVER_CONFIG["model"] = model_path

    # Override config with command line arguments
    if args.model:
        VLLM_SERVER_CONFIG["model"] = args.model
    if args.port:
        VLLM_SERVER_CONFIG["port"] = args.port
    if args.host:
        VLLM_SERVER_CONFIG["host"] = args.host

    # Create and start server
    server = VLLMServer(VLLM_SERVER_CONFIG)

    try:
        server.start(wait_for_ready=True)
        logger.info(f"vLLM server is running at {server.server_url}")
        logger.info("Press Ctrl+C to stop the server")

        # Keep the server running
        while True:
            time.sleep(1)
            if not server.is_running():
                logger.error("Server stopped unexpectedly!")
                break

    except KeyboardInterrupt:
        logger.info("\nShutting down...")
    except Exception as e:
        logger.error(f"Error: {e}")
    finally:
        server.stop()


if __name__ == "__main__":
    main()

test_code_interpreter_full.py

"""
Test the full Python environment code interpreter with error handling
"""
import json
from tools import ToolRegistry

def test_successful_execution():
    """Test that code executes successfully with full Python environment"""
    print("=" * 60)
    print("Test 1: Successful execution with full Python environment")
    print("=" * 60)

    registry = ToolRegistry()

    # Test with various Python features that would fail in a sandbox
    test_cases = [
        {
            "name": "Complex calculation",
            "code": "import numpy as np\nresult = np.array([1, 2, 3, 4, 5]).mean()"
        },
        {
            "name": "File operations (simulated)",
            "code": "import os\nresult = os.getcwd()"
        },
        {
            "name": "Dict comprehension",
            "code": "result = {i: i**2 for i in range(5)}"
        },
        {
            "name": "Lambda and map",
            "code": "result = list(map(lambda x: x**2, [1, 2, 3, 4, 5]))"
        }
    ]

    for test in test_cases:
        print(f"\n{test['name']}:")
        try:
            result = registry.execute_tool("code_interpreter", {"code": test["code"]})
            result_dict = json.loads(result)
            if result_dict.get("success"):
                print(f"  ✓ Success: {result_dict.get('result')}")
            else:
                print(f"  ✗ Failed: {result_dict.get('error')}")
        except Exception as e:
            print(f"  ✗ Exception: {e}")

def test_error_handling():
    """Test that errors are properly captured and formatted"""
    print("\n" + "=" * 60)
    print("Test 2: Error handling and reporting")
    print("=" * 60)

    registry = ToolRegistry()

    error_cases = [
        {
            "name": "Syntax Error",
            "code": "if True\n    print('missing colon')"
        },
        {
            "name": "Name Error",
            "code": "result = undefined_variable + 10"
        },
        {
            "name": "Type Error",
            "code": "result = '5' + 5"
        },
        {
            "name": "Division by Zero",
            "code": "result = 10 / 0"
        },
        {
            "name": "Import Error",
            "code": "import nonexistent_module\nresult = 42"
        }
    ]

    for test in error_cases:
        print(f"\n{test['name']}:")
        result = registry.execute_tool("code_interpreter", {"code": test["code"]})
        result_dict = json.loads(result)

        if not result_dict.get("success"):
            print(f"  ✓ Error properly caught:")
            print(f"    Error Type: {result_dict.get('error_type')}")
            print(f"    Error Message: {result_dict.get('error')}")
            if result_dict.get('traceback'):
                print(f"    Traceback: {result_dict.get('traceback')[:100]}...")
        else:
            print(f"  ✗ Error not caught - this shouldn't happen")

def test_full_environment_access():
    """Test that the code interpreter has access to full Python environment"""
    print("\n" + "=" * 60)
    print("Test 3: Full Python environment access")
    print("=" * 60)

    registry = ToolRegistry()

    # Test access to various Python features that would be blocked in a sandbox
    full_env_tests = [
        {
            "name": "Access to all builtins",
            "code": "result = [callable(eval), callable(exec), callable(compile), callable(__import__)]"
        },
        {
            "name": "Dynamic import",
            "code": "import sys\nresult = f'Python {sys.version_info.major}.{sys.version_info.minor}'"
        },
        {
            "name": "List comprehension with filter",
            "code": "result = [x for x in range(20) if x % 2 == 0 and x % 3 == 0]"
        },
        {
            "name": "Multiple variable assignment",
            "code": "a, b, c = 1, 2, 3\nresult = a + b + c"
        }
    ]

    for test in full_env_tests:
        print(f"\n{test['name']}:")
        result = registry.execute_tool("code_interpreter", {"code": test["code"]})
        result_dict = json.loads(result)

        if result_dict.get("success"):
            print(f"  ✓ Success: {result_dict.get('result')}")
            if result_dict.get('output'):
                print(f"    Output: {result_dict.get('output')}")
        else:
            print(f"  ✗ Failed: {result_dict.get('error')}")

def test_agent_error_propagation():
    """Test that errors are properly formatted for the agent"""
    print("\n" + "=" * 60)
    print("Test 4: Agent error message formatting")
    print("=" * 60)

    from agent import VLLMToolAgent

    # This test would require a running vLLM server, so we'll just show
    # how errors would be formatted

    registry = ToolRegistry()

    # Simulate an error
    result = registry.execute_tool("code_interpreter", {
        "code": "result = 1 / 0"
    })

    result_dict = json.loads(result)

    # Format as the agent would
    if not result_dict.get("success"):
        error_msg = f"❌ Tool 'code_interpreter' execution failed:\n"
        if "error" in result_dict:
            error_msg += f"Error: {result_dict['error']}\n"
        if "error_type" in result_dict:
            error_msg += f"Type: {result_dict['error_type']}\n"
        if "traceback" in result_dict:
            error_msg += f"Traceback:\n{result_dict['traceback']}\n"

        print("\nFormatted error message that would be sent to agent:")
        print("-" * 60)
        print(error_msg)
        print("-" * 60)
        print("\nThe agent will receive this error message and can:")
        print("  1. Try to fix the code")
        print("  2. Ask the user for clarification")
        print("  3. Provide an alternative solution")

if __name__ == "__main__":
    test_successful_execution()
    test_error_handling()
    test_full_environment_access()
    test_agent_error_propagation()

    print("\n" + "=" * 60)
    print("All tests completed!")
    print("=" * 60)
    print("\nSummary:")
    print("  ✓ Full Python environment is available (no sandbox restrictions)")
    print("  ✓ Errors are properly caught and detailed traceback provided")
    print("  ✓ Error messages are formatted clearly for the agent")
    print("  ✓ Agent can receive and process error information")

test_ollama_streaming_final.py


test_parallel_tools.py

"""
End-to-end test for parallel tool calling with a real LLM (Ollama).

Covers:
1. Deterministic proof of parallel execution: two 2s-sleep tools must finish
   in ~2s (not ~4s) through each agent's tool-execution path.
2. Real-model runs of the book's "Vancouver time + weather" example through:
   - OllamaNativeAgent.chat / chat_stream (native tool calling)
   - OllamaOpenAICompatible.chat (OpenAI-compatible endpoint, native tools)
   - VLLMToolAgent.chat / chat_stream (<tool_call> XML parsed from content;
     the OpenAI-compatible endpoint of Ollama stands in for the vLLM server,
     so the `tools` kwarg is dropped to make the model emit XML in content)

Run from this directory:  python3 test_parallel_tools.py
Requires: ollama serve + ollama pull qwen2.5:7b-instruct-q8_0
"""
import json
import logging
import time
from types import SimpleNamespace

from ollama_native import OllamaNativeAgent, OllamaOpenAICompatible
from agent import VLLMToolAgent

logging.basicConfig(level=logging.WARNING)

SLEEP = 2
QUERY = "What time is it in Vancouver and what's the current weather in Vancouver?"
# qwen3:0.6b is the project default but flaky at native tool calling;
# qwen2.5:7b-instruct-q8_0 (also pulled locally) calls tools reliably.
REAL_MODEL = "qwen2.5:7b-instruct-q8_0"
MAX_ATTEMPTS = 4  # small models occasionally skip tool calling; retry


# ---------------------------------------------------------------- helpers

def _sleep_tool(name):
    def fn(tag: str = "") -> dict:
        time.sleep(SLEEP)
        return {"tool": name, "slept": SLEEP, "success": True}
    return fn


SLEEP_SCHEMA = {
    "type": "object",
    "properties": {"tag": {"type": "string", "description": "optional tag"}},
    "required": [],
}


def _register_sleep_tools(registry):
    registry.register_tool("sleep_a", _sleep_tool("sleep_a"), "Sleep tool A", SLEEP_SCHEMA)
    registry.register_tool("sleep_b", _sleep_tool("sleep_b"), "Sleep tool B", SLEEP_SCHEMA)


def _check_parallel(label, elapsed, n=2):
    status = "PARALLEL OK" if elapsed < SLEEP * 1.8 else "NOT PARALLEL"
    print(f"  [{label}] {n} x {SLEEP}s tools finished in {elapsed:.1f}s -> {status}")
    assert elapsed < SLEEP * 1.8, f"{label}: tool calls were not executed in parallel"


# ------------------------------------- 1. deterministic parallel checks

def test_native_execute_parallel():
    print("\n== OllamaNativeAgent._execute_tool_calls (sleep tools) ==")
    agent = OllamaNativeAgent(model=REAL_MODEL)
    _register_sleep_tools(agent.tool_registry)
    calls = [
        {"function": {"name": "sleep_a", "arguments": {}}},
        {"function": {"name": "sleep_b", "arguments": {}}},
    ]
    start = time.time()
    results = agent._execute_tool_calls(calls)
    _check_parallel("native _execute_tool_calls", time.time() - start)
    assert all(json.loads(r)["success"] for r in results)


def _make_chunk(text):
    return SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=text))])


def test_vllm_stream_parallel():
    print("\n== VLLMToolAgent.chat_stream (fake stream with 2 tool calls) ==")
    agent = VLLMToolAgent(api_base="http://localhost:11434/v1", api_key="ollama")
    _register_sleep_tools(agent.tool_registry)

    tool_xml = (
        '<tool_call>{"name": "sleep_a", "arguments": {}}</tool_call>'
        '<tool_call>{"name": "sleep_b", "arguments": {}}</tool_call>'
    )
    state = {"n": 0}

    def fake_create(**kwargs):
        state["n"] += 1
        if state["n"] == 1:
            # split the two tool calls across chunks to exercise buffering
            parts = [tool_xml[:25], tool_xml[25:90], tool_xml[90:]]
            return iter([_make_chunk(p) for p in parts])
        return iter([_make_chunk("done")])

    agent.client = SimpleNamespace(
        chat=SimpleNamespace(completions=SimpleNamespace(create=fake_create))
    )

    start = time.time()
    events = list(agent.chat_stream("run both sleep tools"))
    elapsed = time.time() - start

    types = [e["type"] for e in events]
    print(f"  event types: {types}")
    assert types.count("tool_call") == 2, f"expected 2 tool_call events: {types}"
    assert types.count("tool_result") == 2, f"expected 2 tool_result events: {types}"
    _check_parallel("vllm chat_stream", elapsed)


# ------------------------------------- 2. real-model end-to-end runs

def test_native_real_model():
    print(f"\n== OllamaNativeAgent.chat (real {REAL_MODEL}) ==")
    agent = OllamaNativeAgent(model=REAL_MODEL)
    response, tool_msgs = "", []
    for attempt in range(1, MAX_ATTEMPTS + 1):
        agent.reset_conversation()
        response = agent.chat(QUERY)
        tool_msgs = [m for m in agent.conversation_history if m.get("role") == "tool"]
        if tool_msgs:
            break
        print(f"  attempt {attempt}: model made no tool call, retrying")
    assistant_tc = [m for m in agent.conversation_history if m.get("tool_calls")]
    batch = len(assistant_tc[0]["tool_calls"]) if assistant_tc else 0
    print(f"  tool calls in one turn: {batch} ({'PARALLEL BATCH' if batch > 1 else 'single'})")
    for m in tool_msgs:
        print(f"    - {m['content'][:120]}")
    print(f"  final response: {response[:300]}")
    assert tool_msgs, "model did not call any tool"

    print(f"\n== OllamaNativeAgent.chat_stream (real {REAL_MODEL}) ==")
    events = []
    for attempt in range(1, MAX_ATTEMPTS + 1):
        agent.reset_conversation()
        events = list(agent.chat_stream(QUERY))
        if any(e["type"] == "tool_result" for e in events):
            break
        print(f"  attempt {attempt}: no tool call, retrying")
    tool_calls = [e for e in events if e["type"] == "tool_call"]
    tool_results = [e for e in events if e["type"] == "tool_result"]
    print(f"  tool_call events: {len(tool_calls)}, tool_result events: {len(tool_results)}")
    for e in tool_calls:
        print(f"    - {e['content']['name']}({e['content']['arguments']})")
    final = "".join(e["content"] for e in events if e["type"] == "content")
    print(f"  final content: {final[:300]}")
    assert tool_results, "streaming path produced no tool results"


def test_openai_compat_real_model():
    print(f"\n== OllamaOpenAICompatible.chat (real {REAL_MODEL}) ==")
    agent = OllamaOpenAICompatible(model=REAL_MODEL)
    response, tool_msgs = "", []
    for attempt in range(1, MAX_ATTEMPTS + 1):
        agent.reset_conversation()
        response = agent.chat(QUERY)
        tool_msgs = [m for m in agent.conversation_history if m.get("role") == "tool"]
        if tool_msgs:
            break
        print(f"  attempt {attempt}: model made no tool call, retrying")
    print(f"  tool results in history: {len(tool_msgs)}")
    print(f"  final response: {response[:300]}")
    assert tool_msgs, "model did not call any tool"


def test_vllm_agent_real_model():
    print(f"\n== VLLMToolAgent.chat (real {REAL_MODEL} via OpenAI endpoint) ==")
    agent = VLLMToolAgent(api_base="http://localhost:11434/v1", api_key="ollama")

    # Ollama's OpenAI endpoint stands in for the vLLM server. The agent parses
    # <tool_call> XML from content, so drop the native `tools` kwarg (which
    # would make Ollama return structured tool_calls instead) and fix the
    # hardcoded vLLM model name to the Ollama one.
    real_create = agent.client.chat.completions.create

    def create_xml_mode(**kwargs):
        kwargs.pop("tools", None)
        kwargs.pop("tool_choice", None)
        kwargs["model"] = REAL_MODEL
        return real_create(**kwargs)

    agent.client = SimpleNamespace(
        chat=SimpleNamespace(completions=SimpleNamespace(create=create_xml_mode))
    )

    response, tool_msgs, batch = "", [], 0
    for attempt in range(1, MAX_ATTEMPTS + 1):
        agent.reset_conversation()
        # temperature 0.7 sampling can degenerate into a tool-call loop with
        # this handcrafted XML format; 0.3 is stable
        response = agent.chat(QUERY, temperature=0.3)
        tool_msgs = [m for m in agent.conversation_history if m.get("name")]
        assistant_tc = [m for m in agent.conversation_history if m.get("tool_calls")]
        batch = len(assistant_tc[0]["tool_calls"]) if assistant_tc else 0
        if tool_msgs and batch <= 10:
            break
        print(f"  attempt {attempt}: no tool call or degenerate run ({batch} calls), retrying")
    print(f"  tool calls in one turn: {batch} ({'PARALLEL BATCH' if batch > 1 else 'single'})")
    for m in tool_msgs:
        print(f"    - {m['name']}: {m['content'][:100]}")
    print(f"  final response: {response[:300]}")
    assert tool_msgs, "model did not emit <tool_call> XML"

    print(f"\n== VLLMToolAgent.chat_stream (real {REAL_MODEL}) ==")
    events = []
    for attempt in range(1, MAX_ATTEMPTS + 1):
        agent.reset_conversation()
        # temperature 0.7 sampling can degenerate into a tool-call loop with
        # this handcrafted XML format; 0.3 is stable
        events = list(agent.chat_stream(QUERY, temperature=0.3))
        n_calls = sum(1 for e in events if e["type"] == "tool_call")
        if any(e["type"] == "tool_result" for e in events) and n_calls <= 10:
            break
        print(f"  attempt {attempt}: no tool call or degenerate run ({n_calls} calls), retrying")
    tool_calls = [e for e in events if e["type"] == "tool_call"]
    tool_results = [e for e in events if e["type"] == "tool_result"]
    print(f"  tool_call events: {len(tool_calls)}, tool_result events: {len(tool_results)}")
    for e in tool_calls:
        print(f"    - {e['content']}")
    final = "".join(e["content"] for e in events if e["type"] == "content")
    print(f"  final content: {final[:300]}")
    assert tool_results, "streaming path produced no tool results"


if __name__ == "__main__":
    test_native_execute_parallel()
    test_vllm_stream_parallel()
    test_native_real_model()
    test_openai_compat_real_model()
    test_vllm_agent_real_model()
    print("\nALL TESTS PASSED")

test_sample_command.py


test_streaming.py

#!/usr/bin/env python3
"""
Test script to demonstrate streaming functionality
for both vLLM and Ollama backends
"""

import sys
import platform
from main import ToolCallingAgent


def test_streaming():
    """Test streaming functionality with various queries"""
    print("="*60)
    print("🚀 STREAMING TEST DEMO")
    print("="*60)
    print(f"Platform: {platform.system()}")
    print("="*60)

    # Initialize agent
    print("\n⚙️  Initializing agent...")
    agent = ToolCallingAgent()
    print(f"✅ Using {agent.backend_type} backend")

    # Test queries that will demonstrate streaming features
    test_queries = [
        {
            "name": "Simple Calculation with Thinking",
            "query": "Calculate 15 * 23 + sqrt(144). Think through the steps."
        },
        {
            "name": "Tool Usage with Weather",
            "query": "What's the weather in Tokyo? If it's hot (above 25°C), suggest some cooling tips."
        },
        {
            "name": "Multiple Tools",
            "query": "Convert 100 USD to EUR and tell me the current time in London."
        }
    ]

    for test in test_queries:
        print("\n" + "="*60)
        print(f"📋 Test: {test['name']}")
        print("="*60)
        print(f"Query: {test['query']}")
        print("-"*60)

        try:
            print("\n🔄 Streaming response:\n")

            thinking_shown = False
            tools_shown = False
            response_shown = False

            # Stream the response
            for chunk in agent.chat(test['query'], stream=True):
                chunk_type = chunk.get("type")
                content = chunk.get("content", "")

                if chunk_type == "thinking":
                    if not thinking_shown:
                        print("🧠 Internal Thinking:")
                        print("  ", end="")
                        thinking_shown = True
                    # Show thinking in gray/dim text
                    print(f"\033[90m{content}\033[0m")

                elif chunk_type == "tool_call":
                    if not tools_shown:
                        print("\n🔧 Tool Calls:")
                        tools_shown = True
                    tool_info = content
                    print(f"  📦 Calling: {tool_info.get('name', 'unknown')}")
                    print(f"     Arguments: {tool_info.get('arguments', {})}")

                elif chunk_type == "tool_result":
                    result_str = str(content)
                    print(f"     ✓ Result: {result_str}")

                elif chunk_type == "content":
                    if not response_shown:
                        print("\n🤖 Assistant Response:")
                        print("  ", end="")
                        response_shown = True
                    # Stream the content character by character
                    print(content, end="", flush=True)

                elif chunk_type == "error":
                    print(f"\n❌ Error: {content}")

            print("\n")  # New line after response

        except Exception as e:
            print(f"\n❌ Error during test: {e}")

        # Reset conversation for next test
        agent.reset_conversation()

        # Ask user if they want to continue
        if test != test_queries[-1]:
            cont = input("\nPress Enter to continue to next test (or 'q' to quit): ")
            if cont.lower() == 'q':
                break

    print("\n" + "="*60)
    print("✅ Streaming test completed!")
    print("="*60)


def compare_streaming_vs_regular():
    """Compare streaming vs regular responses"""
    print("="*60)
    print("📊 STREAMING VS REGULAR COMPARISON")
    print("="*60)

    # Initialize agent
    agent = ToolCallingAgent()

    test_query = "What's the weather in Paris and convert 20°C to Fahrenheit?"

    print(f"\n📋 Test Query: {test_query}")
    print("="*60)

    # Regular mode
    print("\n1️⃣ REGULAR MODE (No Streaming):")
    print("-"*40)
    print("⏳ Processing...")
    response = agent.chat(test_query, stream=False)
    print(f"🤖 Response: {response}")

    agent.reset_conversation()

    # Streaming mode
    print("\n2️⃣ STREAMING MODE:")
    print("-"*40)
    print("⏳ Processing (you'll see content as it arrives)...\n")

    for chunk in agent.chat(test_query, stream=True):
        chunk_type = chunk.get("type")
        content = chunk.get("content", "")

        if chunk_type == "thinking":
            print(f"[THINKING] \033[90m{content}\033[0m")
        elif chunk_type == "tool_call":
            print(f"[TOOL CALL] {content}")
        elif chunk_type == "tool_result":
            print(f"[TOOL RESULT] {content}")
        elif chunk_type == "content":
            print(content, end="", flush=True)

    print("\n\n" + "="*60)
    print("✅ Comparison complete!")
    print("💡 Streaming mode shows intermediate steps in real-time")
    print("="*60)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="Test streaming functionality")
    parser.add_argument(
        "--mode",
        choices=["demo", "compare"],
        default="demo",
        help="Test mode: demo (full demo) or compare (streaming vs regular)"
    )

    args = parser.parse_args()

    if args.mode == "demo":
        test_streaming()
    else:
        compare_streaming_vs_regular()

test_streaming_fix.py


test_weather.py


tools.py

"""
Sample tools for demonstrating vLLM tool calling functionality
"""
import json
import math
import random
import io
import contextlib
from typing import Dict, Any, List
from datetime import datetime
import requests
from io import BytesIO
import PyPDF2


class ToolRegistry:
    """Registry for managing available tools"""

    def __init__(self):
        self.tools = {}
        self._register_default_tools()

    def _register_default_tools(self):
        """Register default tools"""
        self.register_tool(
            name="get_current_temperature",
            function=self.get_current_temperature,
            description="Get the current temperature for a specific location",
            parameters={
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "The city and country, e.g., 'Paris, France'"
                    },
                    "unit": {
                        "type": "string",
                        "enum": ["celsius", "fahrenheit"],
                        "description": "The temperature unit to use (by default, celsius)"
                    }
                },
                "required": ["location", "unit"]
            }
        )

        self.register_tool(
            name="get_current_time",
            function=self.get_current_time,
            description="Get the current date and time in a specific timezone",
            parameters={
                "type": "object",
                "properties": {
                    "timezone": {
                        "type": "string",
                        "description": "Timezone name (e.g., 'America/New_York', 'Europe/London', 'Asia/Tokyo'). Use standard IANA timezone names.",
                        "default": "UTC"
                    }
                },
                "required": []
            }
        )

        self.register_tool(
            name="convert_currency",
            function=self.convert_currency,
            description="Convert an amount from one currency to another. You MUST use this tool to convert currencies in order to get the latest exchange rate.",
            parameters={
                "type": "object",
                "properties": {
                    "amount": {
                        "type": "number",
                        "description": "Amount to convert"
                    },
                    "from_currency": {
                        "type": "string",
                        "description": "Source currency code (e.g., 'USD', 'EUR')"
                    },
                    "to_currency": {
                        "type": "string",
                        "description": "Target currency code (e.g., 'USD', 'EUR')"
                    }
                },
                "required": ["amount", "from_currency", "to_currency"]
            }
        )

        self.register_tool(
            name="code_interpreter",
            function=self.code_interpreter,
            description="Execute Python code for calculations and data processing. You MUST use this tool to perform any complex calculations or data processing.",
            parameters={
                "type": "object",
                "properties": {
                    "code": {
                        "type": "string",
                        "description": "Python code to execute"
                    }
                },
                "required": ["code"]
            }
        )

    def register_tool(self, name: str, function: callable, description: str, parameters: Dict):
        """Register a new tool"""
        self.tools[name] = {
            "function": function,
            "description": description,
            "parameters": parameters
        }

    def get_tool_schemas(self) -> List[Dict]:
        """Get OpenAI-compatible tool schemas"""
        schemas = []
        for name, tool in self.tools.items():
            schemas.append({
                "type": "function",
                "function": {
                    "name": name,
                    "description": tool["description"],
                    "parameters": tool["parameters"]
                }
            })
        return schemas

    def execute_tool(self, name: str, arguments: Dict[str, Any]) -> str:
        """Execute a tool by name with given arguments"""
        if name not in self.tools:
            return json.dumps({"error": f"Tool '{name}' not found"})

        try:
            result = self.tools[name]["function"](**arguments)
            return json.dumps(result) if isinstance(result, (dict, list)) else str(result)
        except Exception as e:
            return json.dumps({"error": str(e)})

    # Tool implementations
    @staticmethod
    def get_current_temperature(location: str, unit: str = "celsius") -> Dict:
        """
        Get current temperature using Open-Meteo free weather API
        No API key required - https://open-meteo.com/
        """
        try:
            # First, geocode the location to get coordinates
            geocoding_url = "https://geocoding-api.open-meteo.com/v1/search"
            geo_params = {
                "name": location,
                "count": 1,
                "language": "en",
                "format": "json"
            }

            geo_response = requests.get(geocoding_url, params=geo_params, timeout=5)
            geo_data = geo_response.json()

            if not geo_data.get("results"):
                return {
                    "location": location,
                    "error": f"Location '{location}' not found",
                    "timestamp": datetime.now().isoformat()
                }

            # Get coordinates from first result
            result = geo_data["results"][0]
            latitude = result["latitude"]
            longitude = result["longitude"]
            location_name = f"{result.get('name', location)}, {result.get('country', '')}"

            # Get current weather from Open-Meteo
            weather_url = "https://api.open-meteo.com/v1/forecast"

            # Determine temperature unit
            temp_unit = "fahrenheit" if unit.lower() == "fahrenheit" else "celsius"

            weather_params = {
                "latitude": latitude,
                "longitude": longitude,
                "current": "temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m",
                "temperature_unit": temp_unit,
                "timezone": "auto"
            }

            weather_response = requests.get(weather_url, params=weather_params, timeout=5)
            weather_data = weather_response.json()

            if "current" not in weather_data:
                return {
                    "location": location_name,
                    "error": "Weather data not available",
                    "timestamp": datetime.now().isoformat()
                }

            current = weather_data["current"]

            # Map weather codes to conditions
            weather_codes = {
                0: "clear sky",
                1: "mainly clear", 2: "partly cloudy", 3: "overcast",
                45: "foggy", 48: "foggy",
                51: "light drizzle", 53: "moderate drizzle", 55: "dense drizzle",
                61: "light rain", 63: "moderate rain", 65: "heavy rain",
                71: "light snow", 73: "moderate snow", 75: "heavy snow",
                77: "snow grains",
                80: "light rain showers", 81: "moderate rain showers", 82: "heavy rain showers",
                85: "light snow showers", 86: "heavy snow showers",
                95: "thunderstorm", 96: "thunderstorm with light hail", 99: "thunderstorm with heavy hail"
            }

            weather_code = current.get("weather_code", 0)
            conditions = weather_codes.get(weather_code, "unknown")

            unit_symbol = "°F" if unit.lower() == "fahrenheit" else "°C"

            return {
                "location": location_name,
                "temperature": round(current["temperature_2m"], 1),
                "unit": unit_symbol,
                "conditions": conditions,
                "humidity": current.get("relative_humidity_2m"),
                "wind_speed": round(current.get("wind_speed_10m", 0), 1),
                "wind_unit": "km/h",
                "coordinates": {"latitude": latitude, "longitude": longitude},
                "timestamp": current.get("time", datetime.now().isoformat()),
                "source": "Open-Meteo"
            }

        except requests.RequestException as e:
            # Fallback to simulated data if API fails
            import logging
            logging.warning(f"Open-Meteo API error: {e}. Using simulated data.")

            # Simulated fallback
            base_temp = 20 + random.uniform(-10, 10)

            if unit == "fahrenheit":
                temp = base_temp * 9/5 + 32
                unit_symbol = "°F"
            else:
                temp = base_temp
                unit_symbol = "°C"

            return {
                "location": location,
                "temperature": round(temp, 1),
                "unit": unit_symbol,
                "conditions": random.choice(["sunny", "cloudy", "partly cloudy", "rainy"]),
                "timestamp": datetime.now().isoformat(),
                "note": "Simulated data (API unavailable)"
            }
        except Exception as e:
            return {
                "location": location,
                "error": str(e),
                "timestamp": datetime.now().isoformat()
            }

    @staticmethod
    def get_current_time(timezone: str = "UTC") -> Dict:
        """
        Get current date and time in specified timezone using zoneinfo (Python 3.9+)
        """
        from datetime import datetime
        from zoneinfo import ZoneInfo

        # Common abbreviation mappings to IANA timezone names
        timezone_aliases = {
            "EST": "America/New_York",
            "EDT": "America/New_York",
            "PST": "America/Los_Angeles",
            "PDT": "America/Los_Angeles",
            "CST": "America/Chicago",
            "CDT": "America/Chicago",
            "MST": "America/Denver",
            "MDT": "America/Denver",
            "GMT": "Europe/London",
            "BST": "Europe/London",
            "CET": "Europe/Paris",
            "CEST": "Europe/Paris",
            "JST": "Asia/Tokyo",
            "IST": "Asia/Kolkata",
            "AEST": "Australia/Sydney",
            "AEDT": "Australia/Sydney",
            "SGT": "Asia/Singapore",
            "HKT": "Asia/Hong_Kong",
            "UTC+1": "Etc/GMT-1",  # Note: signs are inverted in Etc/GMT
            "UTC-1": "Etc/GMT+1",
            "UTC+8": "Etc/GMT-8",
            "UTC-8": "Etc/GMT+8"
        }

        # Convert abbreviation to IANA name if needed
        tz_name = timezone_aliases.get(timezone.upper(), timezone)

        try:
            tz = ZoneInfo(tz_name)
            current_time = datetime.now(tz)

            return {
                "timezone": tz_name,
                "datetime": current_time.strftime("%Y-%m-%d %H:%M:%S"),
                "date": current_time.strftime("%Y-%m-%d"),
                "time": current_time.strftime("%H:%M:%S"),
                "day_of_week": current_time.strftime("%A"),
                "utc_offset": current_time.strftime("%z"),
                "timestamp": current_time.isoformat()
            }
        except Exception as e:
            # Fallback to UTC if timezone not found
            try:
                tz_utc = ZoneInfo("UTC")
                current_time = datetime.now(tz_utc)
                return {
                    "timezone": "UTC",
                    "datetime": current_time.strftime("%Y-%m-%d %H:%M:%S"),
                    "date": current_time.strftime("%Y-%m-%d"),
                    "time": current_time.strftime("%H:%M:%S"),
                    "day_of_week": current_time.strftime("%A"),
                    "utc_offset": "+0000",
                    "timestamp": current_time.isoformat(),
                    "note": f"Invalid timezone '{timezone}', using UTC as fallback"
                }
            except Exception as fallback_error:
                return {
                    "error": str(e),
                    "fallback_error": str(fallback_error),
                    "timezone": timezone,
                    "timestamp": datetime.utcnow().isoformat()
                }

    @staticmethod
    def convert_currency(amount: float, from_currency: str, to_currency: str) -> Dict:
        """
        Convert currency using live exchange rates (simulated)
        """
        # Normalize currency codes
        from_currency = from_currency.upper().replace("S$", "SGD").replace("$", "USD")
        to_currency = to_currency.upper().replace("S$", "SGD").replace("$", "USD")

        # Simulated exchange rates
        exchange_rates = {
            "USD": 1.0,
            "EUR": 0.92,
            "GBP": 0.79,
            "JPY": 149.50,
            "CNY": 7.24,
            "CAD": 1.36,
            "AUD": 1.53,
            "CHF": 0.88,
            "INR": 83.12,
            "SGD": 1.34,
            "KRW": 1330.50,
            "MXN": 17.10
        }

        if from_currency not in exchange_rates or to_currency not in exchange_rates:
            return {"error": f"Unsupported currency: {from_currency} or {to_currency}"}

        # Convert to USD first, then to target currency
        usd_amount = amount / exchange_rates[from_currency]
        converted_amount = usd_amount * exchange_rates[to_currency]

        return {
            "original_amount": amount,
            "from_currency": from_currency,
            "to_currency": to_currency,
            "converted_amount": round(converted_amount, 2),
            "exchange_rate": round(exchange_rates[to_currency] / exchange_rates[from_currency], 4),
            "timestamp": datetime.now().isoformat()
        }

    @staticmethod
    def parse_pdf(url: str) -> Dict:
        """
        Parse a PDF document from URL or local file
        """
        try:
            # Check if it's a local file
            if url.startswith('file://') or url.startswith('/') or url.startswith('./'):
                # Local file
                file_path = url.replace('file://', '')
                with open(file_path, 'rb') as f:
                    pdf_content = f.read()
            else:
                # Remote URL
                response = requests.get(url, timeout=30)
                response.raise_for_status()
                pdf_content = response.content

            # Parse PDF
            pdf_file = BytesIO(pdf_content)
            pdf_reader = PyPDF2.PdfReader(pdf_file)

            text_content = []
            for page_num, page in enumerate(pdf_reader.pages, 1):
                text = page.extract_text()
                text_content.append({
                    "page": page_num,
                    "text": text[:1000]  # Limit text per page
                })

            return {
                "url": url,
                "num_pages": len(pdf_reader.pages),
                "content": text_content[:5],  # Limit to first 5 pages
                "success": True
            }
        except Exception as e:
            return {"error": str(e), "success": False}

    @staticmethod
    def code_interpreter(code: str) -> Dict:
        """
        Execute Python code in a full Python environment.
        This provides unrestricted access to Python's built-in functions and standard library.
        """
        try:
            # Strip markdown code blocks and other formatting
            import re

            # Remove ```python or ```py or ``` blocks
            code = re.sub(r'^```(?:python|py)?\s*\n', '', code.strip())
            code = re.sub(r'\n```\s*$', '', code)
            code = re.sub(r'^```\s*', '', code)
            code = re.sub(r'\s*```$', '', code)

            # Also strip any leading/trailing whitespace
            code = code.strip()

            # Convert common mathematical notation to Python syntax
            # Replace ^ with ** for exponentiation
            code = re.sub(r'\^', '**', code)

            # Create a full Python namespace with all builtins available
            # This gives the agent access to the complete Python environment
            import sys
            namespace = {
                '__builtins__': __builtins__,
                'math': math,
                'random': random,
                'datetime': datetime,
                'sys': sys,
                're': re,
                'json': json
            }

            # Capture both stdout and stderr
            output_buffer = io.StringIO()
            error_buffer = io.StringIO()

            with contextlib.redirect_stdout(output_buffer), contextlib.redirect_stderr(error_buffer):
                exec(code, namespace)

            # Get output and any error messages
            printed_output = output_buffer.getvalue()
            error_output = error_buffer.getvalue()

            # Try to get result from common variable names
            result = namespace.get('result', None)
            if result is None:
                for var_name in ['A', 'total', 'sum', 'output', 'answer', 'final', 'value']:
                    if var_name in namespace:
                        result = namespace[var_name]
                        break

            response = {
                "result": result,
                "output": printed_output if printed_output else None,
                "stderr": error_output if error_output else None,
                "success": True
            }

            return response

        except SyntaxError as e:
            error_msg = f"Syntax Error on line {e.lineno}: {e.msg}\n{e.text}"
            return {
                "error": error_msg,
                "error_type": "SyntaxError",
                "success": False
            }
        except Exception as e:
            import traceback
            error_trace = traceback.format_exc()
            return {
                "error": str(e),
                "error_type": type(e).__name__,
                "traceback": error_trace,
                "success": False
            }

def format_tool_response(tool_name: str, tool_result: str) -> Dict:
    """Format tool response for the chat model"""
    return {
        "role": "tool",
        "name": tool_name,
        "content": tool_result
    }

setup.sh

#!/bin/bash

# vLLM Tool Calling Demo - Setup Script
# This script helps set up the environment for the demo

echo "======================================"
echo "vLLM Tool Calling Demo Setup"
echo "======================================"

# Check Python version
echo -e "\n1. Checking Python version..."
python_version=$(python3 --version 2>&1 | grep -Po '(?<=Python )\d+\.\d+')
required_version="3.8"

if [ "$(printf '%s\n' "$required_version" "$python_version" | sort -V | head -n1)" = "$required_version" ]; then 
    echo "✅ Python $python_version is installed (>= $required_version required)"
else
    echo "❌ Python $python_version is too old. Please install Python >= $required_version"
    exit 1
fi

# Check CUDA availability
echo -e "\n2. Checking CUDA availability..."
if command -v nvidia-smi &> /dev/null; then
    echo "✅ NVIDIA GPU detected:"
    nvidia-smi --query-gpu=name,memory.total --format=csv,noheader
else
    echo "⚠️  No NVIDIA GPU detected. vLLM requires a CUDA-capable GPU."
    echo "   You can still install dependencies but won't be able to run the model locally."
fi

# Create virtual environment
echo -e "\n3. Setting up Python virtual environment..."
if [ ! -d "venv" ]; then
    python3 -m venv venv
    echo "✅ Virtual environment created"
else
    echo "✅ Virtual environment already exists"
fi

# Activate virtual environment
source venv/bin/activate

# Upgrade pip
echo -e "\n4. Upgrading pip..."
pip install --upgrade pip

# Install requirements
echo -e "\n5. Installing requirements..."
pip install -r requirements.txt

# Check PyTorch CUDA
echo -e "\n6. Checking PyTorch CUDA support..."
python3 -c "import torch; print('✅ PyTorch CUDA available' if torch.cuda.is_available() else '❌ PyTorch CUDA not available')"

# Create .env file if it doesn't exist
echo -e "\n7. Setting up environment configuration..."
if [ ! -f ".env" ]; then
    cp env.example .env
    echo "✅ Created .env file from template"
else
    echo "✅ .env file already exists"
fi

# Create logs directory
echo -e "\n8. Creating logs directory..."
mkdir -p logs
echo "✅ Logs directory created"

# Optional: Install ModelScope for downloading from Chinese mirror
echo -e "\n9. Optional packages..."
read -p "Install ModelScope for downloading models from Chinese mirror? (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
    pip install modelscope
    echo "✅ ModelScope installed"
fi

echo -e "\n======================================"
echo "Setup Complete!"
echo "======================================"
echo ""
echo "Next steps:"
echo "1. Activate the virtual environment: source venv/bin/activate"
echo "2. Check system compatibility: python check_compatibility.py"
echo "3. Run the main script: python main.py"
echo ""
echo "The script will automatically detect your platform and use:"
echo "  - vLLM if you have an NVIDIA GPU"
echo "  - Ollama on Mac or systems without GPU"
echo ""
echo "For more information, see README.md"