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={