added seerr beginning tools
Build and Push Agent API / build (push) Successful in 15s

This commit is contained in:
2026-05-11 20:38:29 +02:00
parent 2ee33b50eb
commit d943d4bd31
11 changed files with 879 additions and 67 deletions
+14
View File
@@ -0,0 +1,14 @@
# ---------------------------------------------------------------------------
# Agent Backend — Environment Variables
# Copy this to .env and fill in your values.
# ---------------------------------------------------------------------------
# LLM — DeepSeek (OpenAI-compatible)
DEEPSEEK_API_KEY=sk-your-deepseek-api-key
# ---------------------------------------------------------------------------
# Seerr (Overseerr / Jellyseerr)
# ---------------------------------------------------------------------------
SEERR_URL=https://seerr.example.com
SEERR_API_KEY=your-seerr-api-key
# SEERR_TIMEOUT=30 # optional, defaults to 30 seconds
+2
View File
@@ -62,3 +62,5 @@ def load_all_agents() -> None:
# Also import skill modules so they self-register # Also import skill modules so they self-register
import skills.media_info # noqa: F401 import skills.media_info # noqa: F401
import skills.seerr # noqa: F401
import skills.triage # noqa: F401
+13 -6
View File
@@ -2,18 +2,25 @@
media-agent — an agent that knows how to handle media queries media-agent — an agent that knows how to handle media queries
(Jellyfin / Sonarr / Seerr / subtitle requests). (Jellyfin / Sonarr / Seerr / subtitle requests).
For now it only loads the *media_info* demo skill which teaches it Skills:
a structured response format. Later you'll add real API-calling skills. - media_info : base persona (prompt-only)
- seerr : trending, discover, request media, submit issues (tools + API)
- triage : fallback for unsupported actions (prompt-only, uses seerr tools)
""" """
from agents import Agent, register from agents import Agent, register
media_agent = Agent( media_agent = Agent(
agent_id="media-agent", agent_id="media-agent",
description="Media assistant — handles movie/TV/subtitle/ticket requests. " description="Media assistant — handles movie/TV/subtitle/ticket requests "
"Will eventually connect to Seerr, Sonarr, Jellyfin, etc.", "via Seerr, Jellyfin, Sonarr, etc.",
skills=["media_info"], skills=["media_info", "seerr", "triage"],
base_prompt="You are a media assistant. Help users with their media library.", base_prompt=(
"You are a media assistant connected to Seerr and other media services. "
"Help users discover, request, and troubleshoot their media library. "
"Use the tools provided to perform real actions."
),
) )
register(media_agent) register(media_agent)
+192 -25
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Body, Depends from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from openai import OpenAI from openai import OpenAI
from pydantic import BaseModel from pydantic import BaseModel
@@ -7,6 +7,7 @@ import asyncio
from api.dependencies import get_llm_client from api.dependencies import get_llm_client
from agents import get as get_agent, list_all as list_all_agents from agents import get as get_agent, list_all as list_all_agents
from skills import get_all_tools, execute_tool, ToolResult
router = APIRouter() router = APIRouter()
@@ -24,34 +25,99 @@ class ChatCompletionRequest(BaseModel):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Core helpers # Agent resolution
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _resolve_agent(agent_id: str | None = None, model: str | None = None): def _resolve_agent(agent_id: str | None = None, model: str | None = None):
""" """
Look up the agent. Resolution order: Resolution order:
1. explicit agent_id 1. explicit agent_id
2. model name (OpenWebUI sends this — maps to agent_id if registered) 2. model field (OpenWebUI sends this — maps to agent_id if registered)
3. fallback to "naked" 3. fallback to "naked"
""" """
lookup = agent_id or model lookup = agent_id or model
if lookup is None: if lookup is None:
agent = get_agent("naked") return get_agent("naked")
else: agent = get_agent(lookup)
agent = get_agent(lookup) return agent if agent else get_agent("naked")
if agent is None:
agent = get_agent("naked")
return agent
def run_agent( # ---------------------------------------------------------------------------
# Tool-calling loop (non-streaming)
# ---------------------------------------------------------------------------
async def run_agent_with_tools(
client: OpenAI,
message: str,
agent_id: str | None = None,
model: str | None = None,
max_turns: int = 5,
) -> str:
"""Send the user message to the LLM with tool definitions.
Loop: if the LLM responds with tool_calls, execute them and feed
results back until the LLM produces a final text answer.
"""
agent = _resolve_agent(agent_id, model)
tools = get_all_tools(agent.skills)
system_prompt = agent.build_system_prompt()
messages: list[dict] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": message},
]
loop = asyncio.get_running_loop()
for _ in range(max_turns):
resp = await loop.run_in_executor(
None,
lambda: client.chat.completions.create(
model="deepseek-chat",
messages=messages,
tools=tools if tools else None,
tool_choice="auto" if tools else None,
),
)
choice = resp.choices[0]
# If the model sends a final text answer, return it
if choice.finish_reason == "stop" and choice.message.content:
return choice.message.content
# If the model wants to call tools
if choice.message.tool_calls:
# Append the assistant message with tool_calls
messages.append(choice.message.model_dump(exclude_none=True))
for tc in choice.message.tool_calls:
fn_name = tc.function.name
fn_args = json.loads(tc.function.arguments)
tr = await execute_tool(agent.skills, fn_name, fn_args)
result = tr.content if tr else f"Tool '{fn_name}' is not available right now."
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result,
})
continue
# Fallback — should not normally happen
return choice.message.content or "I'm not sure how to help with that."
return "I've taken several actions but still need more information. Could you clarify?"
# ---------------------------------------------------------------------------
# Non-streaming helper (no tools — used by sync endpoint if tools are absent)
# ---------------------------------------------------------------------------
def run_agent_simple(
client: OpenAI, client: OpenAI,
message: str, message: str,
session_id: str | None = None,
agent_id: str | None = None, agent_id: str | None = None,
model: str | None = None, model: str | None = None,
) -> str: ) -> str:
"""Non-streaming: uses the chosen agent's system prompt.""" """Plain LLM call — no tools. Used when the agent has no tool-enabled skills."""
agent = _resolve_agent(agent_id, model) agent = _resolve_agent(agent_id, model)
response = client.chat.completions.create( response = client.chat.completions.create(
model="deepseek-chat", model="deepseek-chat",
@@ -63,15 +129,105 @@ def run_agent(
return response.choices[0].message.content return response.choices[0].message.content
# ---------------------------------------------------------------------------
# Streaming generators
# ---------------------------------------------------------------------------
async def _stream_with_tools(
client: OpenAI,
message: str,
agent_id: str | None = None,
model: str | None = None,
max_turns: int = 5,
):
"""Streaming version with tool-calling loop.
Yields tokens from the final text response (tools run silently in the background).
"""
agent = _resolve_agent(agent_id, model)
tools = get_all_tools(agent.skills)
system_prompt = agent.build_system_prompt()
messages: list[dict] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": message},
]
loop = asyncio.get_running_loop()
for turn in range(max_turns):
# Non-streaming call to check for tool_calls
resp = await loop.run_in_executor(
None,
lambda: client.chat.completions.create(
model="deepseek-chat",
messages=messages,
tools=tools if tools else None,
tool_choice="auto" if tools else None,
),
)
choice = resp.choices[0]
# Tool calls? Execute them and loop
if choice.message.tool_calls:
messages.append(choice.message.model_dump(exclude_none=True))
for tc in choice.message.tool_calls:
fn_name = tc.function.name
fn_args = json.loads(tc.function.arguments)
tr = await execute_tool(agent.skills, fn_name, fn_args)
result = tr.content if tr else f"Tool '{fn_name}' is not available right now."
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result,
})
continue
# Final text answer — stream it
if choice.finish_reason == "stop" and choice.message.content:
# Already have a non-streaming answer — yield it token-by-token
for token in choice.message.content:
yield token
await asyncio.sleep(0)
return
# Last resort: stream the final response
def _sync_stream():
stream = client.chat.completions.create(
model="deepseek-chat",
messages=messages,
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta
if delta and delta.content:
yield delta.content
gen = _sync_stream()
while True:
token = await loop.run_in_executor(None, next, gen, None)
if token is None:
return
yield token
yield ""
async def run_agent_stream( async def run_agent_stream(
client: OpenAI, client: OpenAI,
message: str, message: str,
session_id: str | None = None,
agent_id: str | None = None, agent_id: str | None = None,
model: str | None = None, model: str | None = None,
): ):
"""Async generator — yields tokens using the chosen agent's system prompt.""" """Async generator — yields tokens. Uses tool-loop when skills have tools."""
agent = _resolve_agent(agent_id, model) agent = _resolve_agent(agent_id, model)
tools = get_all_tools(agent.skills)
if tools:
async for token in _stream_with_tools(client, message, agent_id, model):
yield token
return
# No tools — simple streaming
system_prompt = agent.build_system_prompt() system_prompt = agent.build_system_prompt()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -111,7 +267,7 @@ async def chat(req: ChatRequest, client: OpenAI = Depends(get_llm_client)):
"""Streaming chat endpoint — returns Server-Sent Events.""" """Streaming chat endpoint — returns Server-Sent Events."""
async def event_stream(): async def event_stream():
async for token in run_agent_stream( async for token in run_agent_stream(
client, req.message, req.session_id, req.agent_id, client, req.message, req.agent_id,
): ):
payload = json.dumps({"token": token, "session_id": req.session_id}) payload = json.dumps({"token": token, "session_id": req.session_id})
yield f"data: {payload}\n\n" yield f"data: {payload}\n\n"
@@ -130,9 +286,18 @@ async def chat(req: ChatRequest, client: OpenAI = Depends(get_llm_client)):
@router.post("/chat/sync") @router.post("/chat/sync")
def chat_sync(req: ChatRequest, client: OpenAI = Depends(get_llm_client)): async def chat_sync(req: ChatRequest, client: OpenAI = Depends(get_llm_client)):
"""Non-streaming fallback — returns the full response at once.""" """Non-streaming endpoint — uses tool-calling when the agent has tools."""
response = run_agent(client, req.message, req.session_id, req.agent_id) agent = _resolve_agent(req.agent_id)
tools = get_all_tools(agent.skills)
if tools:
response = await run_agent_with_tools(
client, req.message, req.agent_id,
)
else:
response = run_agent_simple(client, req.message, req.agent_id)
return {"response": response, "session_id": req.session_id} return {"response": response, "session_id": req.session_id}
@@ -174,11 +339,9 @@ async def chat_completions(
client: OpenAI = Depends(get_llm_client), client: OpenAI = Depends(get_llm_client),
): ):
"""OpenAI-compatible /chat/completions — supports stream=True. """OpenAI-compatible /chat/completions — supports stream=True.
The last message's content is used as the user prompt; defaults to 'naked' agent. Resolves the agent from the model field (OpenWebUI sends this).
""" """
user_message = req.messages[-1]["content"] user_message = req.messages[-1]["content"]
# Resolve agent from the model field (OpenWebUI sends this)
agent = _resolve_agent(model=req.model) agent = _resolve_agent(model=req.model)
if req.stream: if req.stream:
@@ -219,9 +382,13 @@ async def chat_completions(
}, },
) )
# Non-streaming path — resolve agent from model field # Non-streaming path
agent = _resolve_agent(model=req.model) tools = get_all_tools(agent.skills)
response = run_agent(client, user_message, agent_id=agent.agent_id) if tools:
response = await run_agent_with_tools(client, user_message, agent_id=agent.agent_id)
else:
response = run_agent_simple(client, user_message, agent_id=agent.agent_id)
return { return {
"id": "chatcmpl-local", "id": "chatcmpl-local",
"object": "chat.completion", "object": "chat.completion",
+25 -1
View File
@@ -2,6 +2,30 @@ from dotenv import load_dotenv
from pathlib import Path from pathlib import Path
import os import os
load_dotenv(Path(__file__).resolve().parent.parent / ".env") # ---------------------------------------------------------------------------
# Load .env from the project root (one level above core/)
# ---------------------------------------------------------------------------
_env_path = Path(__file__).resolve().parent.parent / ".env"
load_dotenv(_env_path)
# ---------------------------------------------------------------------------
# General-purpose config accessor — every skill uses this
# ---------------------------------------------------------------------------
def get_config(key: str, default: str | None = None) -> str | None:
"""Read a value from the environment (loaded from .env)."""
return os.getenv(key, default)
# ---------------------------------------------------------------------------
# LLM
# ---------------------------------------------------------------------------
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY")
# ---------------------------------------------------------------------------
# Seerr (Overseerr / Jellyseerr)
# ---------------------------------------------------------------------------
SEERR_URL = os.getenv("SEERR_URL", "")
SEERR_API_KEY = os.getenv("SEERR_API_KEY", "")
SEERR_TIMEOUT = int(os.getenv("SEERR_TIMEOUT", "30"))
+11
View File
@@ -1,3 +1,5 @@
import logging
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -5,6 +7,15 @@ from api.v1.chat import router as v1_router
from core.config import DEEPSEEK_API_KEY from core.config import DEEPSEEK_API_KEY
from core.llm import create_client from core.llm import create_client
# ---------------------------------------------------------------------------
# Logging — tool calls will appear in the uvicorn console
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%H:%M:%S",
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Load all agents & skills so they self-register at startup # Load all agents & skills so they self-register at startup
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1
View File
@@ -2,3 +2,4 @@ fastapi
openai openai
uvicorn uvicorn
python-dotenv python-dotenv
httpx
+82 -3
View File
@@ -3,13 +3,41 @@ Skill system — each skill is a piece of domain knowledge or a capability
that can be attached to an agent to shape its behavior and system prompt. that can be attached to an agent to shape its behavior and system prompt.
A Skill is a lightweight object with: A Skill is a lightweight object with:
- name : short identifier (e.g. "media_info") - name : short identifier (e.g. "media_info")
- description : human-readable summary - description : human-readable summary
- prompt_fragment : extra text injected into the agent's system prompt - prompt_fragment : extra text injected into the agent's system prompt
- tools : OpenAI function-calling tool definitions (list of dicts)
- execute : async callable to run a tool → ToolResult
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict from typing import Any, Awaitable, Callable, Dict, List, Optional
from core.config import get_config # re-export so every skill can use it
# ---------------------------------------------------------------------------
# ToolResult — every skill executor must return this
# ---------------------------------------------------------------------------
@dataclass
class ToolResult:
"""Result of executing a tool.
- success: True if the API returned 2xx and the action completed.
- content: The message to feed back to the LLM (will be shown to the user).
"""
content: str
success: bool = True
@classmethod
def ok(cls, content: str) -> "ToolResult":
return cls(content=content, success=True)
@classmethod
def fail(cls, content: str) -> "ToolResult":
return cls(content=content, success=False)
# Type alias for a tool executor
ToolExecutor = Callable[[str, dict], Awaitable[ToolResult]]
@dataclass @dataclass
@@ -17,6 +45,8 @@ class Skill:
name: str name: str
description: str description: str
prompt_fragment: str = "" prompt_fragment: str = ""
tools: List[Dict[str, Any]] = field(default_factory=list)
execute: Optional[ToolExecutor] = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -48,3 +78,52 @@ def get_combined_prompt(skill_names: list[str], base_prompt: str = "") -> str:
if s and s.prompt_fragment: if s and s.prompt_fragment:
parts.append(s.prompt_fragment) parts.append(s.prompt_fragment)
return "\n\n".join(parts) return "\n\n".join(parts)
def get_all_tools(skill_names: list[str]) -> List[Dict[str, Any]]:
"""Collect all OpenAI tool definitions across the requested skills."""
tools: List[Dict[str, Any]] = []
seen: set[str] = set()
for name in skill_names:
s = get(name)
if s:
for t in s.tools:
fn_name = t.get("function", {}).get("name", "")
if fn_name and fn_name not in seen:
seen.add(fn_name)
tools.append(t)
return tools
async def execute_tool(
skill_names: list[str], tool_name: str, args: dict
) -> ToolResult | None:
"""Find the skill that owns *tool_name* and run its executor.
Only logs failures to the console — successful calls are silent.
"""
import logging
logger = logging.getLogger("skills")
for name in skill_names:
s = get(name)
if s and s.execute:
for t in s.tools:
if t.get("function", {}).get("name") == tool_name:
try:
result = await s.execute(tool_name, args)
if not result.success:
logger.warning(
"⚠️ TOOL FAILED: %s | args=%s%s",
tool_name, args, result.content[:300],
)
return result
except Exception as exc:
logger.exception(
"💥 TOOL CRASH: %s | args=%s", tool_name, args
)
return ToolResult.fail(
f"Tool '{tool_name}' crashed unexpectedly: {exc}"
)
logger.warning("⚠️ TOOL NOT FOUND: %s (skills=%s)", tool_name, skill_names)
return None
+16 -30
View File
@@ -1,45 +1,31 @@
""" """
Demo skill: media_info Demo skill: media_info
Gives the agent knowledge about how to respond to media-related queries A lightweight base skill that teaches the agent it is a media assistant.
(movie / TV / subtitle requests). This is intentionally simple — in the future Real API capabilities come from other skills (seerr, triage, etc.).
you would add real API-calling skills here (Sonarr / Jellyfin / Seerr / etc.).
""" """
from skills import Skill, register from skills import Skill, register
media_info_skill = Skill( media_info_skill = Skill(
name="media_info", name="media_info",
description="Respond to media queries with a structured format " description="Base media assistant persona — movie, TV, subtitle, and media requests.",
"(movie / TV show requests, subtitles, tickets).", prompt_fragment="""## Media Assistant Persona
prompt_fragment="""## Media Agent Instructions
You are a media assistant. When users ask about movies, TV shows, subtitles, You are a friendly media assistant connected to a media back-end (Seerr,
or media library requests, follow these rules: Jellyfin, Sonarr, etc.). Your job is to help users discover, request, and
troubleshoot their media library.
- If a user wants to **request** a movie or show, respond with a clear When responding:
confirmation using this format: - Be concise and helpful.
- Use the tools available to you for real actions.
- If a user asks about **subtitles**, explain that Bazarr handles those and
suggest submitting a ticket if there's a problem.
- Always confirm successful actions and warn about failures.
``` This is the base media assistant persona. Real API capabilities come from the
[MEDIA REQUEST] attached skills (seerr, triage, etc.).""",
Title: <title>
Type: <movie | show>
Status: PENDING — this would be submitted to Seerr
```
- If a user asks about **subtitles**, acknowledge the request and respond with:
```
[SUBTITLE REQUEST]
Media: <title>
Language: <language>
Status: PENDING — Bazarr would process this
```
- Otherwise, answer normally but always remind the user that media-backend
integrations (Seerr, Sonarr, Jellyfin) are not yet connected.
This is a **demo** skill. Real API calls will be added later.""",
) )
register(media_info_skill) register(media_info_skill)
+470
View File
@@ -0,0 +1,470 @@
"""
Seerr skill — connects to Overseerr / Jellyseerr API for media discovery,
requests, and issue submission.
.env variables:
SEERR_URL base URL (e.g. https://seerr.example.com)
SEERR_API_KEY API key from Seerr settings
SEERR_TIMEOUT optional request timeout in seconds (default 30)
"""
from __future__ import annotations
import json
from urllib.parse import quote
import httpx
from skills import Skill, register, ToolResult, get_config
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
SEERR_URL = (get_config("SEERR_URL") or "").rstrip("/")
SEERR_API_KEY = get_config("SEERR_API_KEY") or ""
SEERR_TIMEOUT = int(get_config("SEERR_TIMEOUT", "30"))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _client() -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=SEERR_URL,
headers={"X-Api-Key": SEERR_API_KEY},
timeout=SEERR_TIMEOUT,
)
def _fmt_items(items: list[dict], kind: str) -> str:
"""Format a list of media items for the LLM to present."""
lines = []
for i, item in enumerate(items[:10], 1):
title = item.get("title") or item.get("name") or "Unknown"
year = (
item.get("releaseDate", "")[:4]
or item.get("firstAirDate", "")[:4]
or "?"
)
overview = (item.get("overview") or "")[:120]
lines.append(f"{i}. **{title}** ({year}) — {overview}")
return f"Found {len(items)} {kind}. Top results:\n\n" + "\n".join(lines)
# ---------------------------------------------------------------------------
# Tool definitions (OpenAI function-calling schema)
# ---------------------------------------------------------------------------
TOOLS = [
{
"type": "function",
"function": {
"name": "seerr_trending",
"description": "Get trending movies and TV shows from Seerr using "
"the /discover/trending endpoint. Call this when a user asks what "
"is popular, trending, or new.",
"parameters": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["movie", "tv", "all"],
"description": "What kind of media to fetch. "
"Use 'all' when the user doesn't specify.",
},
"language": {
"type": "string",
"description": "Language filter (e.g. 'en', 'nl'). "
"Omit for all languages.",
},
},
"required": ["kind"],
},
},
},
{
"type": "function",
"function": {
"name": "seerr_discover",
"description": "Discover movies or TV shows by genre, studio, "
"keyword, or language in Seerr. Uses /discover/{movies|tv}/genre/{id} "
"for genre queries, /discover/{movies|tv}/studio/{id} for studios, "
"and /discover/{movies|tv}?query= for keyword search. "
"Call when a user asks 'what movies in category X do you recommend?' "
"or 'show me horror movies' or 'find Studio Ghibli movies'.",
"parameters": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["movie", "tv"],
"description": "Media type to search.",
},
"genre": {
"type": "string",
"description": "Genre name, e.g. 'horror', 'comedy', "
"'animation', 'action', 'science fiction'. "
"Use this for genre-based discovery.",
},
"studio": {
"type": "string",
"description": "Studio name to filter by, e.g. "
"'Studio Ghibli', 'Pixar', 'Marvel'. "
"Use this for studio-based discovery.",
},
"keyword": {
"type": "string",
"description": "Free-text keyword search, e.g. "
"'space', 'superhero', 'dinosaur'. "
"Use this for topic-based discovery.",
},
"language": {
"type": "string",
"description": "Language filter (e.g. 'en', 'ja'). "
"Omit for all languages.",
},
"page": {
"type": "integer",
"description": "Page number (default 1).",
},
},
"required": ["kind"],
},
},
},
{
"type": "function",
"function": {
"name": "seerr_request_media",
"description": "Request a movie or TV show to be added to the media "
"library via Seerr. Call when a user asks 'can you request movie X?' "
"or 'please add show Y'.",
"parameters": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["movie", "tv"],
"description": "Whether this is a movie or TV show.",
},
"title": {
"type": "string",
"description": "The title of the movie or TV show to request.",
},
"tmdb_id": {
"type": "integer",
"description": "The TMDb ID if known (optional — Seerr will "
"search by title if not provided).",
},
},
"required": ["kind", "title"],
},
},
},
{
"type": "function",
"function": {
"name": "seerr_submit_issue",
"description": "Submit a ticket/issue for a specific media item. "
"Call when a user wants to report a problem (bad quality, wrong "
"language, missing episodes, corrupt file, etc.) or when they want "
"an action that only a human operator can perform. "
"IMPORTANT: always include the media_title so the system can "
"look up the correct mediaId.",
"parameters": {
"type": "object",
"properties": {
"subject": {
"type": "string",
"description": "Short summary of the issue.",
},
"description": {
"type": "string",
"description": "Detailed description of the problem.",
},
"media_title": {
"type": "string",
"description": "The movie or TV show title this issue "
"relates to. Always provide this — the system will "
"search for the matching mediaId.",
},
"issue_type": {
"type": "integer",
"enum": [1, 2, 3, 4],
"description": "Issue category code: "
"1 = Video (playback, codec, quality), "
"2 = Audio (sync, missing), "
"3 = Subtitle (missing, wrong, timing), "
"4 = Other (operator-only actions like delete/cancel).",
},
},
"required": ["subject", "description", "media_title", "issue_type"],
},
},
},
]
# ---------------------------------------------------------------------------
# Tool executor
# ---------------------------------------------------------------------------
async def _execute(tool_name: str, args: dict) -> ToolResult:
"""Route tool calls to the right handler. Returns ToolResult with success
based on HTTP status code (2xx = ok, everything else = fail)."""
import logging
logger = logging.getLogger("skills.seerr")
handlers = {
"seerr_trending": _trending,
"seerr_discover": _discover,
"seerr_request_media": _request_media,
"seerr_submit_issue": _submit_issue,
}
handler = handlers.get(tool_name)
if not handler:
return ToolResult.fail(f"Unknown tool: {tool_name}")
try:
result = await handler(args)
return result
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
body = exc.response.text[:500]
logger.error("Seerr API HTTP %s on %s: %s", status, tool_name, body)
return ToolResult.fail(
f"Seerr API returned HTTP {status} for '{tool_name}'. "
f"Response: {body}"
)
except httpx.HTTPError as exc:
logger.error("Seerr API network error on %s: %s", tool_name, exc)
return ToolResult.fail(
f"Seerr API is unreachable for '{tool_name}': {exc}"
)
except Exception as exc:
logger.exception("Unexpected error in %s", tool_name)
return ToolResult.fail(f"Unexpected error in '{tool_name}': {exc}")
# ---------------------------------------------------------------------------
# API handlers
# ---------------------------------------------------------------------------
async def _trending(args: dict) -> ToolResult:
"""Use Jellyseerr's /api/v1/discover/trending endpoint.
Query params: language (optional), mediaType (movie | tv, default all).
"""
media_type = args.get("kind", "all")
language = args.get("language", "").strip() or None
params: dict = {}
if media_type in ("movie", "tv"):
params["mediaType"] = media_type
if language:
params["language"] = language
async with _client() as c:
r = await c.get("/api/v1/discover/trending", params=params)
r.raise_for_status()
data = r.json()
results = data.get("results", [])
label = f"trending {media_type}" if media_type != "all" else "trending items"
if language:
label += f" ({language})"
if not results:
return ToolResult.ok(f"No {label} found right now.")
return ToolResult.ok(_fmt_items(results, label))
async def _discover(args: dict) -> ToolResult:
"""Jellyseerr discover endpoints:
Genre: /api/v1/discover/{movies|tv}/genre/{genreId}
Studio: /api/v1/discover/{movies|tv}/studio/{studioId}
Keyword: /api/v1/discover/{movies|tv}?query=keyword
Language falls back to the base discover endpoint.
"""
kind = args["kind"]
genre = args.get("genre", "").strip()
studio = args.get("studio", "").strip()
keyword = args.get("keyword", "").strip()
language = args.get("language", "").strip() or None
page = args.get("page", 1)
# Map common genre names to TMDb genre IDs
genre_map = {
"action": 28, "adventure": 12, "animation": 16, "comedy": 35,
"crime": 80, "documentary": 99, "drama": 18, "family": 10751,
"fantasy": 14, "history": 36, "horror": 27, "music": 10402,
"mystery": 9648, "romance": 10749, "science fiction": 878,
"sci-fi": 878, "scifi": 878, "tv movie": 10770, "thriller": 53,
"war": 10752, "western": 37,
}
base = f"/api/v1/discover/{'movies' if kind == 'movie' else 'tv'}"
params: dict = {"page": page}
endpoint = base
if genre:
genre_id = genre_map.get(genre.lower())
if not genre_id:
return ToolResult.fail(
f"I don't recognise the genre '{genre}'. "
f"Try one of: {', '.join(sorted(genre_map.keys()))}."
)
endpoint = f"{base}/genre/{genre_id}"
elif studio:
endpoint = f"{base}/studio/{studio}"
elif keyword:
params["query"] = keyword
endpoint = base
if language:
params["language"] = language
async with _client() as c:
r = await c.get(endpoint, params=params)
r.raise_for_status()
results = r.json().get("results", [])
desc = genre or studio or keyword or kind
if not results:
return ToolResult.ok(f"No {desc} {kind}s found.")
return ToolResult.ok(_fmt_items(results, f"{desc} {kind}s"))
async def _request_media(args: dict) -> ToolResult:
kind = args["kind"]
title = args["title"]
tmdb_id = args.get("tmdb_id")
# Step 1: Search for the media
async with _client() as c:
r = await c.get("/api/v1/search/", params={"query": quote(title), "page": 1})
r.raise_for_status()
results = r.json().get("results", [])
# Filter by mediaType if we have results from unified search
filtered = [item for item in results if item.get("mediaType") == kind] if results else []
if not filtered:
filtered = results # fallback if mediaType not set
if not filtered:
return ToolResult.fail(
f"I couldn't find '{title}' on Seerr. "
f"Please double-check the title or provide a TMDb ID."
)
# If tmdb_id provided, match it; otherwise use the first result
match = None
if tmdb_id:
match = next(
(item for item in filtered if item.get("id") == tmdb_id),
None,
)
if not match:
match = filtered[0]
# Seerr's request endpoint expects the local mediaInfo.id
media_info = match.get("mediaInfo", {})
media_id = media_info.get("id") or match.get("id")
media_title = match.get("title") or match.get("name") or title
media_year = (
(match.get("releaseDate") or match.get("firstAirDate") or "?")[:4]
)
# Step 2: Submit the request
request_body = {
"mediaType": kind,
"mediaId": media_id,
}
if kind == "tv":
request_body["seasons"] = "all"
req_r = await c.post("/api/v1/request", json=request_body)
if req_r.status_code == 201:
return ToolResult.ok(
f"✅ Successfully requested **{media_title}** ({media_year}). "
f"It has been submitted to Seerr and will be processed soon."
)
elif req_r.status_code == 409:
return ToolResult.fail(
f"⚠️ **{media_title}** ({media_year}) has already been requested "
f"or is already available."
)
else:
detail = req_r.text
return ToolResult.fail(
f"❌ Failed to request **{media_title}** ({media_year}). "
f"Seerr responded with status {req_r.status_code}: {detail}"
)
async def _submit_issue(args: dict) -> ToolResult:
subject = args["subject"]
description = args["description"]
media_title = args.get("media_title", "")
issue_type = args.get("issue_type", 4) # numeric code: 1=video, 2=audio, 3=sub, 4=other
media_id = args.get("media_id")
body: dict = {
"issueType": int(issue_type),
"subject": subject,
"message": description,
}
if media_title:
body["message"] = f"[Media: {media_title}]\n\n{description}"
async with _client() as c:
# --- Resolve mediaId (Seerr's internal ID, not TMDb) ---
if not media_id and media_title:
search_r = await c.get("/api/v1/search/", params={"query": quote(media_title), "page": 1})
if search_r.status_code == 200:
results = search_r.json().get("results", [])
if results:
# Seerr's /api/v1/issue expects the local mediaInfo.id,
# not the TMDb id at the top level.
media_info = results[0].get("mediaInfo", {})
media_id = media_info.get("id") or results[0].get("id")
if media_id:
body["mediaId"] = int(media_id)
r = await c.post("/api/v1/issue", json=body)
resp_json = r.json() if r.text else {}
if r.status_code in (200, 201):
ticket_id = resp_json.get("id", "N/A")
return ToolResult.ok(
f"✅ Issue submitted successfully (ticket #{ticket_id}). "
f"A human operator will review: **{subject}**"
)
else:
return ToolResult.fail(
f"❌ Failed to submit issue. Seerr responded with "
f"status {r.status_code}: {r.text[:500]}"
)
# ---------------------------------------------------------------------------
# Register the skill
# ---------------------------------------------------------------------------
seerr_skill = Skill(
name="seerr",
description="Seerr integration — trending, discover, request media, submit issues",
prompt_fragment="""## Seerr Media Tools
You have access to the Seerr media management system. Use the provided tools
to help users with media-related tasks:
- **seerr_trending** — when a user asks what is trending/popular/new
- **seerr_discover** — when a user asks for recommendations by genre/category
- **seerr_request_media** — when a user wants to request a movie or TV show
- **seerr_submit_issue** — when a user needs to report a problem or needs an
operator-only action (like deleting media or cancelling a request)
Always confirm successful actions to the user. If a tool fails, tell the user
what went wrong and suggest alternatives.""",
tools=TOOLS,
execute=_execute,
)
register(seerr_skill)
+51
View File
@@ -0,0 +1,51 @@
"""
Triage skill — fallback for actions that aren't covered by any registered skill.
When a user asks for something that the agent cannot do (either because the
skill doesn't exist or is intentionally unavailable — e.g. deleting media,
cancelling requests, banning users), this skill teaches the LLM to:
1. Politely explain that the action requires a human operator.
2. Offer to submit a ticket instead.
3. Use the seerr_submit_issue tool (if available) to create the ticket.
"""
from skills import Skill, register
# This skill has no tools of its own — it guides the LLM's behavior.
# The actual ticket submission is handled by seerr_submit_issue.
triage_skill = Skill(
name="triage",
description="Fallback for unsupported actions — explains limitations "
"and offers to create a ticket instead.",
prompt_fragment="""## Triage & Fallback Rules
You are a helpful media assistant, but you have limited capabilities. Follow these
rules when a user asks for something you **cannot** do:
### Actions you CANNOT perform (human-operator-only):
- Deleting media, requests, or users
- Cancelling existing requests
- Modifying library settings
- Changing user permissions
- Any destructive or administrative action
### When the user asks for an unsupported action:
1. **Politely explain** that this action requires a human operator.
2. **Offer to submit a ticket** via the seerr_submit_issue tool with a clear
description of what the user wants.
3. Never say "I don't know how to do that" without also offering the ticket
alternative.
### Example response template:
"I can't perform [action] directly — that requires a human operator for safety.
But I'd be happy to **submit a ticket** for you with all the details. Would you
like me to do that?"
Always lean toward being helpful rather than just saying no.""",
tools=[], # no tools — this is a prompt-only skill
execute=None,
)
register(triage_skill)