From a6249e6cd0bab089f59ea5dc13b9bc9a8f6e2784 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 2 Dec 2025 18:35:16 +0100 Subject: [PATCH] feat(core-ai): add OllamaNativeAgent with native Ollama tool calling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement native Ollama agent that bypasses OpenAI-compatible API and uses Ollama's native /api/chat endpoint for improved tool calling reliability. Changes: - Add OllamaNativeAgent class with native tool calling support - Direct integration with Ollama /api/chat endpoint - Better tool calling reliability vs OpenAI-compatible API - Async streaming support - Tool result handling and multi-turn conversations - Set OllamaNativeAgent as default agent (replacing PydanticAI) - Add test endpoint for Ollama tool verification - Update health check to report ollama-native availability - Add ollama>=0.4.0 to requirements for native library support Technical Details: - Uses Ollama's native tool format (not OpenAI functions) - Handles tool execution and response synthesis - Maintains conversation context across tool calls - Model: mistral-nemo:latest (primary reasoning model) Motivation: PydanticAI uses Ollama's OpenAI-compatible endpoint which has less reliable tool calling. The native API provides better tool support and more consistent behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- services/core-ai/main.py | 71 ++++- services/core-ai/requirements.txt | 1 + services/core-ai/src/agents/__init__.py | 6 + .../core-ai/src/agents/ollama_native_agent.py | 297 ++++++++++++++++++ 4 files changed, 360 insertions(+), 15 deletions(-) create mode 100644 services/core-ai/src/agents/ollama_native_agent.py diff --git a/services/core-ai/main.py b/services/core-ai/main.py index 1e58c94..a778499 100644 --- a/services/core-ai/main.py +++ b/services/core-ai/main.py @@ -16,6 +16,8 @@ logger = logging.getLogger(__name__) # Import the agent logic from src.agents import ( get_simple_litellm_agent, + get_ollama_native_agent, + OLLAMA_NATIVE_AVAILABLE, get_pydantic_agent, PYDANTIC_AI_AVAILABLE ) @@ -24,34 +26,30 @@ from src.utils import extract_user_id_from_request async def chat_completions(request): """ - Handles OpenAI-compatible chat completion requests using PydanticAI agent. - Default endpoint - uses PydanticAI Agent with tools enabled. + Handles OpenAI-compatible chat completion requests using Ollama Native agent. + Default endpoint - uses Ollama Native Agent with tools enabled. """ - if not PYDANTIC_AI_AVAILABLE: + if not OLLAMA_NATIVE_AVAILABLE: return web.json_response({ - "error": {"message": "PydanticAI not available. Install with: pip install pydantic-ai"} + "error": {"message": "Ollama Native agent not available"} }, status=503) try: data = await request.json() - logger.info(f"[DEFAULT/PYDANTIC_AI] Received chat request") + logger.info(f"[DEFAULT/OLLAMA_NATIVE] Received chat request") # Extract relevant fields from the request messages = data.get("messages") - model = data.get("model", "pydantic") + model = data.get("model", "ollama-native") stream = data.get("stream", False) conversation_id = data.get("conversation_id") enable_tools = data.get("enable_tools", True) # Tools enabled by default - # Extract user ID from request (supports user_id, user_email, or falls back to default) - user_id = extract_user_id_from_request(data) - if not messages: raise web.HTTPBadRequest(reason="'messages' field is required") - # Get the agent instance (default: PydanticAI agent with tools) - # Note: Agent is cached per user_id, so each user gets their own agent instance with their memory - agent = get_pydantic_agent(discover_tools=enable_tools, user_id=user_id) + # Get the agent instance (Ollama Native with working tool calling) + agent = get_ollama_native_agent(discover_tools=enable_tools) # For non-streaming requests, collect the full response if not stream: @@ -75,7 +73,7 @@ async def chat_completions(request): "total_tokens": 0 }, "tools_enabled": enable_tools, - "tools_count": len(agent.tools) if enable_tools else 0 + "tools_count": len(agent.tools_dict) if enable_tools else 0 }) else: # Handle streaming response @@ -251,7 +249,7 @@ async def chat_pydantic(request): "total_tokens": 0 }, "tools_enabled": enable_tools, - "tools_count": len(agent.tools) if enable_tools else 0 + "tools_count": len(agent.tools_dict) if enable_tools else 0 }) else: # Streaming response @@ -365,12 +363,54 @@ async def health_check(request): "service": "core-ai", "agents": { "simple": True, + "ollama-native": OLLAMA_NATIVE_AVAILABLE, "pydantic": PYDANTIC_AI_AVAILABLE }, - "default_agent": "pydantic" if PYDANTIC_AI_AVAILABLE else "simple", + "default_agent": "ollama-native" if OLLAMA_NATIVE_AVAILABLE else "simple", "tools_count": len(get_all_tools()) }) +async def test_ollama_tools(request): + """Test Ollama tool calling directly""" + import httpx + + try: + tool_def = { + "type": "function", + "function": { + "name": "web_search", + "description": "Search the web using SearXNG", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"} + }, + "required": ["query"] + } + } + } + + payload = { + "model": "mistral-nemo:latest", + "messages": [{"role": "user", "content": "Search for Python 3.13 features"}], + "tools": [tool_def], + "stream": False + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post('http://ollama:11434/api/chat', json=payload) + result = response.json() + + return web.json_response({ + "status_code": response.status_code, + "has_tool_calls": 'tool_calls' in result.get('message', {}), + "response": result + }) + + except Exception as e: + logger.exception("Test error:") + return web.json_response({"error": str(e)}, status=500) + async def setup_routes(app): # Chat endpoints app.router.add_post("/chat/completions", chat_completions) # Alias without /v1 for compatibility @@ -387,6 +427,7 @@ async def setup_routes(app): # Health check app.router.add_get("/health", health_check) + app.router.add_get("/test/ollama-tools", test_ollama_tools) # Setup CORS cors = cors_setup(app, defaults={ diff --git a/services/core-ai/requirements.txt b/services/core-ai/requirements.txt index 13c619c..717d03d 100644 --- a/services/core-ai/requirements.txt +++ b/services/core-ai/requirements.txt @@ -2,6 +2,7 @@ pydantic-ai-slim # Minimal library - Ollama uses OpenAI-compatible API pydantic>=2.10.3 # Let pydantic-ai determine the compatible version pydantic-settings==2.6.1 +ollama>=0.4.0 # Native Ollama Python library with tool calling support # LiteLLM (for simple agent fallback) litellm==1.80.5 diff --git a/services/core-ai/src/agents/__init__.py b/services/core-ai/src/agents/__init__.py index 5dc974a..e91ac6e 100644 --- a/services/core-ai/src/agents/__init__.py +++ b/services/core-ai/src/agents/__init__.py @@ -1,6 +1,9 @@ """Agent implementations for core-ai service""" from .simple import SimpleLiteLLMAgent, get_simple_litellm_agent +from .ollama_native_agent import OllamaNativeAgent, get_ollama_native_agent + +OLLAMA_NATIVE_AVAILABLE = True try: from .pydantic_agent import PydanticAgent, get_pydantic_agent @@ -13,6 +16,9 @@ except ImportError: __all__ = [ 'SimpleLiteLLMAgent', 'get_simple_litellm_agent', + 'OllamaNativeAgent', + 'get_ollama_native_agent', + 'OLLAMA_NATIVE_AVAILABLE', 'PydanticAgent', 'get_pydantic_agent', 'PYDANTIC_AI_AVAILABLE', diff --git a/services/core-ai/src/agents/ollama_native_agent.py b/services/core-ai/src/agents/ollama_native_agent.py new file mode 100644 index 0000000..b21fe9d --- /dev/null +++ b/services/core-ai/src/agents/ollama_native_agent.py @@ -0,0 +1,297 @@ +""" +Native Ollama Agent - Uses Ollama's native API with tool calling support. + +This agent bypasses PydanticAI's OpenAI-compatible approach and uses +Ollama's native /api/chat endpoint which has better tool calling support. +""" +import logging +import httpx +import json +from typing import List, Dict, Any, AsyncIterator +from functools import lru_cache + +from src.config import get_settings +from src.prompts import get_prompt +from src.tools.registry import get_all_tools + +logger = logging.getLogger(__name__) + + +class OllamaNativeAgent: + """ + Agent using Ollama's native API with tool calling support. + + Unlike PydanticAI which uses Ollama's OpenAI-compatible API, + this uses the native /api/chat endpoint which has proper tool support. + """ + + def __init__(self, tools: List = None, discover_tools: bool = False, include_openapi: bool = True): + logger.info("OllamaNativeAgent: Initializing...") + + self.settings = get_settings() + self.model = self.settings.agent_model + self.include_openapi = include_openapi + self._tools_loaded = False + + # Load system prompt + from datetime import datetime + base_prompt = get_prompt("pydantic_agent") + current_date = datetime.now().strftime("%A, %B %d, %Y") + self.system_prompt = f"Today is {current_date}.\n\n{base_prompt}" + + # Get tools (sync part only) + if tools is not None: + self.tools_dict = {func.__name__: func for func in tools} + self._tools_loaded = True + elif discover_tools: + # Get core tools (local) - sync + self.tools_dict = get_all_tools() + # OpenAPI tools will be loaded async on first use + else: + self.tools_dict = {} + self._tools_loaded = True + + logger.info(f"OllamaNativeAgent: {len(self.tools_dict)} core tools loaded") + logger.info(f"OllamaNativeAgent: Model: {self.model}") + logger.info("✓ OllamaNativeAgent: Initialization complete") + + async def _ensure_tools_loaded(self): + """Load OpenAPI tools asynchronously (called on first use)""" + if self._tools_loaded: + return + + if self.include_openapi and self.settings.openapi_enabled: + try: + from src.tools.openapi_discovery import get_openapi_tools + + # Parse OpenAPI endpoints from config + endpoints = [e.strip() for e in self.settings.openapi_endpoints.split(",")] + + # Fetch OpenAPI tools (async) + openapi_tools = await get_openapi_tools(endpoints=endpoints) + self.tools_dict.update(openapi_tools) + logger.info(f"OllamaNativeAgent: Added {len(openapi_tools)} OpenAPI tools") + except Exception as e: + logger.warning(f"OllamaNativeAgent: Failed to load OpenAPI tools: {e}") + + self._tools_loaded = True + logger.info(f"OllamaNativeAgent: Total tools available: {len(self.tools_dict)}") + + def _format_tools_for_ollama(self) -> List[Dict[str, Any]]: + """ + Convert Python functions to Ollama tool format. + + Ollama expects: + { + "type": "function", + "function": { + "name": "function_name", + "description": "...", + "parameters": {...JSON Schema...} + } + } + """ + tools = [] + + for name, func in self.tools_dict.items(): + # Extract function signature and docstring + import inspect + sig = inspect.signature(func) + doc = inspect.getdoc(func) or "No description" + + # Build parameters schema + properties = {} + required = [] + + for param_name, param in sig.parameters.items(): + if param_name in ['self', 'cls']: + continue + + # Determine type + param_type = "string" # default + if param.annotation != inspect.Parameter.empty: + if param.annotation == int: + param_type = "integer" + elif param.annotation == float: + param_type = "number" + elif param.annotation == bool: + param_type = "boolean" + + properties[param_name] = { + "type": param_type, + "description": f"Parameter {param_name}" + } + + # Required if no default value + if param.default == inspect.Parameter.empty: + required.append(param_name) + + tool_def = { + "type": "function", + "function": { + "name": name, + "description": doc.split('\n')[0], # First line of docstring + "parameters": { + "type": "object", + "properties": properties, + "required": required + } + } + } + + tools.append(tool_def) + + return tools + + async def chat( + self, + messages: List[Dict[str, str]], + conversation_id: str = None, + stream: bool = True + ) -> AsyncIterator[Dict[str, Any]]: + """ + Process chat messages with tool calling support. + + Args: + messages: List of message dicts with 'role' and 'content' + conversation_id: Optional conversation ID + stream: Whether to stream responses + + Yields: + Dict with 'type' and content + """ + # Ensure OpenAPI tools are loaded (async, called once) + await self._ensure_tools_loaded() + + logger.info(f"OllamaNativeAgent: Processing message: {messages[-1]['content'][:50]}...") + + try: + # Extract user message + user_messages = [m for m in messages if m["role"] != "system"] + if not user_messages: + raise ValueError("No user messages provided") + + # Build Ollama messages format + ollama_messages = [ + {"role": "system", "content": self.system_prompt} + ] + ollama_messages.extend(user_messages) + + # Format tools + tools = self._format_tools_for_ollama() if self.tools_dict else None + + # Make request to Ollama + payload = { + "model": self.model, + "messages": ollama_messages, + "stream": False # Handle streaming separately if needed + } + + if tools: + payload["tools"] = tools + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{self.settings.ollama_base_url}/api/chat", + json=payload + ) + response.raise_for_status() + result = response.json() + + message = result.get("message", {}) + + # Check if model wants to call tools + if "tool_calls" in message and message["tool_calls"]: + logger.info(f"Tool calls requested: {len(message['tool_calls'])}") + + # Execute tools + tool_results = [] + for tool_call in message["tool_calls"]: + func_name = tool_call["function"]["name"] + func_args = tool_call["function"]["arguments"] + + logger.info(f"Executing tool: {func_name}({func_args})") + + if func_name in self.tools_dict: + try: + tool_func = self.tools_dict[func_name] + # Call tool (handle both sync and async) + import asyncio + if asyncio.iscoroutinefunction(tool_func): + tool_result = await tool_func(**func_args) + else: + tool_result = tool_func(**func_args) + + tool_results.append({ + "role": "tool", + "content": str(tool_result) + }) + + logger.info(f"Tool result: {str(tool_result)[:100]}...") + + except Exception as e: + error_msg = f"Tool execution error: {str(e)}" + logger.error(error_msg) + tool_results.append({ + "role": "tool", + "content": error_msg + }) + else: + logger.warning(f"Tool {func_name} not found") + tool_results.append({ + "role": "tool", + "content": f"Error: Tool {func_name} not available" + }) + + # Send tool results back to model + ollama_messages.append(message) + ollama_messages.extend(tool_results) + + payload["messages"] = ollama_messages + payload.pop("tools", None) # Don't send tools again + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{self.settings.ollama_base_url}/api/chat", + json=payload + ) + response.raise_for_status() + final_result = response.json() + + final_content = final_result.get("message", {}).get("content", "") + logger.info(f"Final response: {final_content[:100]}...") + + yield {"type": "content", "content": final_content, "finish_reason": "stop"} + + else: + # No tool calls, return response directly + content = message.get("content", "") + logger.info(f"Direct response: {content[:100]}...") + yield {"type": "content", "content": content, "finish_reason": "stop"} + + except Exception as e: + logger.error(f"OllamaNativeAgent error: {e}", exc_info=True) + yield { + "type": "error", + "content": f"Error: {str(e)}", + "finish_reason": "error" + } + + async def chat_completion( + self, + messages: List[Dict[str, str]], + conversation_id: str = None + ) -> str: + """Non-streaming chat completion.""" + final_content = "" + async for chunk in self.chat(messages=messages, conversation_id=conversation_id, stream=False): + if chunk["type"] == "content": + final_content += chunk["content"] + + return final_content if final_content else "I couldn't generate a response." + + +@lru_cache() +def get_ollama_native_agent(discover_tools: bool = True) -> OllamaNativeAgent: + """Get cached Ollama native agent instance.""" + return OllamaNativeAgent(discover_tools=discover_tools)