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:
@@ -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