Formatting

This commit is contained in:
2025-12-07 03:33:51 +01:00
parent a923a760ef
commit 4eae1d6d58
24 changed files with 1003 additions and 833 deletions
+58 -66
View File
@@ -1,7 +1,8 @@
"""Main agent for media library management."""
import json
import logging
from typing import Any, Dict, List, Optional
from typing import Any
from infrastructure.persistence import get_memory
@@ -15,157 +16,156 @@ logger = logging.getLogger(__name__)
class Agent:
"""
AI agent for media library management.
Uses OpenAI-compatible tool calling API.
"""
def __init__(self, llm, max_tool_iterations: int = 5):
"""
Initialize the agent.
Args:
llm: LLM client with complete() method
max_tool_iterations: Maximum number of tool execution iterations
"""
self.llm = llm
self.tools: Dict[str, Tool] = make_tools()
self.tools: dict[str, Tool] = make_tools()
self.prompt_builder = PromptBuilder(self.tools)
self.max_tool_iterations = max_tool_iterations
def step(self, user_input: str) -> str:
"""
Execute one agent step with the user input.
This method:
1. Adds user message to memory
2. Builds prompt with history and context
3. Calls LLM, executing tools as needed
4. Returns final response
Args:
user_input: User's message
Returns:
Agent's final response
"""
memory = get_memory()
# Add user message to history
memory.stm.add_message("user", user_input)
memory.save()
# Build initial messages
system_prompt = self.prompt_builder.build_system_prompt()
messages: List[Dict[str, Any]] = [
{"role": "system", "content": system_prompt}
]
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
# Add conversation history
history = memory.stm.get_recent_history(settings.max_history_messages)
messages.extend(history)
# Add unread events if any
unread_events = memory.episodic.get_unread_events()
if unread_events:
events_text = "\n".join([
f"- {e['type']}: {e['data']}"
for e in unread_events
])
messages.append({
"role": "system",
"content": f"Background events:\n{events_text}"
})
events_text = "\n".join(
[f"- {e['type']}: {e['data']}" for e in unread_events]
)
messages.append(
{"role": "system", "content": f"Background events:\n{events_text}"}
)
# Get tools specification for OpenAI format
tools_spec = self.prompt_builder.build_tools_spec()
# Tool execution loop
for iteration in range(self.max_tool_iterations):
# Call LLM with tools
llm_result = self.llm.complete(messages, tools=tools_spec)
# Handle both tuple (response, usage) and dict response
if isinstance(llm_result, tuple):
response_message, usage = llm_result
else:
response_message = llm_result
# Check if there are tool calls
tool_calls = response_message.get("tool_calls")
if not tool_calls:
# No tool calls, this is the final response
final_content = response_message.get("content", "")
memory.stm.add_message("assistant", final_content)
memory.save()
return final_content
# Add assistant message with tool calls to conversation
messages.append(response_message)
# Execute each tool call
for tool_call in tool_calls:
tool_result = self._execute_tool_call(tool_call)
# Add tool result to messages
messages.append({
"tool_call_id": tool_call.get("id"),
"role": "tool",
"name": tool_call.get("function", {}).get("name"),
"content": json.dumps(tool_result, ensure_ascii=False),
})
messages.append(
{
"tool_call_id": tool_call.get("id"),
"role": "tool",
"name": tool_call.get("function", {}).get("name"),
"content": json.dumps(tool_result, ensure_ascii=False),
}
)
# Max iterations reached, force final response
messages.append({
"role": "system",
"content": "Please provide a final response to the user without using any more tools."
})
messages.append(
{
"role": "system",
"content": "Please provide a final response to the user without using any more tools.",
}
)
llm_result = self.llm.complete(messages)
if isinstance(llm_result, tuple):
final_message, usage = llm_result
else:
final_message = llm_result
final_response = final_message.get("content", "I've completed the requested actions.")
final_response = final_message.get(
"content", "I've completed the requested actions."
)
memory.stm.add_message("assistant", final_response)
memory.save()
return final_response
def _execute_tool_call(self, tool_call: Dict[str, Any]) -> Dict[str, Any]:
def _execute_tool_call(self, tool_call: dict[str, Any]) -> dict[str, Any]:
"""
Execute a single tool call.
Args:
tool_call: OpenAI-format tool call dict
Returns:
Result dictionary
"""
function = tool_call.get("function", {})
tool_name = function.get("name", "")
try:
args_str = function.get("arguments", "{}")
args = json.loads(args_str)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse tool arguments: {e}")
return {
"error": "bad_args",
"message": f"Invalid JSON arguments: {e}"
}
return {"error": "bad_args", "message": f"Invalid JSON arguments: {e}"}
# Validate tool exists
if tool_name not in self.tools:
available = list(self.tools.keys())
return {
"error": "unknown_tool",
"message": f"Tool '{tool_name}' not found",
"available_tools": available
"available_tools": available,
}
tool = self.tools[tool_name]
# Execute tool
try:
result = tool.func(**args)
@@ -177,17 +177,9 @@ class Agent:
# Bad arguments
memory = get_memory()
memory.episodic.add_error(tool_name, f"bad_args: {e}")
return {
"error": "bad_args",
"message": str(e),
"tool": tool_name
}
return {"error": "bad_args", "message": str(e), "tool": tool_name}
except Exception as e:
# Other errors
memory = get_memory()
memory.episodic.add_error(tool_name, str(e))
return {
"error": "execution_failed",
"message": str(e),
"tool": tool_name
}
return {"error": "execution_failed", "message": str(e), "tool": tool_name}
+10 -4
View File
@@ -51,7 +51,9 @@ class DeepSeekClient:
logger.info(f"DeepSeek client initialized with model: {self.model}")
def complete(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None) -> dict[str, Any]:
def complete(
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None
) -> dict[str, Any]:
"""
Generate a completion from the LLM.
@@ -80,7 +82,9 @@ class DeepSeekClient:
raise ValueError(f"Invalid role: {msg['role']}")
# Content is optional for tool messages (they may have tool_call_id instead)
if msg["role"] != "tool" and "content" not in msg:
raise ValueError(f"Non-tool message must have 'content' key, got {msg.keys()}")
raise ValueError(
f"Non-tool message must have 'content' key, got {msg.keys()}"
)
url = f"{self.base_url}/v1/chat/completions"
headers = {
@@ -92,13 +96,15 @@ class DeepSeekClient:
"messages": messages,
"temperature": settings.temperature,
}
# Add tools if provided
if tools:
payload["tools"] = tools
try:
logger.debug(f"Sending request to {url} with {len(messages)} messages and {len(tools) if tools else 0} tools")
logger.debug(
f"Sending request to {url} with {len(messages)} messages and {len(tools) if tools else 0} tools"
)
response = requests.post(
url, headers=headers, json=payload, timeout=self.timeout
)
+10 -4
View File
@@ -66,7 +66,9 @@ class OllamaClient:
logger.info(f"Ollama client initialized with model: {self.model}")
def complete(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None) -> dict[str, Any]:
def complete(
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None
) -> dict[str, Any]:
"""
Generate a completion from the LLM.
@@ -95,7 +97,9 @@ class OllamaClient:
raise ValueError(f"Invalid role: {msg['role']}")
# Content is optional for tool messages (they may have tool_call_id instead)
if msg["role"] != "tool" and "content" not in msg:
raise ValueError(f"Non-tool message must have 'content' key, got {msg.keys()}")
raise ValueError(
f"Non-tool message must have 'content' key, got {msg.keys()}"
)
url = f"{self.base_url}/api/chat"
payload = {
@@ -106,13 +110,15 @@ class OllamaClient:
"temperature": self.temperature,
},
}
# Add tools if provided
if tools:
payload["tools"] = tools
try:
logger.debug(f"Sending request to {url} with {len(messages)} messages and {len(tools) if tools else 0} tools")
logger.debug(
f"Sending request to {url} with {len(messages)} messages and {len(tools) if tools else 0} tools"
)
response = requests.post(url, json=payload, timeout=self.timeout)
response.raise_for_status()
data = response.json()
+24 -16
View File
@@ -1,18 +1,20 @@
"""Prompt builder for the agent system."""
from typing import Dict, List, Any
import json
from typing import Any
from infrastructure.persistence import get_memory
from .registry import Tool
from infrastructure.persistence import get_memory
class PromptBuilder:
"""Builds system prompts for the agent with memory context."""
def __init__(self, tools: Dict[str, Tool]):
def __init__(self, tools: dict[str, Tool]):
self.tools = tools
def build_tools_spec(self) -> List[Dict[str, Any]]:
def build_tools_spec(self) -> list[dict[str, Any]]:
"""Build the tool specification for the LLM API."""
tool_specs = []
for tool in self.tools.values():
@@ -44,11 +46,13 @@ class PromptBuilder:
if memory.episodic.last_search_results:
results = memory.episodic.last_search_results
result_list = results.get('results', [])
lines.append(f"\nLAST SEARCH: '{results.get('query')}' ({len(result_list)} results)")
result_list = results.get("results", [])
lines.append(
f"\nLAST SEARCH: '{results.get('query')}' ({len(result_list)} results)"
)
# Show first 5 results
for i, result in enumerate(result_list[:5]):
name = result.get('name', 'Unknown')
name = result.get("name", "Unknown")
lines.append(f" {i+1}. {name}")
if len(result_list) > 5:
lines.append(f" ... and {len(result_list) - 5} more")
@@ -57,7 +61,7 @@ class PromptBuilder:
question = memory.episodic.pending_question
lines.append(f"\nPENDING QUESTION: {question.get('question')}")
lines.append(f" Type: {question.get('type')}")
if question.get('options'):
if question.get("options"):
lines.append(f" Options: {len(question.get('options'))}")
if memory.episodic.active_downloads:
@@ -68,10 +72,12 @@ class PromptBuilder:
if memory.episodic.recent_errors:
lines.append("\nRECENT ERRORS (up to 3):")
for error in memory.episodic.recent_errors[-3:]:
lines.append(f" - Action '{error.get('action')}' failed: {error.get('error')}")
lines.append(
f" - Action '{error.get('action')}' failed: {error.get('error')}"
)
# Unread events
unread = [e for e in memory.episodic.background_events if not e.get('read')]
unread = [e for e in memory.episodic.background_events if not e.get("read")]
if unread:
lines.append(f"\nUNREAD EVENTS: {len(unread)}")
for event in unread[:3]:
@@ -86,8 +92,10 @@ class PromptBuilder:
if memory.stm.current_workflow:
workflow = memory.stm.current_workflow
lines.append(f"CURRENT WORKFLOW: {workflow.get('type')} (stage: {workflow.get('stage')})")
if workflow.get('target'):
lines.append(
f"CURRENT WORKFLOW: {workflow.get('type')} (stage: {workflow.get('stage')})"
)
if workflow.get("target"):
lines.append(f" Target: {workflow.get('target')}")
if memory.stm.current_topic:
@@ -97,7 +105,7 @@ class PromptBuilder:
lines.append("EXTRACTED ENTITIES:")
for key, value in memory.stm.extracted_entities.items():
lines.append(f" - {key}: {value}")
if memory.stm.language:
lines.append(f"CONVERSATION LANGUAGE: {memory.stm.language}")
@@ -106,7 +114,7 @@ class PromptBuilder:
def _format_config_context(self) -> str:
"""Format configuration context."""
memory = get_memory()
lines = ["CURRENT CONFIGURATION:"]
if memory.ltm.config:
for key, value in memory.ltm.config.items():
@@ -118,10 +126,10 @@ class PromptBuilder:
def build_system_prompt(self) -> str:
"""Build the complete system prompt."""
memory = get_memory()
# Base instruction
base = "You are a helpful AI assistant for managing a media library."
# Language instruction
language_instruction = (
"Your first task is to determine the user's language from their message "
+26 -23
View File
@@ -1,8 +1,10 @@
"""Tool registry - defines and registers all available tools for the agent."""
from dataclasses import dataclass
from typing import Callable, Any, Dict
import logging
import inspect
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
@@ -10,36 +12,37 @@ logger = logging.getLogger(__name__)
@dataclass
class Tool:
"""Represents a tool that can be used by the agent."""
name: str
description: str
func: Callable[..., Dict[str, Any]]
parameters: Dict[str, Any]
func: Callable[..., dict[str, Any]]
parameters: dict[str, Any]
def _create_tool_from_function(func: Callable) -> Tool:
"""
Create a Tool object from a function.
Args:
func: Function to convert to a tool
Returns:
Tool object with metadata extracted from function
"""
sig = inspect.signature(func)
doc = inspect.getdoc(func)
# Extract description from docstring (first line)
description = doc.strip().split('\n')[0] if doc else func.__name__
description = doc.strip().split("\n")[0] if doc else func.__name__
# Build JSON schema from function signature
properties = {}
required = []
for param_name, param in sig.parameters.items():
if param_name == "self":
continue
# Map Python types to JSON schema types
param_type = "string" # default
if param.annotation != inspect.Parameter.empty:
@@ -51,22 +54,22 @@ def _create_tool_from_function(func: Callable) -> Tool:
param_type = "number"
elif param.annotation == bool:
param_type = "boolean"
properties[param_name] = {
"type": param_type,
"description": f"Parameter {param_name}"
"description": f"Parameter {param_name}",
}
# Add to required if no default value
if param.default == inspect.Parameter.empty:
required.append(param_name)
parameters = {
"type": "object",
"properties": properties,
"required": required,
}
return Tool(
name=func.__name__,
description=description,
@@ -75,18 +78,18 @@ def _create_tool_from_function(func: Callable) -> Tool:
)
def make_tools() -> Dict[str, Tool]:
def make_tools() -> dict[str, Tool]:
"""
Create and register all available tools.
Returns:
Dictionary mapping tool names to Tool objects
"""
# Import tools here to avoid circular dependencies
from .tools import filesystem as fs_tools
from .tools import api as api_tools
from .tools import filesystem as fs_tools
from .tools import language as lang_tools
# List of all tool functions
tool_functions = [
fs_tools.set_path_for_folder,
@@ -98,12 +101,12 @@ def make_tools() -> Dict[str, Tool]:
api_tools.get_torrent_by_index,
lang_tools.set_language,
]
# Create Tool objects from functions
tools = {}
for func in tool_functions:
tool = _create_tool_from_function(func)
tools[tool.name] = tool
logger.info(f"Registered {len(tools)} tools: {list(tools.keys())}")
return tools
+9 -11
View File
@@ -1,19 +1,20 @@
"""Language management tools for the agent."""
import logging
from typing import Dict, Any
from typing import Any
from infrastructure.persistence import get_memory
logger = logging.getLogger(__name__)
def set_language(language: str) -> Dict[str, Any]:
def set_language(language: str) -> dict[str, Any]:
"""
Set the conversation language.
Args:
language: Language code (e.g., 'en', 'fr', 'es', 'de')
Returns:
Status dictionary
"""
@@ -21,17 +22,14 @@ def set_language(language: str) -> Dict[str, Any]:
memory = get_memory()
memory.stm.set_language(language)
memory.save()
logger.info(f"Language set to: {language}")
return {
"status": "ok",
"message": f"Language set to {language}",
"language": language
"language": language,
}
except Exception as e:
logger.error(f"Failed to set language: {e}")
return {
"status": "error",
"error": str(e)
}
return {"status": "error", "error": str(e)}