Formatting
This commit is contained in:
+58
-66
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user