feat: aggiunge OllamaToolAgent con tool calling
- Nuovo modulo ollama_agent.py con parsing delle risposte - Supporta chiamate tool per provider senza bind_tools - Tool built-in: execute, read_file, write_file, ls, search, system_info - Il modello risponde con [TOOL_CALL] che noi parsiamo ed eseguiamo - Integrato in main.py per uso automatico con Ollama
This commit is contained in:
1 parent
2077618f52
commit
a309c7e27c
2 files changed
+462
-11
No files matched your search
@@ -414,19 +414,52 @@ def create_langcode_agent(system_prompt: Optional[str] = None, use_simple_agent:
|
||||
"""
|
||||
Create and return an agent instance with configurable model.
|
||||
|
||||
For Ollama and other providers without full tool-calling support,
|
||||
uses a simple chat agent instead of deepagents.
|
||||
For Ollama uses custom OllamaToolAgent with tool calling support.
|
||||
For other providers with full tool support uses deepagents.
|
||||
"""
|
||||
# Detect if we need simple agent (for Ollama without tool support)
|
||||
if not use_simple_agent and CONFIG_AVAILABLE:
|
||||
# Try to get config info for Ollama detection
|
||||
is_ollama = False
|
||||
if CONFIG_AVAILABLE:
|
||||
active_name = config_manager.get_active_configuration()
|
||||
if active_name:
|
||||
config = config_manager.get_configuration(active_name)
|
||||
if config and config.get("provider") == "ollama":
|
||||
# Ollama doesn't fully support bind_tools in deepagents
|
||||
# Always use simple agent for Ollama
|
||||
print("⚠️ Using simple chat agent (Ollama detected)")
|
||||
use_simple_agent = True
|
||||
is_ollama = True
|
||||
|
||||
# Use OllamaToolAgent for Ollama with tool calling
|
||||
if is_ollama and not use_simple_agent:
|
||||
try:
|
||||
from ollama_agent import create_ollama_tool_agent
|
||||
model = load_model_from_config()
|
||||
custom_tools = get_custom_tools()
|
||||
|
||||
# Enhance with memory and planning tools if available
|
||||
if ENHANCEMENTS_AVAILABLE:
|
||||
try:
|
||||
memory_tools = create_simple_memory_tools(simple_memory)
|
||||
custom_tools.extend(memory_tools)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
planning_tools = enhanced_planner.create_planning_tools()
|
||||
custom_tools.extend(planning_tools)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("🔧 Using OllamaToolAgent with tool calling")
|
||||
return create_ollama_tool_agent(
|
||||
model=model,
|
||||
tools=custom_tools,
|
||||
system_prompt=system_prompt,
|
||||
include_builtin_tools=True
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not load OllamaToolAgent: {e}")
|
||||
print("⚠️ Falling back to simple agent")
|
||||
use_simple_agent = True
|
||||
elif is_ollama:
|
||||
print("⚠️ Using simple chat agent (Ollama detected)")
|
||||
use_simple_agent = True
|
||||
|
||||
if use_simple_agent:
|
||||
return create_simple_agent(system_prompt)
|
||||
@@ -1099,9 +1132,17 @@ def main():
|
||||
# Otherwise, treat it as a chat message
|
||||
try:
|
||||
result = agent.invoke({"messages": [HumanMessage(content=user_input)]})
|
||||
# Extract the AI's response
|
||||
ai_message = result["messages"][-1]
|
||||
print(f"\n🤖 LangCode: {ai_message.content}")
|
||||
|
||||
# Extract the AI's response (works for both simple and tool agent)
|
||||
if "response" in result:
|
||||
# OllamaToolAgent returns a separate response field
|
||||
response_text = result["response"]
|
||||
else:
|
||||
# Simple agent returns the last message
|
||||
ai_message = result["messages"][-1]
|
||||
response_text = ai_message.content if hasattr(ai_message, 'content') else str(ai_message)
|
||||
|
||||
print(f"\n🤖 LangCode: {response_text}")
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if not error_msg:
|
||||
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
Ollama Agent with Tool Calling Support
|
||||
|
||||
Questo modulo implementa un agente che supporta tool calling per Ollama
|
||||
usando un approccio di parsing delle risposte invece di bind_tools.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
import subprocess
|
||||
from typing import List, Dict, Any, Optional, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage, BaseMessage
|
||||
from langchain_core.tools import Tool
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TOOL CALL PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
TOOL_CALL_PATTERN = r'\[TOOL_CALL\]\s*(.*?)\s*\[\/TOOL_CALL\]'
|
||||
TOOL_RESULT_PATTERN = r'\[TOOL_RESULT\]\s*(.*?)\s*\[\/TOOL_RESULT\]'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BASIC BUILT-IN TOOLS
|
||||
# =============================================================================
|
||||
|
||||
def get_builtin_tools() -> List[Tool]:
|
||||
"""
|
||||
Restituisce i tool built-in disponibili per l'agente.
|
||||
"""
|
||||
tools = []
|
||||
|
||||
# Tool: Esegui comando shell
|
||||
def execute_command(command: str) -> str:
|
||||
"""
|
||||
Execute a shell command and return the output.
|
||||
USE WITH CAUTION - only for safe commands.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
output = []
|
||||
if result.stdout:
|
||||
output.append(f"STDOUT:\n{result.stdout}")
|
||||
if result.stderr:
|
||||
output.append(f"STDERR:\n{result.stderr}")
|
||||
if result.returncode != 0:
|
||||
output.append(f"Exit code: {result.returncode}")
|
||||
return "\n".join(output) if output else "Command executed with no output"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Command timed out after 30 seconds"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
tools.append(Tool(
|
||||
name="execute",
|
||||
description="Execute a shell command. Use this for file operations, running scripts, git commands, etc. Input is the command string to execute.",
|
||||
func=execute_command
|
||||
))
|
||||
|
||||
# Tool: Leggi file
|
||||
def read_file(path: str) -> str:
|
||||
"""
|
||||
Read the contents of a file.
|
||||
"""
|
||||
try:
|
||||
file_path = Path(path).expanduser()
|
||||
if not file_path.exists():
|
||||
return f"Error: File '{path}' not found"
|
||||
if file_path.is_dir():
|
||||
return f"Error: '{path}' is a directory, not a file"
|
||||
return file_path.read_text(encoding='utf-8')
|
||||
except Exception as e:
|
||||
return f"Error reading file: {str(e)}"
|
||||
|
||||
tools.append(Tool(
|
||||
name="read_file",
|
||||
description="Read the contents of a file. Input is the file path (can use ~ for home directory).",
|
||||
func=read_file
|
||||
))
|
||||
|
||||
# Tool: Scrivi file
|
||||
def write_file(path: str, content: str) -> str:
|
||||
"""
|
||||
Write content to a file. Creates the file if it doesn't exist.
|
||||
WARNING: This will overwrite existing files!
|
||||
"""
|
||||
try:
|
||||
file_path = Path(path).expanduser()
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content, encoding='utf-8')
|
||||
return f"Successfully wrote to {path}"
|
||||
except Exception as e:
|
||||
return f"Error writing file: {str(e)}"
|
||||
|
||||
tools.append(Tool(
|
||||
name="write_file",
|
||||
description="Write content to a file. Inputs: 'path' (file path) and 'content' (text to write). WARNING: overwrites existing files!",
|
||||
func=write_file
|
||||
))
|
||||
|
||||
# Tool: Lista file
|
||||
def list_directory(path: str = ".") -> str:
|
||||
"""
|
||||
List files in a directory.
|
||||
"""
|
||||
try:
|
||||
dir_path = Path(path).expanduser()
|
||||
if not dir_path.exists():
|
||||
return f"Error: Directory '{path}' not found"
|
||||
if not dir_path.is_dir():
|
||||
return f"Error: '{path}' is not a directory"
|
||||
|
||||
items = []
|
||||
for item in sorted(dir_path.iterdir()):
|
||||
item_type = "DIR" if item.is_dir() else "FILE"
|
||||
items.append(f"{item_type}: {item.name}")
|
||||
return "\n".join(items) if items else "Directory is empty"
|
||||
except Exception as e:
|
||||
return f"Error listing directory: {str(e)}"
|
||||
|
||||
tools.append(Tool(
|
||||
name="ls",
|
||||
description="List files in a directory. Input is the directory path (default: current directory).",
|
||||
func=list_directory
|
||||
))
|
||||
|
||||
# Tool: Cerca file
|
||||
def search_files(directory: str = ".", pattern: str = "*") -> str:
|
||||
"""
|
||||
Search for files matching a pattern in a directory.
|
||||
"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
dir_path = Path(directory).expanduser()
|
||||
if not dir_path.exists():
|
||||
return f"Error: Directory '{directory}' not found"
|
||||
|
||||
matches = list(dir_path.rglob(pattern))
|
||||
if not matches:
|
||||
return f"No files matching '{pattern}' found in {directory}"
|
||||
|
||||
results = []
|
||||
for match in matches[:50]: # Limit to 50 results
|
||||
results.append(str(match))
|
||||
|
||||
return f"Found {len(matches)} matches (showing first 50):\n" + "\n".join(results)
|
||||
except Exception as e:
|
||||
return f"Error searching files: {str(e)}"
|
||||
|
||||
tools.append(Tool(
|
||||
name="search",
|
||||
description="Search for files by name pattern. Inputs: 'directory' (where to search) and 'pattern' (e.g., '*.py', '*.md').",
|
||||
func=search_files
|
||||
))
|
||||
|
||||
# Tool: Info sistema
|
||||
def system_info() -> str:
|
||||
"""
|
||||
Get system information (OS, Python version, current directory).
|
||||
"""
|
||||
import platform
|
||||
import os
|
||||
info = []
|
||||
info.append(f"OS: {platform.system()} {platform.release()}")
|
||||
info.append(f"Python: {platform.python_version()}")
|
||||
info.append(f"Current dir: {os.getcwd()}")
|
||||
info.append(f"User: {os.getenv('USER', 'unknown')}")
|
||||
return "\n".join(info)
|
||||
|
||||
tools.append(Tool(
|
||||
name="system_info",
|
||||
description="Get system information (OS, Python version, current directory). No input required.",
|
||||
func=system_info
|
||||
))
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCall:
|
||||
"""Rappresenta una chiamata a tool"""
|
||||
id: str
|
||||
name: str
|
||||
arguments: Dict[str, Any]
|
||||
|
||||
|
||||
class OllamaToolAgent:
|
||||
"""
|
||||
Agente che supporta tool calling per Ollama e provider simili.
|
||||
|
||||
Strategia: Il modello risponde con marcatori speciali per le chiamate tool.
|
||||
Noi parsiamo, eseguiamo, e riportiamo i risultati.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm,
|
||||
tools: List[Tool],
|
||||
system_prompt: Optional[str] = None,
|
||||
max_iterations: int = 10
|
||||
):
|
||||
self.llm = llm
|
||||
self.tools = {tool.name: tool for tool in tools}
|
||||
self.max_iterations = max_iterations
|
||||
|
||||
# Costruisci il system prompt con la lista dei tool
|
||||
if system_prompt is None:
|
||||
system_prompt = self._default_system_prompt()
|
||||
else:
|
||||
system_prompt = system_prompt + "\n\n" + self._tool_instructions()
|
||||
|
||||
self.messages: List[BaseMessage] = [SystemMessage(content=system_prompt)]
|
||||
|
||||
# Tool per la memoria
|
||||
self.short_term_memory: List[Dict] = []
|
||||
|
||||
def _default_system_prompt(self) -> str:
|
||||
"""System prompt di default"""
|
||||
return """You are LangCode, a helpful AI assistant with access to tools.
|
||||
|
||||
When you need to use a tool, you MUST respond with this EXACT format:
|
||||
|
||||
[TOOL_CALL]
|
||||
{"tool": "tool_name", "arguments": {"arg1": "value1", "arg2": "value2"}}
|
||||
[/TOOL_CALL]
|
||||
|
||||
After receiving the tool result, continue your response normally.
|
||||
|
||||
Available tools:"""
|
||||
|
||||
def _tool_instructions(self) -> str:
|
||||
"""Genera le istruzioni per i tool disponibili"""
|
||||
instructions = []
|
||||
instructions.append("\n\n# AVAILABLE TOOLS\n")
|
||||
instructions.append("You have access to the following tools:\n\n")
|
||||
|
||||
for name, tool in self.tools.items():
|
||||
# Estrai la descrizione dal docstring del tool
|
||||
desc = tool.description or "No description"
|
||||
# Pulisci la descrizione da caratteri speciali
|
||||
desc = desc.replace('"', '\\"').replace('\n', ' ')
|
||||
instructions.append(f"## {name}\n")
|
||||
instructions.append(f"Description: {desc}\n")
|
||||
|
||||
# Parsa i parametri se presenti
|
||||
if hasattr(tool, 'args_schema') and tool.args_schema:
|
||||
instructions.append("Parameters:\n")
|
||||
schema = tool.args_schema
|
||||
# Handle both dict and BaseModel schemas
|
||||
try:
|
||||
model_json_schema = getattr(schema, 'model_json_schema', None) # type: ignore
|
||||
if callable(model_json_schema):
|
||||
schema = model_json_schema()
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(schema, dict) and 'properties' in schema:
|
||||
for param_name, param_info in schema['properties'].items():
|
||||
param_type = param_info.get('type', 'any')
|
||||
param_desc = param_info.get('description', '')
|
||||
required = param_name in schema.get('required', [])
|
||||
req_marker = "(required)" if required else "(optional)"
|
||||
instructions.append(f" - {param_name}: {param_type} {req_marker} - {param_desc}\n")
|
||||
|
||||
instructions.append("\n")
|
||||
|
||||
instructions.append("\n# IMPORTANT RULES:\n")
|
||||
instructions.append("1. When you need to use a tool, respond ONLY with [TOOL_CALL] blocks\n")
|
||||
instructions.append("2. After receiving the result, continue your response\n")
|
||||
instructions.append("3. For simple questions, respond directly without tools\n")
|
||||
instructions.append("4. Use valid JSON in tool calls\n")
|
||||
instructions.append("5. If a tool fails, explain the error to the user\n")
|
||||
|
||||
return ''.join(instructions)
|
||||
|
||||
def _parse_tool_calls(self, text: str) -> List[ToolCall]:
|
||||
"""Parse le chiamate tool dalla risposta del modello"""
|
||||
tool_calls = []
|
||||
matches = re.finditer(TOOL_CALL_PATTERN, text, re.DOTALL)
|
||||
|
||||
for match in matches:
|
||||
try:
|
||||
data = json.loads(match.group(1))
|
||||
tool_calls.append(ToolCall(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
name=data.get('tool'),
|
||||
arguments=data.get('arguments', {})
|
||||
))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return tool_calls
|
||||
|
||||
def _execute_tool(self, tool_call: ToolCall) -> Any:
|
||||
"""Esegui un singolo tool"""
|
||||
if tool_call.name not in self.tools:
|
||||
return {"error": f"Tool '{tool_call.name}' not found"}
|
||||
|
||||
try:
|
||||
tool = self.tools[tool_call.name]
|
||||
result = tool.invoke(tool_call.arguments)
|
||||
|
||||
# Converti result in stringa per il modello
|
||||
if result is None:
|
||||
return {"result": "Tool executed successfully"}
|
||||
elif isinstance(result, (dict, list)):
|
||||
return {"result": json.dumps(result, ensure_ascii=False, indent=2)}
|
||||
else:
|
||||
return {"result": str(result)}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def _format_tool_result(self, tool_call: ToolCall, result: Any) -> str:
|
||||
"""Formatta il risultato di un tool per il modello"""
|
||||
return f"\n[TOOL_RESULT]\nTool: {tool_call.name}\nResult: {json.dumps(result, ensure_ascii=False)}\n[/TOOL_RESULT]\n"
|
||||
|
||||
def invoke(self, input_dict: Dict) -> Dict:
|
||||
"""
|
||||
Invoca l'agente con un input.
|
||||
|
||||
L'agente può fare multiple iterazioni tool-call prima di rispondere.
|
||||
"""
|
||||
# Estrai il messaggio utente
|
||||
user_message = input_dict.get("messages", [])[-1]
|
||||
if hasattr(user_message, 'content'):
|
||||
user_content = user_message.content
|
||||
else:
|
||||
user_content = str(user_message)
|
||||
|
||||
# Aggiungi il messaggio utente alla cronologia
|
||||
self.messages.append(HumanMessage(content=user_content))
|
||||
|
||||
# Loop principale: risposta -> parse tools -> esegui -> ripeti
|
||||
iteration = 0
|
||||
final_response = None
|
||||
|
||||
while iteration < self.max_iterations:
|
||||
iteration += 1
|
||||
|
||||
# Genera risposta dal modello
|
||||
response = self.llm.invoke(self.messages)
|
||||
self.messages.append(response)
|
||||
|
||||
response_text = response.content if hasattr(response, 'content') else str(response)
|
||||
|
||||
# Parse le chiamate tool
|
||||
tool_calls = self._parse_tool_calls(response_text)
|
||||
|
||||
if not tool_calls:
|
||||
# Nessuna chiamata tool, questa è la risposta finale
|
||||
final_response = response_text
|
||||
break
|
||||
|
||||
# Esegui i tool e aggiungi i risultati
|
||||
for tool_call in tool_calls:
|
||||
result = self._execute_tool(tool_call)
|
||||
tool_result_text = self._format_tool_result(tool_call, result)
|
||||
self.messages.append(HumanMessage(content=tool_result_text))
|
||||
|
||||
# Se non abbiamo una risposta (troppe iterazioni)
|
||||
if final_response is None:
|
||||
final_response = "Ho raggiunto il numero massimo di iterazioni. Qualcosa potrebbe essere andato storto."
|
||||
|
||||
return {"messages": self.messages, "response": final_response}
|
||||
|
||||
def clear_history(self):
|
||||
"""Pulisci la cronologia ma mantieni il system prompt"""
|
||||
system_msg = self.messages[0] if self.messages and isinstance(self.messages[0], SystemMessage) else None
|
||||
self.messages = [system_msg] if system_msg else []
|
||||
|
||||
|
||||
def create_ollama_tool_agent(
|
||||
model,
|
||||
tools: Optional[List[Tool]] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
include_builtin_tools: bool = True
|
||||
) -> OllamaToolAgent:
|
||||
"""
|
||||
Factory function per creare un OllamaToolAgent.
|
||||
|
||||
Args:
|
||||
model: Il modello LLM da usare
|
||||
tools: Lista di tool personalizzati (opzionale)
|
||||
system_prompt: System prompt personalizzato (opzionale)
|
||||
include_builtin_tools: Se True, include i tool built-in (execute, read_file, etc.)
|
||||
"""
|
||||
if tools is None:
|
||||
tools = []
|
||||
|
||||
# Aggiungi i tool built-in
|
||||
if include_builtin_tools:
|
||||
builtin = get_builtin_tools()
|
||||
tools = builtin + tools
|
||||
|
||||
return OllamaToolAgent(
|
||||
llm=model,
|
||||
tools=tools,
|
||||
system_prompt=system_prompt
|
||||
)
|
||||
Reference in new issue
Block a user