Files
langcode_agent/ollama_agent.py
T
opencode b22297a5d4 fix: usa @tool decorator invece di Tool() per i built-in
Il problema era che Tool() senza parametri si lamentava quando invocato con {}. Usando @tool decorator funziona correttamente.
2026-03-23 18:01:40 +01:00

478 lines
18 KiB
Python

"""
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
# =============================================================================
# TOOL CALL PATTERNS
# =============================================================================
# Old format: [TOOL_CALL]...[/TOOL_CALL]
TOOL_CALL_PATTERN_OLD = r'\[TOOL_CALL\]\s*(.*?)\s*\[\/TOOL_CALL\]'
# New format: TOOL:toolname|arg1=val1|arg2=val2
TOOL_CALL_PATTERN_NEW = r'^TOOL:(\w+)(?:\|(.*))?$'
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: Info sistema - usa @tool decorator
@tool
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(system_info)
# Tool: Lista file
@tool
def ls(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(ls)
# Tool: Esegui comando shell
@tool
def execute(command: str) -> str:
"""Execute a shell command. Use this for file operations, running scripts, git commands, etc."""
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(execute)
# Tool: Leggi file
@tool
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(read_file)
# Tool: Scrivi file
@tool
def write_file(path: str, content: str) -> str:
"""Write content to a file. WARNING: overwrites 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(write_file)
# Tool: Cerca file
@tool
def search(directory: str, pattern: str) -> str:
"""Search for files by name pattern."""
try:
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]:
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(search)
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 a helpful AI assistant. When users ask about:
- system info, OS, operating system, what computer: respond with TOOL:system_info
- list files, directory contents, ls: respond with TOOL:ls|path=.
Example: User asks "what OS am I using?" -> You respond: TOOL:system_info
Example: User asks "list files" -> You respond: TOOL:ls|path=.
For other questions, just answer normally without 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 = []
# Try new format first: TOOL:toolname|arg1=val1
lines = text.strip().split('\n')
for line in lines:
line = line.strip()
if line.startswith('TOOL:'):
# Parse: TOOL:toolname|arg1=val1|arg2=val2
match = re.match(r'^TOOL:(\w+)(?:\|(.+))?$', line)
if match:
tool_name = match.group(1)
if tool_name in self.tools:
args = {}
if match.group(2):
# Parse arguments: arg1=val1|arg2=val2
for arg_part in match.group(2).split('|'):
if '=' in arg_part:
key, val = arg_part.split('=', 1)
args[key] = val
tool_calls.append(ToolCall(
id=str(uuid.uuid4())[:8],
name=tool_name,
arguments=args
))
# If we found a valid tool call, return only that one
return tool_calls
# Try old format: [TOOL_CALL]{...}[/TOOL_CALL]
matches = re.finditer(TOOL_CALL_PATTERN_OLD, text, re.DOTALL)
for match in matches:
try:
data = json.loads(match.group(1))
tool_name = data.get('tool')
if tool_name and tool_name in self.tools:
tool_calls.append(ToolCall(
id=str(uuid.uuid4())[:8],
name=tool_name,
arguments=data.get('arguments', {})
))
except (json.JSONDecodeError, TypeError):
continue
return tool_calls
def _execute_tool(self, tool_call: ToolCall) -> Any:
"""Esegui un singolo tool"""
print(f"_execute_tool: name={tool_call.name}, args={tool_call.arguments}")
if tool_call.name not in self.tools:
available = ", ".join(self.tools.keys())
return {
"error": f"Tool '{tool_call.name}' not found",
"available_tools": available
}
try:
tool = self.tools[tool_call.name]
# Per tool senza argomenti, non passare arguments
if tool_call.arguments:
result = tool.invoke(tool_call.arguments)
else:
result = tool.invoke({})
print(f"_execute_tool: got result type={type(result)}, value={result}")
# 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:
print(f"_execute_tool: exception={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 _detect_intent(self, text: str) -> Optional[ToolCall]:
"""Detect common intents directly without relying on model"""
text_lower = text.lower().strip()
# System info patterns
system_patterns = [
'sistema operativo', 'so sto', 'che os', 'what os',
'che sistema', 'which system', 'operating system',
'che versione', 'python version', 'versione python',
'sto usando', 'sto usand', 'che so', 'which computer',
'che tipo di sistema', 'che computer', 'what system'
]
if any(p in text_lower for p in system_patterns):
return ToolCall(id='intent', name='system_info', arguments={})
# List files patterns
list_patterns = [
'lista', 'listare', 'listami', 'elenco', 'mostrami i file',
'list files', 'list directory', 'ls', 'file nella cartella',
'file del', 'cartella corrente', 'mostra i file'
]
if any(p in text_lower for p in list_patterns):
return ToolCall(id='intent', name='ls', arguments={'path': '.'})
return None
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)
print(f"OLLAMA_AGENT: user_content='{user_content}'")
# Aggiungi il messaggio utente alla cronologia
self.messages.append(HumanMessage(content=user_content))
# Try to detect intent directly first
detected_tool = self._detect_intent(user_content)
print(f"OLLAMA_AGENT: detected_tool={detected_tool}")
if detected_tool:
result = self._execute_tool(detected_tool)
print(f"OLLAMA_AGENT: result type={type(result)}, value={result}")
# Format result as a clean response
response_text = ""
try:
if isinstance(result, str):
try:
result = json.loads(result)
except:
response_text = result
if isinstance(result, dict):
if "result" in result:
response_text = result["result"]
elif "error" in result:
response_text = f"Errore: {result['error']}"
else:
response_text = json.dumps(result, indent=2)
except Exception as e:
response_text = str(result) if not response_text else response_text
print(f"OLLAMA_AGENT: response_text={response_text}")
return {"messages": self.messages, "response": response_text}
# 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
)