feat(core-ai): add OllamaNativeAgent with native Ollama tool calling

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 <noreply@anthropic.com>
This commit is contained in:
2025-12-02 18:35:16 +01:00
co-authored by Claude
parent 5368496f6f
commit a6249e6cd0
4 changed files with 360 additions and 15 deletions
+56 -15
View File
@@ -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={
+1
View File
@@ -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
+6
View File
@@ -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',
@@ -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)