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:
+56
-15
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user