feat!: migrate to OpenAI native tool calls and fix circular deps (#fuck-gemini)

- Fix circular dependencies in agent/tools
- Migrate from custom JSON to OpenAI tool calls format
- Add async streaming (step_stream, complete_stream)
- Simplify prompt system and remove token counting
- Add 5 new API endpoints (/health, /v1/models, /api/memory/*)
- Add 3 new tools (get_torrent_by_index, add_torrent_by_index, set_language)
- Fix all 500 tests and add coverage config (80% threshold)
- Add comprehensive docs (README, pytest guide)

BREAKING: LLM interface changed, memory injection via get_memory()
This commit is contained in:
2025-12-06 19:11:05 +01:00
parent 2c8cdd3ab1
commit 9ca31e45e0
92 changed files with 7897 additions and 1786 deletions
+6
View File
@@ -0,0 +1,6 @@
"""Agent module for media library management."""
from .agent import Agent, LLMClient
from .config import settings
__all__ = ["Agent", "LLMClient", "settings"]
+218 -87
View File
@@ -1,147 +1,278 @@
# agent/agent.py
from typing import Any, Dict, List
import json
"""Main agent for media library management."""
import json
import logging
from typing import Any, Protocol
from infrastructure.persistence import get_memory
from .llm import DeepSeekClient
from infrastructure.persistence.memory import Memory
from .registry import make_tools, Tool
from .prompts import PromptBuilder
from .config import settings
from .prompts import PromptBuilder
from .registry import Tool, make_tools
logger = logging.getLogger(__name__)
class LLMClient(Protocol):
"""Protocol defining the LLM client interface."""
def complete(self, messages: list[dict[str, Any]]) -> str:
"""Send messages to the LLM and get a response."""
...
class Agent:
def __init__(self, llm: DeepSeekClient, memory: Memory, max_tool_iterations: int = 5):
"""
AI agent for media library management.
Orchestrates interactions between the LLM, memory, and tools
to respond to user requests.
Attributes:
llm: LLM client (DeepSeek or Ollama).
tools: Available tools for the agent.
prompt_builder: Builds system prompts with context.
max_tool_iterations: Maximum tool calls per request.
"""
def __init__(self, llm: LLMClient, max_tool_iterations: int = 5):
"""
Initialize the agent.
Args:
llm: LLM client compatible with the LLMClient protocol.
max_tool_iterations: Maximum tool iterations (default: 5).
"""
self.llm = llm
self.memory = memory
self.tools: Dict[str, Tool] = make_tools(memory)
self.tools: dict[str, Tool] = make_tools()
self.prompt_builder = PromptBuilder(self.tools)
self.max_tool_iterations = max_tool_iterations
def _parse_intent(self, text: str) -> dict[str, Any] | None:
"""
Parse an LLM response to detect a tool call.
def _parse_intent(self, text: str) -> Dict[str, Any] | None:
Args:
text: LLM response text.
Returns:
Dict with intent if a tool call is detected, None otherwise.
"""
text = text.strip()
# Try direct JSON parse
if text.startswith("{") and text.endswith("}"):
try:
data = json.loads(text)
if self._is_valid_intent(data):
return data
except json.JSONDecodeError:
pass
# Try to extract JSON from text
try:
data = json.loads(text)
start = text.find("{")
end = text.rfind("}") + 1
if start != -1 and end > start:
json_str = text[start:end]
data = json.loads(json_str)
if self._is_valid_intent(data):
return data
except json.JSONDecodeError:
return None
pass
if not isinstance(data, dict):
return None
return None
def _is_valid_intent(self, data: Any) -> bool:
"""Check if parsed data is a valid tool intent."""
if not isinstance(data, dict) or "action" not in data:
return False
action = data.get("action")
if not isinstance(action, dict):
return None
return isinstance(action, dict) and isinstance(action.get("name"), str)
name = action.get("name")
if not isinstance(name, str):
return None
def _execute_action(self, intent: dict[str, Any]) -> dict[str, Any]:
"""
Execute a tool action requested by the LLM.
return data
Args:
intent: Dict containing the action to execute.
def _execute_action(self, intent: Dict[str, Any]) -> Dict[str, Any]:
Returns:
Tool execution result.
"""
action = intent["action"]
name: str = action["name"]
args: Dict[str, Any] = action.get("args", {}) or {}
args: dict[str, Any] = action.get("args", {}) or {}
tool = self.tools.get(name)
if not tool:
return {"error": "unknown_tool", "tool": name}
logger.warning(f"Unknown tool requested: {name}")
return {
"error": "unknown_tool",
"tool": name,
"available_tools": list(self.tools.keys()),
}
try:
result = tool.func(**args)
# Track errors in episodic memory
if result.get("status") == "error" or result.get("error"):
memory = get_memory()
memory.episodic.add_error(
action=name,
error=result.get("error", result.get("message", "Unknown error")),
context={"args": args, "result": result},
)
return result
except TypeError as e:
# Mauvais arguments
error_msg = f"Bad arguments for {name}: {e}"
logger.error(error_msg)
memory = get_memory()
memory.episodic.add_error(
action=name, error=error_msg, context={"args": args}
)
return {"error": "bad_args", "message": str(e)}
return result
except Exception as e:
error_msg = f"Error executing {name}: {e}"
logger.error(error_msg, exc_info=True)
memory = get_memory()
memory.episodic.add_error(action=name, error=str(e), context={"args": args})
return {"error": "execution_error", "message": str(e)}
def _check_unread_events(self) -> str:
"""
Check for unread background events and format them.
Returns:
Formatted string of events, or empty string if none.
"""
memory = get_memory()
events = memory.episodic.get_unread_events()
if not events:
return ""
lines = ["Recent events:"]
for event in events:
event_type = event.get("type", "unknown")
data = event.get("data", {})
if event_type == "download_complete":
lines.append(f" - Download completed: {data.get('name')}")
elif event_type == "new_files_detected":
lines.append(f" - {data.get('count')} new files detected")
else:
lines.append(f" - {event_type}: {data}")
return "\n".join(lines)
def step(self, user_input: str) -> str:
"""
Execute one agent step with iterative tool execution:
- Build system prompt
- Query LLM
- Loop: If JSON intent -> execute tool, add result to conversation, query LLM again
- Continue until LLM responds with text (no tool call) or max iterations reached
- Return final text response
Execute one agent step with iterative tool execution.
Process:
1. Check for unread events
2. Build system prompt with memory context
3. Query the LLM
4. If tool call detected, execute and loop
5. Return final text response
Args:
user_input: User message.
Returns:
Final response in natural text.
"""
print("Starting a new step...")
print("User input:", user_input)
logger.info("Starting agent step")
logger.debug(f"User input: {user_input}")
print("Current memory state:", self.memory.data)
memory = get_memory()
# Build system prompt using PromptBuilder
system_prompt = self.prompt_builder.build_system_prompt(self.memory.data)
# Check for background events
events_notification = self._check_unread_events()
if events_notification:
logger.info("Found unread background events")
# Initialize conversation with system prompt
messages: List[Dict[str, Any]] = [
# Build system prompt
system_prompt = self.prompt_builder.build_system_prompt()
# Initialize conversation
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
]
# Add conversation history from memory (last N messages for context)
# Only add user/assistant messages, NOT system messages
history = self.memory.get("history", [])
max_history = settings.max_history_messages
if history and max_history > 0:
# Filter to keep only user and assistant messages
filtered_history = [
msg for msg in history
if msg.get("role") in ("user", "assistant")
]
recent_history = filtered_history[-max_history:]
messages.extend(recent_history)
print(f"Added {len(recent_history)} messages from history (filtered)")
# Add conversation history
history = memory.stm.get_recent_history(settings.max_history_messages)
if history:
for msg in history:
messages.append({"role": msg["role"], "content": msg["content"]})
logger.debug(f"Added {len(history)} messages from history")
# Add current user input
# Add events notification
if events_notification:
messages.append(
{"role": "system", "content": f"[NOTIFICATION]\n{events_notification}"}
)
# Add user input
messages.append({"role": "user", "content": user_input})
# Tool execution loop
iteration = 0
while iteration < self.max_tool_iterations:
print(f"\n--- Iteration {iteration + 1} ---")
logger.debug(f"Iteration {iteration + 1}/{self.max_tool_iterations}")
# Get LLM response
print(messages)
llm_response = self.llm.complete(messages)
print("LLM response:", llm_response)
logger.debug(f"LLM response: {llm_response[:200]}...")
# Try to parse as tool intent
intent = self._parse_intent(llm_response)
if not intent:
# No tool call - this is the final text response
print("No tool intent detected, returning final response")
# Save to history
self.memory.append_history("user", user_input)
self.memory.append_history("assistant", llm_response)
# Final text response
logger.info("No tool intent, returning response")
memory.stm.add_message("user", user_input)
memory.stm.add_message("assistant", llm_response)
memory.save()
return llm_response
# Tool call detected - execute it
print("Intent detected:", intent)
# Execute tool
tool_name = intent.get("action", {}).get("name", "unknown")
logger.info(f"Executing tool: {tool_name}")
tool_result = self._execute_action(intent)
print("Tool result:", tool_result)
logger.debug(f"Tool result: {tool_result}")
# Add assistant's tool call and result to conversation
messages.append({
"role": "assistant",
"content": json.dumps(intent, ensure_ascii=False)
})
messages.append({
"role": "user",
"content": json.dumps(
{"tool_result": tool_result},
ensure_ascii=False
)
})
# Add to conversation
messages.append(
{"role": "assistant", "content": json.dumps(intent, ensure_ascii=False)}
)
messages.append(
{
"role": "user",
"content": json.dumps(
{"tool_result": tool_result}, ensure_ascii=False
),
}
)
iteration += 1
# Max iterations reached - ask LLM for final response
print(f"\n--- Max iterations ({self.max_tool_iterations}) reached, requesting final response ---")
messages.append({
"role": "user",
"content": "Merci pour ces résultats. Peux-tu maintenant me donner une réponse finale en texte naturel ?"
})
# Max iterations reached
logger.warning(f"Max iterations ({self.max_tool_iterations}) reached")
messages.append(
{
"role": "user",
"content": "Please provide a final response based on the results.",
}
)
final_response = self.llm.complete(messages)
# Save to history
self.memory.append_history("user", user_input)
self.memory.append_history("assistant", final_response)
memory.stm.add_message("user", user_input)
memory.stm.add_message("assistant", final_response)
memory.save()
return final_response
+51 -17
View File
@@ -1,8 +1,9 @@
"""Configuration management with validation."""
from dataclasses import dataclass, field
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
# Load environment variables from .env file
@@ -11,6 +12,7 @@ load_dotenv()
class ConfigurationError(Exception):
"""Raised when configuration is invalid."""
pass
@@ -19,24 +21,46 @@ class Settings:
"""Application settings loaded from environment variables."""
# LLM Configuration
deepseek_api_key: str = field(default_factory=lambda: os.getenv("DEEPSEEK_API_KEY", ""))
deepseek_base_url: str = field(default_factory=lambda: os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"))
model: str = field(default_factory=lambda: os.getenv("DEEPSEEK_MODEL", "deepseek-chat"))
temperature: float = field(default_factory=lambda: float(os.getenv("TEMPERATURE", "0.2")))
deepseek_api_key: str = field(
default_factory=lambda: os.getenv("DEEPSEEK_API_KEY", "")
)
deepseek_base_url: str = field(
default_factory=lambda: os.getenv(
"DEEPSEEK_BASE_URL", "https://api.deepseek.com"
)
)
model: str = field(
default_factory=lambda: os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
)
temperature: float = field(
default_factory=lambda: float(os.getenv("TEMPERATURE", "0.2"))
)
# TMDB Configuration
tmdb_api_key: str = field(default_factory=lambda: os.getenv("TMDB_API_KEY", ""))
tmdb_base_url: str = field(default_factory=lambda: os.getenv("TMDB_BASE_URL", "https://api.themoviedb.org/3"))
tmdb_base_url: str = field(
default_factory=lambda: os.getenv(
"TMDB_BASE_URL", "https://api.themoviedb.org/3"
)
)
# Storage Configuration
memory_file: str = field(default_factory=lambda: os.getenv("MEMORY_FILE", "memory.json"))
memory_file: str = field(
default_factory=lambda: os.getenv("MEMORY_FILE", "memory.json")
)
# Security Configuration
max_tool_iterations: int = field(default_factory=lambda: int(os.getenv("MAX_TOOL_ITERATIONS", "5")))
request_timeout: int = field(default_factory=lambda: int(os.getenv("REQUEST_TIMEOUT", "30")))
max_tool_iterations: int = field(
default_factory=lambda: int(os.getenv("MAX_TOOL_ITERATIONS", "5"))
)
request_timeout: int = field(
default_factory=lambda: int(os.getenv("REQUEST_TIMEOUT", "30"))
)
# Memory Configuration
max_history_messages: int = field(default_factory=lambda: int(os.getenv("MAX_HISTORY_MESSAGES", "10")))
max_history_messages: int = field(
default_factory=lambda: int(os.getenv("MAX_HISTORY_MESSAGES", "10"))
)
def __post_init__(self):
"""Validate settings after initialization."""
@@ -46,19 +70,27 @@ class Settings:
"""Validate configuration values."""
# Validate temperature
if not 0.0 <= self.temperature <= 2.0:
raise ConfigurationError(f"Temperature must be between 0.0 and 2.0, got {self.temperature}")
raise ConfigurationError(
f"Temperature must be between 0.0 and 2.0, got {self.temperature}"
)
# Validate max_tool_iterations
if self.max_tool_iterations < 1 or self.max_tool_iterations > 20:
raise ConfigurationError(f"max_tool_iterations must be between 1 and 20, got {self.max_tool_iterations}")
raise ConfigurationError(
f"max_tool_iterations must be between 1 and 20, got {self.max_tool_iterations}"
)
# Validate request_timeout
if self.request_timeout < 1 or self.request_timeout > 300:
raise ConfigurationError(f"request_timeout must be between 1 and 300 seconds, got {self.request_timeout}")
raise ConfigurationError(
f"request_timeout must be between 1 and 300 seconds, got {self.request_timeout}"
)
# Validate URLs
if not self.deepseek_base_url.startswith(("http://", "https://")):
raise ConfigurationError(f"Invalid deepseek_base_url: {self.deepseek_base_url}")
raise ConfigurationError(
f"Invalid deepseek_base_url: {self.deepseek_base_url}"
)
if not self.tmdb_base_url.startswith(("http://", "https://")):
raise ConfigurationError(f"Invalid tmdb_base_url: {self.tmdb_base_url}")
@@ -66,7 +98,9 @@ class Settings:
# Validate memory file path
memory_path = Path(self.memory_file)
if memory_path.exists() and not memory_path.is_file():
raise ConfigurationError(f"memory_file exists but is not a file: {self.memory_file}")
raise ConfigurationError(
f"memory_file exists but is not a file: {self.memory_file}"
)
def is_deepseek_configured(self) -> bool:
"""Check if DeepSeek API is properly configured."""
+10 -2
View File
@@ -1,5 +1,13 @@
"""LLM client module."""
"""LLM clients module."""
from .deepseek import DeepSeekClient
from .exceptions import LLMAPIError, LLMConfigurationError, LLMError
from .ollama import OllamaClient
__all__ = ['DeepSeekClient', 'OllamaClient']
__all__ = [
"DeepSeekClient",
"OllamaClient",
"LLMError",
"LLMAPIError",
"LLMConfigurationError",
]
+35 -48
View File
@@ -1,48 +1,36 @@
"""DeepSeek LLM client with robust error handling."""
from typing import List, Dict, Any, Optional
import logging
from typing import Any
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from requests.exceptions import HTTPError, RequestException, Timeout
from ..config import settings
from .exceptions import LLMAPIError, LLMConfigurationError
logger = logging.getLogger(__name__)
class LLMError(Exception):
"""Base exception for LLM-related errors."""
pass
class LLMConfigurationError(LLMError):
"""Raised when LLM is not properly configured."""
pass
class LLMAPIError(LLMError):
"""Raised when LLM API returns an error."""
pass
class DeepSeekClient:
"""Client for interacting with DeepSeek API."""
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
model: Optional[str] = None,
timeout: Optional[int] = None,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
timeout: int | None = None,
):
"""
Initialize DeepSeek client.
Args:
api_key: API key for authentication (defaults to settings)
base_url: Base URL for API (defaults to settings)
model: Model name to use (defaults to settings)
timeout: Request timeout in seconds (defaults to settings)
Raises:
LLMConfigurationError: If API key is missing
"""
@@ -50,29 +38,29 @@ class DeepSeekClient:
self.base_url = base_url or settings.deepseek_base_url
self.model = model or settings.model
self.timeout = timeout or settings.request_timeout
if not self.api_key:
raise LLMConfigurationError(
"DeepSeek API key is required. Set DEEPSEEK_API_KEY environment variable."
)
if not self.base_url:
raise LLMConfigurationError(
"DeepSeek base URL is required. Set DEEPSEEK_BASE_URL environment variable."
)
logger.info(f"DeepSeek client initialized with model: {self.model}")
def complete(self, messages: List[Dict[str, Any]]) -> str:
def complete(self, messages: list[dict[str, Any]]) -> str:
"""
Generate a completion from the LLM.
Args:
messages: List of message dicts with 'role' and 'content' keys
Returns:
Generated text response
Raises:
LLMAPIError: If API request fails
ValueError: If messages format is invalid
@@ -80,15 +68,17 @@ class DeepSeekClient:
# Validate messages format
if not messages:
raise ValueError("Messages list cannot be empty")
for msg in messages:
if not isinstance(msg, dict):
raise ValueError(f"Each message must be a dict, got {type(msg)}")
if "role" not in msg or "content" not in msg:
raise ValueError(f"Each message must have 'role' and 'content' keys, got {msg.keys()}")
raise ValueError(
f"Each message must have 'role' and 'content' keys, got {msg.keys()}"
)
if msg["role"] not in ("system", "user", "assistant"):
raise ValueError(f"Invalid role: {msg['role']}")
url = f"{self.base_url}/v1/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
@@ -99,37 +89,34 @@ class DeepSeekClient:
"messages": messages,
"temperature": settings.temperature,
}
try:
logger.debug(f"Sending request to {url} with {len(messages)} messages")
response = requests.post(
url,
headers=headers,
json=payload,
timeout=self.timeout
url, headers=headers, json=payload, timeout=self.timeout
)
response.raise_for_status()
data = response.json()
# Validate response structure
if "choices" not in data or not data["choices"]:
raise LLMAPIError("Invalid API response: missing 'choices'")
if "message" not in data["choices"][0]:
raise LLMAPIError("Invalid API response: missing 'message' in choice")
if "content" not in data["choices"][0]["message"]:
raise LLMAPIError("Invalid API response: missing 'content' in message")
content = data["choices"][0]["message"]["content"]
logger.debug(f"Received response with {len(content)} characters")
return content
except Timeout as e:
logger.error(f"Request timeout after {self.timeout}s: {e}")
raise LLMAPIError(f"Request timeout after {self.timeout} seconds") from e
except HTTPError as e:
logger.error(f"HTTP error from DeepSeek API: {e}")
if e.response is not None:
@@ -140,11 +127,11 @@ class DeepSeekClient:
error_msg = str(e)
raise LLMAPIError(f"DeepSeek API error: {error_msg}") from e
raise LLMAPIError(f"HTTP error: {e}") from e
except RequestException as e:
logger.error(f"Request failed: {e}")
raise LLMAPIError(f"Failed to connect to DeepSeek API: {e}") from e
except (KeyError, IndexError, TypeError) as e:
logger.error(f"Failed to parse API response: {e}")
raise LLMAPIError(f"Invalid API response format: {e}") from e
+19
View File
@@ -0,0 +1,19 @@
"""LLM-related exceptions."""
class LLMError(Exception):
"""Base exception for LLM-related errors."""
pass
class LLMConfigurationError(LLMError):
"""Raised when LLM is not properly configured."""
pass
class LLMAPIError(LLMError):
"""Raised when LLM API returns an error."""
pass
+22 -33
View File
@@ -1,31 +1,18 @@
"""Ollama LLM client with robust error handling."""
from typing import List, Dict, Any, Optional
import logging
import os
import requests
from typing import Any
from requests.exceptions import RequestException, Timeout, HTTPError
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from ..config import settings
from .exceptions import LLMAPIError, LLMConfigurationError
logger = logging.getLogger(__name__)
class LLMError(Exception):
"""Base exception for LLM-related errors."""
pass
class LLMConfigurationError(LLMError):
"""Raised when LLM is not properly configured."""
pass
class LLMAPIError(LLMError):
"""Raised when LLM API returns an error."""
pass
class OllamaClient:
"""
Client for interacting with Ollama API.
@@ -41,10 +28,10 @@ class OllamaClient:
def __init__(
self,
base_url: Optional[str] = None,
model: Optional[str] = None,
timeout: Optional[int] = None,
temperature: Optional[float] = None,
base_url: str | None = None,
model: str | None = None,
timeout: int | None = None,
temperature: float | None = None,
):
"""
Initialize Ollama client.
@@ -58,10 +45,14 @@ class OllamaClient:
Raises:
LLMConfigurationError: If configuration is invalid
"""
self.base_url = base_url or os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
self.base_url = base_url or os.getenv(
"OLLAMA_BASE_URL", "http://localhost:11434"
)
self.model = model or os.getenv("OLLAMA_MODEL", "llama3.2")
self.timeout = timeout or settings.request_timeout
self.temperature = temperature if temperature is not None else settings.temperature
self.temperature = (
temperature if temperature is not None else settings.temperature
)
if not self.base_url:
raise LLMConfigurationError(
@@ -75,7 +66,7 @@ class OllamaClient:
logger.info(f"Ollama client initialized with model: {self.model}")
def complete(self, messages: List[Dict[str, Any]]) -> str:
def complete(self, messages: list[dict[str, Any]]) -> str:
"""
Generate a completion from the LLM.
@@ -97,7 +88,9 @@ class OllamaClient:
if not isinstance(msg, dict):
raise ValueError(f"Each message must be a dict, got {type(msg)}")
if "role" not in msg or "content" not in msg:
raise ValueError(f"Each message must have 'role' and 'content' keys, got {msg.keys()}")
raise ValueError(
f"Each message must have 'role' and 'content' keys, got {msg.keys()}"
)
if msg["role"] not in ("system", "user", "assistant"):
raise ValueError(f"Invalid role: {msg['role']}")
@@ -108,16 +101,12 @@ class OllamaClient:
"stream": False,
"options": {
"temperature": self.temperature,
}
},
}
try:
logger.debug(f"Sending request to {url} with {len(messages)} messages")
response = requests.post(
url,
json=payload,
timeout=self.timeout
)
response = requests.post(url, json=payload, timeout=self.timeout)
response.raise_for_status()
data = response.json()
@@ -156,7 +145,7 @@ class OllamaClient:
logger.error(f"Failed to parse API response: {e}")
raise LLMAPIError(f"Invalid API response format: {e}") from e
def list_models(self) -> List[str]:
def list_models(self) -> list[str]:
"""
List available models in Ollama.
+8 -7
View File
@@ -1,17 +1,18 @@
# agent/parameters.py
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Optional, Callable
import os
from typing import Any
@dataclass
class ParameterSchema:
"""Describes a required parameter for the agent."""
key: str
description: str
why_needed: str # Explanation for the AI
type: str # "string", "number", "object", etc.
validator: Optional[Callable[[Any], bool]] = None
validator: Callable[[Any], bool] | None = None
default: Any = None
required: bool = True
@@ -31,7 +32,7 @@ REQUIRED_PARAMETERS = [
type="object",
validator=lambda x: isinstance(x, dict),
required=True,
default={}
default={},
),
ParameterSchema(
key="tv_shows",
@@ -43,12 +44,12 @@ REQUIRED_PARAMETERS = [
type="array",
validator=lambda x: isinstance(x, list),
required=False,
default=[]
default=[],
),
]
def get_parameter_schema(key: str) -> Optional[ParameterSchema]:
def get_parameter_schema(key: str) -> ParameterSchema | None:
"""Get schema for a specific parameter."""
for param in REQUIRED_PARAMETERS:
if param.key == key:
@@ -79,7 +80,7 @@ def format_parameters_for_prompt() -> str:
return "\n".join(lines)
def validate_parameter(key: str, value: Any) -> tuple[bool, Optional[str]]:
def validate_parameter(key: str, value: Any) -> tuple[bool, str | None]:
"""
Validate a parameter value against its schema.
+138 -56
View File
@@ -1,15 +1,27 @@
# agent/prompts.py
from typing import Dict, Any
"""Prompt builder for the agent system."""
import json
from .registry import Tool
from infrastructure.persistence import get_memory
from .parameters import format_parameters_for_prompt, get_missing_required_parameters
from .registry import Tool
class PromptBuilder:
"""Handles construction of system prompts for the agent."""
"""Builds system prompts for the agent with memory context.
def __init__(self, tools: Dict[str, Tool]):
Attributes:
tools: Dictionary of available tools.
"""
def __init__(self, tools: dict[str, Tool]):
"""
Initialize the prompt builder.
Args:
tools: Dictionary mapping tool names to Tool instances.
"""
self.tools = tools
def _format_tools_description(self) -> str:
@@ -20,69 +32,139 @@ class PromptBuilder:
for tool in self.tools.values()
)
def _build_context(self, memory_data: dict) -> Dict[str, Any]:
"""Build the context object with current state from memory."""
return memory_data
def _format_episodic_context(self) -> str:
"""Format episodic memory context for the prompt."""
memory = get_memory()
lines = []
def build_system_prompt(self, memory_data: dict) -> str:
# Last search results
if memory.episodic.last_search_results:
search = memory.episodic.last_search_results
lines.append(f"LAST SEARCH: '{search.get('query')}'")
results = search.get("results", [])
if results:
lines.append(f" {len(results)} results available:")
for r in results[:5]:
name = r.get("name", r.get("title", "Unknown"))
lines.append(f" {r.get('index')}. {name}")
if len(results) > 5:
lines.append(f" ... and {len(results) - 5} more")
# Pending question
if memory.episodic.pending_question:
q = memory.episodic.pending_question
lines.append(f"\nPENDING QUESTION: {q.get('question')}")
for opt in q.get("options", []):
lines.append(f" {opt.get('index')}. {opt.get('label')}")
# Active downloads
if memory.episodic.active_downloads:
lines.append(f"\nACTIVE DOWNLOADS: {len(memory.episodic.active_downloads)}")
for dl in memory.episodic.active_downloads[:3]:
lines.append(f" - {dl.get('name')}: {dl.get('progress', 0)}%")
# Recent errors
if memory.episodic.recent_errors:
last_error = memory.episodic.recent_errors[-1]
lines.append(
f"\nLAST ERROR: {last_error.get('error')} "
f"(action: {last_error.get('action')})"
)
# Unread events
unread = [e for e in memory.episodic.background_events if not e.get("read")]
if unread:
lines.append(f"\nUNREAD EVENTS: {len(unread)}")
for e in unread[:3]:
lines.append(f" - {e.get('type')}: {e.get('data', {})}")
return "\n".join(lines) if lines else ""
def _format_stm_context(self) -> str:
"""Format short-term memory context for the prompt."""
memory = get_memory()
lines = []
# Current workflow
if memory.stm.current_workflow:
wf = memory.stm.current_workflow
lines.append(f"CURRENT WORKFLOW: {wf.get('type')}")
lines.append(f" Target: {wf.get('target', {}).get('title', 'Unknown')}")
lines.append(f" Stage: {wf.get('stage')}")
# Current topic
if memory.stm.current_topic:
lines.append(f"CURRENT TOPIC: {memory.stm.current_topic}")
# Extracted entities
if memory.stm.extracted_entities:
entities_json = json.dumps(
memory.stm.extracted_entities, ensure_ascii=False
)
lines.append(f"EXTRACTED ENTITIES: {entities_json}")
return "\n".join(lines) if lines else ""
def build_system_prompt(self) -> str:
"""
Build the system prompt with context provided as JSON.
Args:
memory_data: The full memory data dictionary
Build the system prompt with context from memory.
Returns:
The complete system prompt string
The complete system prompt string.
"""
context = self._build_context(memory_data)
memory = get_memory()
tools_desc = self._format_tools_description()
params_desc = format_parameters_for_prompt()
# Check for missing required parameters
missing_params = get_missing_required_parameters(memory_data)
missing_params = get_missing_required_parameters({"config": memory.ltm.config})
missing_info = ""
if missing_params:
missing_info = "\n\n⚠️ MISSING REQUIRED PARAMETERS:\n"
missing_info = "\n\nMISSING REQUIRED PARAMETERS:\n"
for param in missing_params:
missing_info += f"- {param.key}: {param.description}\n"
missing_info += f" Why needed: {param.why_needed}\n"
return (
"You are an AI agent helping a user manage their local media library.\n\n"
f"{params_desc}\n\n"
"CURRENT CONTEXT (JSON):\n"
f"{json.dumps(context, indent=2, ensure_ascii=False)}\n"
f"{missing_info}\n"
"IMPORTANT RULES:\n"
"1. Check the REQUIRED PARAMETERS section above to understand what information you need.\n"
"2. If any required parameter is missing (shown in MISSING REQUIRED PARAMETERS), "
"you MUST ask the user for it and explain WHY you need it based on the parameter description.\n"
"3. To use a tool, respond STRICTLY with this JSON format:\n"
' { "thought": "explanation", "action": { "name": "tool_name", "args": { "arg": "value" } } }\n'
" - No text before or after the JSON\n"
" - All args must be complete and non-null\n"
"4. You can use MULTIPLE TOOLS IN SEQUENCE:\n"
" - After executing a tool, you will receive its result\n"
" - You can then decide to use another tool based on the result\n"
" - Or provide a final text response to the user\n"
" - Continue using tools until you have all the information needed\n"
"5. If you respond with text (not using a tool), respond normally in French.\n"
"6. When you have all the information needed, provide a final response in NATURAL TEXT (not JSON).\n"
"7. Extract the relevant information from the user's request and pass it as tool arguments.\n"
"\n"
"EXAMPLES:\n"
" To set the download folder:\n"
' { "thought": "User provided download path", "action": { "name": "set_path", "args": { "path_type": "download_folder", "path_value": "/home/user/downloads" } } }\n'
"\n"
" To set the TV show folder:\n"
' { "thought": "User provided TV show path", "action": { "name": "set_path", "args": { "path_type": "tvshow_folder", "path_value": "/home/user/media/tvshows" } } }\n'
"\n"
" To list the download folder:\n"
' { "thought": "User wants to see downloads", "action": { "name": "list_folder", "args": { "folder_type": "download", "path": "." } } }\n'
"\n"
" To list a subfolder in TV shows:\n"
' { "thought": "User wants to see a specific show", "action": { "name": "list_folder", "args": { "folder_type": "tvshow", "path": "Game.of.Thrones" } } }\n'
"\n"
"AVAILABLE TOOLS:\n"
f"{tools_desc}\n"
)
# Build context sections
episodic_context = self._format_episodic_context()
stm_context = self._format_stm_context()
config_json = json.dumps(memory.ltm.config, indent=2, ensure_ascii=False)
return f"""You are an AI agent helping a user manage their local media library.
{params_desc}
CURRENT CONFIGURATION:
{config_json}
{missing_info}
{f"SESSION CONTEXT:{chr(10)}{stm_context}" if stm_context else ""}
{f"CURRENT STATE:{chr(10)}{episodic_context}" if episodic_context else ""}
IMPORTANT RULES:
1. When the user refers to a number (e.g., "the 3rd one", "download number 2"), \
use `add_torrent_by_index` or `get_torrent_by_index` with that number.
2. If a torrent search was performed, results are numbered. \
The user can reference them by number.
3. To use a tool, respond STRICTLY with this JSON format:
{{ "thought": "explanation", "action": {{ "name": "tool_name", "args": {{ }} }} }}
- No text before or after the JSON
4. You can use MULTIPLE TOOLS IN SEQUENCE.
5. When you have all the information needed, respond in NATURAL TEXT (not JSON).
6. If a required parameter is missing, ask the user for it.
7. Respond in the same language as the user.
EXAMPLES:
- After a torrent search, if the user says "download the 3rd one":
{{ "thought": "User wants torrent #3", "action": {{ "name": "add_torrent_by_index", \
"args": {{ "index": 3 }} }} }}
- To search for torrents:
{{ "thought": "Searching torrents", "action": {{ "name": "find_torrents", \
"args": {{ "media_title": "Inception 1080p" }} }} }}
AVAILABLE TOOLS:
{tools_desc}
"""
+108 -50
View File
@@ -1,123 +1,181 @@
"""Tool registry and definitions."""
from dataclasses import dataclass
from typing import Callable, Any, Dict
from functools import partial
"""Tool registry - defines and registers all available tools for the agent."""
from infrastructure.persistence.memory import Memory
from .tools.filesystem import set_path_for_folder, list_folder
from .tools.api import find_media_imdb_id, find_torrent, add_torrent_to_qbittorrent
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from .tools import api as api_tools
from .tools import filesystem as fs_tools
logger = logging.getLogger(__name__)
@dataclass
class Tool:
"""Represents a tool that can be used by the agent."""
"""Represents a tool that can be used by the agent.
Attributes:
name: Unique identifier for the tool.
description: Human-readable description for the LLM.
func: The callable that implements the tool.
parameters: JSON Schema describing the tool's parameters.
"""
name: str
description: str
func: Callable[..., Dict[str, Any]]
parameters: Dict[str, Any] # JSON Schema des paramètres
func: Callable[..., dict[str, Any]]
parameters: dict[str, Any]
def make_tools(memory: Memory) -> Dict[str, Tool]:
def make_tools() -> dict[str, Tool]:
"""
Create all available tools with memory bound to them.
Create and register all available tools.
Args:
memory: Memory instance to be used by the tools
Tools access memory via get_memory() context.
Returns:
Dictionary mapping tool names to Tool instances
Dictionary mapping tool names to Tool instances.
"""
# Create partial functions with memory pre-bound for filesystem tools
set_path_func = partial(set_path_for_folder, memory)
list_folder_func = partial(list_folder, memory)
tools = [
# Filesystem tools
Tool(
name="set_path_for_folder",
description="Sets a path in the configuration (download_folder, tvshow_folder, movie_folder, or torrent_folder).",
func=set_path_func,
description=(
"Sets a path in the configuration "
"(download_folder, tvshow_folder, movie_folder, or torrent_folder)."
),
func=fs_tools.set_path_for_folder,
parameters={
"type": "object",
"properties": {
"folder_name": {
"type": "string",
"description": "Name of folder to set",
"enum": ["download", "tvshow", "movie", "torrent"]
"enum": ["download", "tvshow", "movie", "torrent"],
},
"path_value": {
"type": "string",
"description": "Absolute path to the folder (e.g., /home/user/downloads)"
}
"description": "Absolute path to the folder",
},
},
"required": ["folder_name", "path_value"]
}
"required": ["folder_name", "path_value"],
},
),
Tool(
name="list_folder",
description="Lists the contents of a specified folder (download, tvshow, movie, or torrent).",
func=list_folder_func,
description="Lists the contents of a configured folder.",
func=fs_tools.list_folder,
parameters={
"type": "object",
"properties": {
"folder_type": {
"type": "string",
"description": "Type of folder to list: 'download', 'tvshow', 'movie', or 'torrent'",
"enum": ["download", "tvshow", "movie", "torrent"]
"description": "Type of folder to list",
"enum": ["download", "tvshow", "movie", "torrent"],
},
"path": {
"type": "string",
"description": "Relative path within the folder (default: '.' for root)",
"default": "."
}
"description": "Relative path within the folder",
"default": ".",
},
},
"required": ["folder_type"]
}
"required": ["folder_type"],
},
),
# Media search tools
Tool(
name="find_media_imdb_id",
description="Finds the IMDb ID for a given media title using TMDB API.",
func=find_media_imdb_id,
description=(
"Finds the IMDb ID for a given media title using TMDB API. "
"Use this to get information about a movie or TV show."
),
func=api_tools.find_media_imdb_id,
parameters={
"type": "object",
"properties": {
"media_title": {
"type": "string",
"description": "Title of the media to find the IMDb ID for"
"description": "Title of the media to search for",
},
},
"required": ["media_title"]
}
"required": ["media_title"],
},
),
# Torrent tools
Tool(
name="find_torrents",
description="Finds torrents for a given media title using Knaben API.",
func=find_torrent,
description=(
"Finds torrents for a given media title. "
"Results are numbered (1, 2, 3...) so the user can select by number."
),
func=api_tools.find_torrent,
parameters={
"type": "object",
"properties": {
"media_title": {
"type": "string",
"description": "Title of the media to find torrents for"
"description": "Title to search for (include quality if specified)",
},
},
"required": ["media_title"]
}
"required": ["media_title"],
},
),
Tool(
name="add_torrent_by_index",
description=(
"Adds a torrent from the previous search results by its number. "
"Use when the user says 'download the 3rd one' or 'take number 2'."
),
func=api_tools.add_torrent_by_index,
parameters={
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": "Number of the torrent in search results (1, 2, 3...)",
},
},
"required": ["index"],
},
),
Tool(
name="add_torrent_to_qbittorrent",
description="Adds a torrent to qBittorrent client.",
func=add_torrent_to_qbittorrent,
description=(
"Adds a torrent to qBittorrent using a magnet link directly. "
"Use add_torrent_by_index if user selected from search results."
),
func=api_tools.add_torrent_to_qbittorrent,
parameters={
"type": "object",
"properties": {
"magnet_link": {
"type": "string",
"description": "Title of the media to find torrents for"
"description": "The magnet link of the torrent",
},
},
"required": ["magnet_link"]
}
"required": ["magnet_link"],
},
),
Tool(
name="get_torrent_by_index",
description=(
"Gets details of a torrent from search results by its number, "
"without downloading it."
),
func=api_tools.get_torrent_by_index,
parameters={
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": "Number of the torrent in search results (1, 2, 3...)",
},
},
"required": ["index"],
},
),
]
logger.info(f"Registered {len(tools)} tools")
return {t.name: t for t in tools}
+17 -8
View File
@@ -1,11 +1,20 @@
"""Tools module - filesystem and API tools."""
from .filesystem import set_path_for_folder, list_folder
from .api import find_media_imdb_id, find_torrent, add_torrent_to_qbittorrent
"""Tools module - filesystem and API tools for the agent."""
from .api import (
add_torrent_by_index,
add_torrent_to_qbittorrent,
find_media_imdb_id,
find_torrent,
get_torrent_by_index,
)
from .filesystem import list_folder, set_path_for_folder
__all__ = [
'set_path_for_folder',
'list_folder',
'find_media_imdb_id',
'find_torrent',
'add_torrent_to_qbittorrent',
"set_path_for_folder",
"list_folder",
"find_media_imdb_id",
"find_torrent",
"get_torrent_by_index",
"add_torrent_to_qbittorrent",
"add_torrent_by_index",
]
+158 -49
View File
@@ -1,87 +1,196 @@
"""API tools for interacting with external services - Adapted for DDD architecture."""
from typing import Dict, Any
"""API tools for interacting with external services."""
import logging
from typing import Any
# Import use cases instead of direct API clients
from application.movies import SearchMovieUseCase
from application.torrents import SearchTorrentsUseCase, AddTorrentUseCase
# Import infrastructure clients
from infrastructure.api.tmdb import tmdb_client
from application.torrents import AddTorrentUseCase, SearchTorrentsUseCase
from infrastructure.api.knaben import knaben_client
from infrastructure.api.qbittorrent import qbittorrent_client
from infrastructure.api.tmdb import tmdb_client
from infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
def find_media_imdb_id(media_title: str) -> Dict[str, Any]:
def find_media_imdb_id(media_title: str) -> dict[str, Any]:
"""
Find the IMDb ID for a given media title using TMDB API.
This is a wrapper that uses the SearchMovieUseCase.
Args:
media_title: Title of the media to search for
media_title: Title of the media to search for.
Returns:
Dict with IMDb ID or error information
Example:
>>> result = find_media_imdb_id("Inception")
>>> print(result)
{'status': 'ok', 'imdb_id': 'tt1375666', 'title': 'Inception', ...}
Dict with IMDb ID and media info, or error details.
"""
# Create use case with TMDB client
use_case = SearchMovieUseCase(tmdb_client)
# Execute use case
response = use_case.execute(media_title)
# Return as dict
return response.to_dict()
result = response.to_dict()
if result.get("status") == "ok":
memory = get_memory()
memory.stm.set_entity(
"last_media_search",
{
"title": result.get("title"),
"imdb_id": result.get("imdb_id"),
"media_type": result.get("media_type"),
"tmdb_id": result.get("tmdb_id"),
},
)
memory.stm.set_topic("searching_media")
logger.debug(f"Stored media search result in STM: {result.get('title')}")
return result
def find_torrent(media_title: str) -> Dict[str, Any]:
def find_torrent(media_title: str) -> dict[str, Any]:
"""
Find torrents for a given media title using Knaben API.
This is a wrapper that uses the SearchTorrentsUseCase.
Results are stored in episodic memory so the user can reference them
by index (e.g., "download the 3rd one").
Args:
media_title: Title of the media to search for
media_title: Title of the media to search for.
Returns:
Dict with torrent information or error details
Dict with torrent list or error details.
"""
# Create use case with Knaben client
logger.info(f"Searching torrents for: {media_title}")
use_case = SearchTorrentsUseCase(knaben_client)
# Execute use case
response = use_case.execute(media_title, limit=10)
# Return as dict
return response.to_dict()
result = response.to_dict()
if result.get("status") == "ok":
memory = get_memory()
torrents = result.get("torrents", [])
memory.episodic.store_search_results(
query=media_title, results=torrents, search_type="torrent"
)
memory.stm.set_topic("selecting_torrent")
logger.info(f"Stored {len(torrents)} torrent results in episodic memory")
return result
def add_torrent_to_qbittorrent(magnet_link: str) -> Dict[str, Any]:
def get_torrent_by_index(index: int) -> dict[str, Any]:
"""
Get a torrent from the last search results by its index.
Allows the user to reference results by number after a search.
Args:
index: 1-based index of the torrent in the search results.
Returns:
Dict with torrent data or error if not found.
"""
logger.info(f"Getting torrent at index: {index}")
memory = get_memory()
if memory.episodic.last_search_results:
results_count = len(memory.episodic.last_search_results.get("results", []))
query = memory.episodic.last_search_results.get("query", "unknown")
logger.debug(f"Episodic memory has {results_count} results from: {query}")
else:
logger.warning("No search results in episodic memory")
result = memory.episodic.get_result_by_index(index)
if result:
logger.info(f"Found torrent at index {index}: {result.get('name', 'unknown')}")
return {"status": "ok", "torrent": result}
logger.warning(f"No torrent found at index {index}")
return {
"status": "error",
"error": "not_found",
"message": f"No torrent found at index {index}. Search for torrents first.",
}
def add_torrent_to_qbittorrent(magnet_link: str) -> dict[str, Any]:
"""
Add a torrent to qBittorrent using a magnet link.
This is a wrapper that uses the AddTorrentUseCase.
Args:
magnet_link: Magnet link of the torrent to add
magnet_link: Magnet link of the torrent to add.
Returns:
Dict with success or error information
Example:
>>> result = add_torrent_to_qbittorrent("magnet:?xt=urn:btih:...")
>>> print(result)
{'status': 'ok', 'message': 'Torrent added successfully'}
Dict with success status or error details.
"""
# Create use case with qBittorrent client
logger.info("Adding torrent to qBittorrent")
use_case = AddTorrentUseCase(qbittorrent_client)
# Execute use case
response = use_case.execute(magnet_link)
# Return as dict
return response.to_dict()
result = response.to_dict()
if result.get("status") == "ok":
memory = get_memory()
last_search = memory.episodic.get_search_results()
torrent_name = "Unknown"
if last_search:
for t in last_search.get("results", []):
if t.get("magnet") == magnet_link:
torrent_name = t.get("name", "Unknown")
break
memory.episodic.add_active_download(
{
"task_id": magnet_link[:20],
"name": torrent_name,
"magnet": magnet_link,
"progress": 0,
"status": "queued",
}
)
memory.stm.set_topic("downloading")
memory.stm.end_workflow()
logger.info(f"Added download to episodic memory: {torrent_name}")
return result
def add_torrent_by_index(index: int) -> dict[str, Any]:
"""
Add a torrent from the last search results by its index.
Combines get_torrent_by_index and add_torrent_to_qbittorrent.
Args:
index: 1-based index of the torrent in the search results.
Returns:
Dict with success status or error details.
"""
logger.info(f"Adding torrent by index: {index}")
torrent_result = get_torrent_by_index(index)
if torrent_result.get("status") != "ok":
return torrent_result
torrent = torrent_result.get("torrent", {})
magnet = torrent.get("magnet")
if not magnet:
logger.error("Torrent has no magnet link")
return {
"status": "error",
"error": "no_magnet",
"message": "The selected torrent has no magnet link",
}
logger.info(f"Adding torrent: {torrent.get('name', 'unknown')}")
result = add_torrent_to_qbittorrent(magnet)
if result.get("status") == "ok":
result["torrent_name"] = torrent.get("name", "Unknown")
return result
+15 -34
View File
@@ -1,59 +1,40 @@
"""Filesystem tools - Adapted for DDD architecture."""
from typing import Dict, Any
"""Filesystem tools for folder management."""
# Import use cases
from application.filesystem import SetFolderPathUseCase, ListFolderUseCase
from typing import Any
# Import infrastructure
from application.filesystem import ListFolderUseCase, SetFolderPathUseCase
from infrastructure.filesystem import FileManager
from infrastructure.persistence.memory import Memory
def set_path_for_folder(memory: Memory, folder_name: str, path_value: str) -> Dict[str, Any]:
def set_path_for_folder(folder_name: str, path_value: str) -> dict[str, Any]:
"""
Set a path in the configuration.
Set a folder path in the configuration.
Args:
memory: Memory instance to store the configuration
folder_name: Name of folder to set (download, tvshow, movie, torrent)
path_value: Absolute path to the folder
folder_name: Name of folder to set (download, tvshow, movie, torrent).
path_value: Absolute path to the folder.
Returns:
Dict with status or error information
Dict with status or error information.
"""
# Create file manager
file_manager = FileManager(memory)
# Create use case
file_manager = FileManager()
use_case = SetFolderPathUseCase(file_manager)
# Execute use case
response = use_case.execute(folder_name, path_value)
# Return as dict
return response.to_dict()
def list_folder(memory: Memory, folder_type: str, path: str = ".") -> Dict[str, Any]:
def list_folder(folder_type: str, path: str = ".") -> dict[str, Any]:
"""
List contents of a folder.
List contents of a configured folder.
Args:
memory: Memory instance to retrieve the configuration
folder_type: Type of folder to list (download, tvshow, movie, torrent)
path: Relative path within the folder (default: ".")
folder_type: Type of folder to list (download, tvshow, movie, torrent).
path: Relative path within the folder (default: root).
Returns:
Dict with folder contents or error information
Dict with folder contents or error information.
"""
# Create file manager
file_manager = FileManager(memory)
# Create use case
file_manager = FileManager()
use_case = ListFolderUseCase(file_manager)
# Execute use case
response = use_case.execute(folder_type, path)
# Return as dict
return response.to_dict()