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
+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)