small refactor of the structure

This commit is contained in:
2026-05-25 12:16:24 +02:00
parent 51e099acdd
commit b0f10b6bb1
26 changed files with 37 additions and 37 deletions
+6 -6
View File
@@ -12,7 +12,7 @@ An Agent is a lightweight wrapper:
from dataclasses import dataclass, field
from typing import Dict, List
from skills import Skill, get_combined_prompt, list_all as list_all_skills
from agents.skills import Skill, get_combined_prompt, list_all as list_all_skills
@dataclass
@@ -61,8 +61,8 @@ def load_all_agents() -> None:
import agents.media_agent # noqa: F401
# Also import skill modules so they self-register
import skills.media_info # noqa: F401
import skills.seerr # noqa: F401
import skills.triage # noqa: F401
import skills.easter_eggs # noqa: F401
import skills.watch_history # noqa: F401
import agents.skills.media_info # noqa: F401
import agents.skills.seerr # noqa: F401
import agents.skills.triage # noqa: F401
import agents.skills.easter_eggs # noqa: F401
import agents.skills.watch_history # noqa: F401
+154
View File
@@ -0,0 +1,154 @@
"""
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.
A Skill is a lightweight object with:
- name : short identifier (e.g. "media_info")
- description : human-readable summary
- 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 typing import Any, Awaitable, Callable, Dict, List, Optional
from src.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
class Skill:
name: str
description: str
prompt_fragment: str = ""
tools: List[Dict[str, Any]] = field(default_factory=list)
execute: Optional[ToolExecutor] = None
requires_auth: List[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Global skill registry — populated at startup / import time
# ---------------------------------------------------------------------------
_skill_registry: Dict[str, Skill] = {}
def register(skill: Skill) -> None:
"""Register a skill so agents can look it up by name."""
_skill_registry[skill.name] = skill
def get(name: str) -> Skill | None:
"""Return a registered skill by name, or None."""
return _skill_registry.get(name)
def list_all() -> Dict[str, Skill]:
"""Return a shallow copy of the registry."""
return dict(_skill_registry)
def get_combined_prompt(skill_names: list[str], base_prompt: str = "") -> str:
"""Build a system prompt from a base prompt + requested skill fragments."""
parts = [base_prompt] if base_prompt else []
for name in skill_names:
s = get(name)
if s and s.prompt_fragment:
parts.append(s.prompt_fragment)
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,
discord_user_id: int | None = None,
) -> ToolResult | None:
"""Find the skill that owns *tool_name* and run its executor.
If *discord_user_id* is provided, also checks whether the owning skill
requires authentication for any services. If auth is missing, returns
a friendly ToolResult.fail(...) telling the user how to log in.
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:
# --- Auth gate ---
if s.requires_auth and discord_user_id is not None:
from core import auth_store
from auth import get_auth_service
missing: list[str] = []
for svc in s.requires_auth:
if not auth_store.is_authenticated(discord_user_id, svc):
missing.append(svc)
if missing:
svc_displays = ", ".join(
(get_auth_service(m) and get_auth_service(m).display_name) or m
for m in missing
)
return ToolResult.fail(
f"You need to log in to {svc_displays} first. "
+ " ".join(f"Send `/login {m}` in a DM to get started." for m in missing)
)
# --- End auth gate ---
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
+139
View File
@@ -0,0 +1,139 @@
"""
Easter eggs skill — theme-aware persona adapter.
When a user's message contains trigger words from a known fandom/universe,
the LLM adopts that theme's persona flavor while still performing all
requested actions normally. Functionality is never sacrificed for a reference.
Add a new theme by adding one entry to THEMES — no code changes needed.
"""
from agents.skills import Skill, register
THEMES = {
"naruto": {
"triggers": [
"rasengan", "sasuke", "naruto", "kakashi", "sakura", "hokage",
"chidori", "sharingan", "kurama", "dattebayo", "believe it",
"hidden leaf", "konoha", "akatsuki", "itachi", "jiraiya",
"shippuden", "boruto", "sensei", "ninja", "tsunade", "orochimaru",
"tailed beast", "jinchuriki", "rinnegan", "byakugan", "genin",
"chunin", "jonin", "anbu", "uchiha", "hyuga", "uzumaki",
],
"persona": (
"Adopt the speaking style of a ninja from the Hidden Leaf Village. "
"If someone screems 'Rasengan!' in their request, respond with 'SOOSSKEEE!' "
"If someone screams 'SOSSSKEE!' in their request, respond with 'RESEENNGGAANN!' "
"Stay fully functional — carry out all requested actions normally, but only if something is requested"
),
},
"jeroen": {
"triggers": [
"jeroen"
],
"persona": (
"mention that jeroen is a 'Land verader' in the response "
"Stay fully functional — carry out all requested actions normally, but only if something is requested"
)
},
"chicken": {
"triggers": [
"chicken", "cluck", "bawk", "bok bok", "chook", "poultry", "BOAKKK", "BOAKY!"
],
"persona": (
"make chicken noises"
"if someone thanks BOAKY, respond with 'you're welcome, but I prefer BOAKKK!' "
"Stay fully functional — carry out all requested actions normally, but only if something is requested"
)
},
"Mexico": {
"triggers": [
"mexico", "taco", "burrito", "sombrero", "guacamole", "quesadilla",
],
"persona": (
"make sentences only with at least 80 percent of the following words: taco, burrito, sombrero, guacamole, quesadilla. "
"Stay fully functional — carry out all requested actions normally, but only if something is requested"
)
},
"nacho": {
"triggers": [
"nacho", "sharing", "plate", "snack", "chips", "cheese"
],
"persona": (
"if anywhere nacho is mentioned, mention that one of the best things is, to share a plate of nachos with Matty"
"Stay fully functional — carry out all requested actions normally, but only if something is requested"
)
},
"RockAndStone!": {
"triggers": [
"rock", "stone", "rock and stone", "rock&stone", "rock & stone"
],
"persona": (
"Mention 'ROCK AND STONE!' anywhere in the response "
"Stay fully functional — carry out all requested actions normally, but only if something is requested"
)
},
"airplane": {
"triggers": [
"airplane", "plane", "flight", "pilot", "cockpit", "turbulence"
],
"persona": (
"mention airplane noises, and say that Erwin will be terrified "
"mention something airplane related, and hope Erwin is not around to see this reference."
"Stay fully functional — carry out all requested actions normally, but only if something is requested"
)
},
"dyslexia": {
"triggers": [
"unable to read", "dyslexia", "dislexia", "dislexic", "dyslexic", "typo", "trouble reading", "misspelled", "misspelling"
],
"persona": (
"mention that tim is dyslexic and has trouble reading in the response "
"mention it is sometimes difficult to read, since the creator (which is Tim) of this agent is dyslexic. "
"Stay fully functional — carry out all requested actions normally, but only if something is requested"
)
}
}
# ---------------------------------------------------------------------------
# Build the prompt fragment from THEMES
# ---------------------------------------------------------------------------
theme_blocks = []
for name, theme in THEMES.items():
all_triggers = theme["triggers"]
# Show first 8 triggers + count of remaining
shown = all_triggers[:8]
suffix = f" (+{len(all_triggers) - 8} more)" if len(all_triggers) > 8 else ""
triggers_str = ", ".join(f'"{t}"' for t in shown) + suffix
theme_blocks.append(
f"### {name.upper()}\n"
f"Triggers (case-insensitive, substring match): {triggers_str}\n"
f"Persona: {theme['persona']}"
)
easter_eggs_skill = Skill(
name="easter_eggs",
description="Theme-aware persona adapter — flavors responses "
"when users mention known fandoms/universes.",
prompt_fragment=(
"## Themed Personas\n\n"
"Before responding, scan the user's message for these themes "
"(case-insensitive, substring match). If a theme matches, adopt its "
"persona flavor while still performing all requested actions normally. "
"Never skip functionality for the sake of a reference.\n\n"
"If multiple themes match, pick the one with the most trigger hits.\n"
"If no theme matches, respond with your normal base persona.\n\n"
+ "\n\n".join(theme_blocks)
),
tools=[],
execute=None,
)
register(easter_eggs_skill)
+41
View File
@@ -0,0 +1,41 @@
"""
Demo skill: media_info
A lightweight base skill that teaches the agent it is a media assistant.
Real API capabilities come from other skills (seerr, triage, etc.).
"""
from agents.skills import Skill, register
media_info_skill = Skill(
name="media_info",
description="Base media assistant persona — movie, TV, subtitle, and media requests.",
prompt_fragment="""## Media Assistant Persona
You are a friendly media assistant connected to a media back-end (Seerr,
Jellyfin, Sonarr, etc.). Your job is to help users discover, request, and
troubleshoot their media library.
When responding:
- 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.
## Jellyfin & Authentication
You are connected to the user's Jellyfin server. If a user asks you to
"connect to Jellyfin", "link my Jellyfin", or asks about their watch history,
simply call the `watch_history` tool. The system will automatically handle
authentication — if the user isn't linked yet, they'll be guided through
Quick Connect seamlessly. NEVER tell a user you "don't have access to
Jellyfin" or "can't connect" — always try the tool first and let the system
sort it out.
This is the base media assistant persona. Real API capabilities come from the
attached skills (seerr, triage, etc.).""",
)
register(media_info_skill)
+971
View File
@@ -0,0 +1,971 @@
"""
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_USERNAME - login username (email)
SEERR_PASSWORD - login password
SEERR_API_KEY - fallback API key (used if username/password not set)
SEERR_TIMEOUT - optional request timeout in seconds (default 30)
Auth flow:
1. If SEERR_USERNAME + SEERR_PASSWORD are set:
POST /api/v1/auth/jellyfin {username, password}
→ stores the connect.sid cookie in a persistent httpx session
→ all subsequent requests use cookie auth
2. Falls back to X-Api-Key header if SEERR_API_KEY is set.
"""
from __future__ import annotations
import json
from urllib.parse import quote
import httpx
from agents.skills import Skill, register, ToolResult, get_config
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
SEERR_URL = (get_config("SEERR_URL") or "").rstrip("/")
SEERR_USERNAME = get_config("SEERR_USERNAME") or ""
SEERR_PASSWORD = get_config("SEERR_PASSWORD") or ""
SEERR_API_KEY = get_config("SEERR_API_KEY") or ""
SEERR_TIMEOUT = int(get_config("SEERR_TIMEOUT", "30"))
# ---------------------------------------------------------------------------
# Auth — cookie-based session (preferred) or API key fallback
# ---------------------------------------------------------------------------
#
# IMPORTANT: httpx.AsyncClient binds internal asyncio primitives to the
# event loop that is current when the client is created. The Discord bot
# runs in a separate thread with its own event loop, so we must create a
# fresh AsyncClient *per event loop*. We cache one client per loop ID so
# each loop still reuses its own singleton (connection pooling works), but
# the bot and the REST API never fight over the same connection pool.
# ---------------------------------------------------------------------------
import asyncio
import threading
_seerr_sessions: dict[int, httpx.AsyncClient] = {}
_seerr_sessions_lock = threading.Lock()
# Cached login cookies — obtained once at module load (sync) and reused
# for every event-loop-specific client. A threading.Event ensures that
# the first caller to trigger the login blocks all other callers until
# the login is complete — preventing a race where a second thread builds
# a client with empty cookies before the login finishes.
_seerr_cookies: dict = {}
_seerr_cookies_ready = threading.Event()
_seerr_cookies_lock = threading.Lock()
def _ensure_cookies() -> None:
"""One-time sync login to get the connect.sid cookie.
Thread-safe: only one thread performs the login; all others block
until it finishes, then reuse the result.
"""
if _seerr_cookies_ready.is_set():
return
with _seerr_cookies_lock:
# Double-check — another thread may have finished while we waited
if _seerr_cookies_ready.is_set():
return
if SEERR_USERNAME.strip() and SEERR_PASSWORD.strip():
sync_client = httpx.Client(base_url=SEERR_URL, timeout=SEERR_TIMEOUT)
try:
resp = sync_client.post("/api/v1/auth/jellyfin", json={
"username": SEERR_USERNAME.strip(),
"password": SEERR_PASSWORD.strip(),
})
resp.raise_for_status()
_seerr_cookies.update(dict(sync_client.cookies))
except httpx.HTTPError:
pass
finally:
sync_client.close()
# Signal completion — even if login failed (empty cookies) so we
# don't retry forever.
_seerr_cookies_ready.set()
def _build_client() -> httpx.AsyncClient:
"""Create a new httpx.AsyncClient for the *current* event loop."""
if _seerr_cookies:
return httpx.AsyncClient(
base_url=SEERR_URL,
cookies=_seerr_cookies,
timeout=SEERR_TIMEOUT,
)
if SEERR_API_KEY.strip():
return httpx.AsyncClient(
base_url=SEERR_URL,
headers={"X-Api-Key": SEERR_API_KEY.strip()},
timeout=SEERR_TIMEOUT,
)
return httpx.AsyncClient(
base_url=SEERR_URL,
timeout=SEERR_TIMEOUT,
)
def _get_session() -> httpx.AsyncClient:
"""Return an AsyncClient valid for the currently-running event loop.
On the very first call the sync login is performed (if credentials are
configured). After that every event loop gets its own cached client.
"""
_ensure_cookies()
try:
loop_id = id(asyncio.get_running_loop())
except RuntimeError:
# No event loop running (e.g. called during module import).
# Build a throw-away client — the first real call will recreate it.
loop_id = 0
with _seerr_sessions_lock:
if loop_id not in _seerr_sessions:
_seerr_sessions[loop_id] = _build_client()
return _seerr_sessions[loop_id]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _SharedClient:
"""Wraps a per-loop httpx.AsyncClient so that `async with` doesn't
close it when the context exits. All 11 call sites use:
async with _client() as c:
"""
def __init__(self, client: httpx.AsyncClient) -> None:
self._client = client
async def __aenter__(self) -> httpx.AsyncClient:
return self._client
async def __aexit__(self, *args: object) -> None:
pass # do NOT close the shared session
def _client() -> _SharedClient:
"""Return a context-manager wrapper around the current loop's session."""
return _SharedClient(_get_session())
# Per-loop sessions are created lazily on first use — no eager init needed.
def _fmt_items(items: list[dict], kind: str) -> str:
"""Format a list of media items for the LLM to present.
Includes the TMDb ID so the LLM can reference it for follow-up actions."""
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 "?"
)
tmdb_id = item.get("id", "")
overview = (item.get("overview") or "")[:120]
id_tag = f" [tmdb:{tmdb_id}]" if tmdb_id else ""
lines.append(f"{i}. **{title}** ({year}){id_tag}{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_search",
"description": "Search for movies, TV shows, or people on Seerr "
"by title or name. Uses /search. Call when a user asks 'find me "
"the movie X', 'search for show Y', or 'who is actor Z?'.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search term — a movie title, "
"TV show name, or person name.",
},
"kind": {
"type": "string",
"enum": ["movie", "tv", "person", "all"],
"description": "Filter by media type. Use 'all' "
"when the user doesn't specify.",
},
"language": {
"type": "string",
"description": "Language filter (e.g. 'en'). "
"Omit for all languages.",
},
"page": {
"type": "integer",
"description": "Page number (default 1).",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "seerr_media_details",
"description": "Get full details for a specific movie or TV show "
"(cast, crew, runtime, genres, ratings, streaming providers, etc.). "
"Call when a user asks 'tell me about movie X' or 'show me details "
"for show Y'.",
"parameters": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["movie", "tv"],
"description": "Whether to look up a movie or TV show.",
},
"tmdb_id": {
"type": "integer",
"description": "The TMDb ID of the movie or TV show.",
},
"title": {
"type": "string",
"description": "Title to search for if tmdb_id is "
"not known. The system will search and use the first match.",
},
"language": {
"type": "string",
"description": "Language filter (e.g. 'en'). "
"Omit for all languages.",
},
},
"required": ["kind"],
},
},
},
{
"type": "function",
"function": {
"name": "seerr_my_requests",
"description": "Get the user's pending, approved, or completed "
"media requests from Seerr. Call when a user asks 'what have I "
"requested?', 'status of my requests?', or 'did my request go through?'.",
"parameters": {
"type": "object",
"properties": {
"filter": {
"type": "string",
"enum": ["all", "approved", "available", "pending",
"processing", "unavailable", "failed",
"deleted", "completed"],
"description": "Filter by request status. "
"Default is 'pending'.",
},
"media_type": {
"type": "string",
"enum": ["movie", "tv", "all"],
"description": "Filter by media type. "
"Default is 'all'.",
},
},
},
},
},
{
"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,
"seerr_search": _search,
"seerr_media_details": _media_details,
"seerr_my_requests": _my_requests,
}
handler = handlers.get(tool_name)
if not handler:
return ToolResult.fail(f"Unknown tool: {tool_name}")
logger.info(
"🔧 TOOL CALL: %s | args=%s",
tool_name,
{k: v for k, v in args.items() if k not in ("description",)},
)
try:
result = await handler(args)
status = "" if result.success else ""
logger.info(
"%s TOOL RESULT: %s%.300s",
status, tool_name, result.content,
)
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}
Keyword: /api/v1/search?query=keyword (free-text search, filtered by mediaType)
Studio: NOT SUPPORTED — /discover/studio requires a numeric TMDB studio ID,
not a name. Use keyword search as fallback.
Language is passed to discover or search as appropriate.
"""
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,
}
params: dict = {"page": page}
endpoint: str
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"/api/v1/discover/{'movies' if kind == 'movie' else 'tv'}/genre/{genre_id}"
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
elif studio:
# /discover/studio/{studioId} requires a numeric TMDB studio ID.
# Fall back to searching by name via /search.
desc = studio
search_query = studio
endpoint = "/api/v1/search"
params["query"] = search_query
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", [])
# Filter to requested media type
results = [item for item in results if item.get("mediaType") == kind]
elif keyword:
# Free-text keyword → use /search, filtered by mediaType
desc = keyword
endpoint = "/api/v1/search"
params["query"] = keyword
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", [])
results = [item for item in results if item.get("mediaType") == kind]
else:
# Bare discover with no filter
endpoint = f"/api/v1/discover/{'movies' if kind == 'movie' else 'tv'}"
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 = 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")
async with _client() as c:
# --- Fast-path: TMDb ID known — confirm the title and request directly ---
if tmdb_id:
# Quick lookup to get the correct title for the confirmation message
detail_r = await c.get(f"/api/v1/{kind}/{tmdb_id}")
if detail_r.status_code == 200:
detail = detail_r.json()
media_title = detail.get("title") or detail.get("name") or title
media_year = (
detail.get("releaseDate", "")[:4]
or detail.get("firstAirDate", "")[:4]
or "?"
)
else:
# Detail lookup failed — fall back to title search
pass
if detail_r.status_code == 200:
# Submit directly with the known TMDb ID
request_body: dict = {"mediaType": kind, "mediaId": tmdb_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 "
f"requested or is already available."
)
else:
return ToolResult.fail(
f"❌ Failed to request **{media_title}** ({media_year}). "
f"Seerr responded with status {req_r.status_code}: {req_r.text[:500]}"
)
# --- Slow-path: search by title ---
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 (search returns mixed movie/tv/person results)
filtered = [item for item in results if item.get("mediaType") == kind] if results else []
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."
)
# --- Ambiguity check: more than one match? ---
if len(filtered) > 1:
lines = [
f"⚠️ Multiple matches for \"{title}\". "
f"Please call `seerr_request_media` again with the "
f"correct `tmdb_id` and exact title:\n"
]
for i, item in enumerate(filtered[:10], 1):
t = item.get("title") or item.get("name", "Unknown")
y = (
item.get("releaseDate", "")[:4]
or item.get("firstAirDate", "")[:4]
or "?"
)
mid = item.get("id", "?")
lines.append(
f"{i}. **{t}** ({y}) — `kind=\"{kind}\", "
f"title=\"{t}\", tmdb_id={mid}`"
)
return ToolResult.ok("\n".join(lines))
# --- Single match — request it ---
match = filtered[0]
media_id = 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]
)
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 _search(args: dict) -> ToolResult:
"""Use Jellyseerr's /api/v1/search endpoint.
Supports filtering by mediaType (movie | tv | person).
"""
query = args["query"]
kind = args.get("kind", "all")
language = args.get("language", "").strip() or None
page = args.get("page", 1)
params: dict = {"query": quote(query), "page": page}
if language:
params["language"] = language
async with _client() as c:
r = await c.get("/api/v1/search", params=params)
r.raise_for_status()
data = r.json()
results = data.get("results", [])
# Filter by mediaType if requested
if kind != "all":
results = [item for item in results if item.get("mediaType") == kind]
label = f"search results for '{query}'"
if kind != "all":
label += f" ({kind})"
if not results:
return ToolResult.ok(f"No {label} found.")
return ToolResult.ok(_fmt_items(results, label))
async def _media_details(args: dict) -> ToolResult:
"""Fetch full details for a movie or TV show.
Resolves the TMDb ID via search if not provided.
"""
kind = args["kind"]
tmdb_id = args.get("tmdb_id")
title = args.get("title", "").strip()
language = args.get("language", "").strip() or None
params: dict = {}
if language:
params["language"] = language
async with _client() as c:
# Resolve TMDb ID if needed
if not tmdb_id and title:
sr = await c.get("/api/v1/search", params={
"query": quote(title), "page": 1,
})
sr.raise_for_status()
sresults = sr.json().get("results", [])
sresults = [item for item in sresults if item.get("mediaType") == kind]
if sresults:
tmdb_id = sresults[0].get("id")
else:
return ToolResult.fail(
f"I couldn't find {kind} '{title}' on Seerr."
)
if not tmdb_id:
return ToolResult.fail(
"I need either a TMDb ID or a title to look up media details."
)
endpoint = f"/api/v1/{kind}/{tmdb_id}"
r = await c.get(endpoint, params=params)
r.raise_for_status()
data = r.json()
# Build a concise summary for the LLM
title_str = data.get("title") or data.get("name") or "Unknown"
year = (
data.get("releaseDate", "")[:4]
or data.get("firstAirDate", "")[:4]
or "?"
)
overview = data.get("overview", "No overview available.")
runtime = data.get("runtime", "?")
vote = data.get("voteAverage", "?")
genres = ", ".join(g.get("name", "") for g in data.get("genres", [])[:5])
lines = [
f"**{title_str}** ({year}) [tmdb:{tmdb_id}]",
f"{vote}/10 | ⏱ {runtime} min | Genres: {genres or 'N/A'}",
f"",
f"{overview[:500]}",
]
# Cast (top 5)
cast = (data.get("credits", {}) or {}).get("cast", [])[:5]
if cast:
lines.append("")
lines.append("**Top Cast:** " + ", ".join(
c["name"] for c in cast
))
# Streaming providers
providers = data.get("watchProviders", [])
if providers:
flatrate = []
for region in providers:
for p in region.get("flatrate", []) or []:
if p.get("name") and p["name"] not in flatrate:
flatrate.append(p["name"])
if flatrate:
lines.append("")
lines.append("**Streaming:** " + ", ".join(flatrate[:5]))
return ToolResult.ok("\n".join(lines))
async def _my_requests(args: dict) -> ToolResult:
"""Fetch the current user's media requests from /request.
Filters and sorting are optional.
"""
filter_status = args.get("filter", "pending")
media_type = args.get("media_type", "all")
params: dict = {"filter": filter_status}
if media_type != "all":
params["mediaType"] = media_type
async with _client() as c:
r = await c.get("/api/v1/request", params=params)
r.raise_for_status()
data = r.json()
results = data.get("results", [])
if not results:
return ToolResult.ok(f"You have no {filter_status} requests right now.")
lines = []
for i, req in enumerate(results[:10], 1):
media = req.get("media", {}) or {}
title = media.get("title") or media.get("name") or "Unknown"
status = req.get("status", "?")
status_labels = {1: "Pending", 2: "Approved", 3: "Declined"}
status_str = status_labels.get(status, f"Status {status}")
is_4k = " (4K)" if req.get("is4k") else ""
lines.append(f"{i}. **{title}**{is_4k}{status_str}")
total = data.get("pageInfo", {}).get("results", len(results))
return ToolResult.ok(
f"You have {total} {filter_status} requests:\n\n" + "\n".join(lines)
)
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")
import logging
logger = logging.getLogger("skills.seerr")
body: dict = {
"issueType": int(issue_type),
"message": description,
}
if media_title:
body["message"] = f"[Media: {media_title}]\n\n{description}"
logger.info("📝 SUBMIT_ISSUE body=%s media_title=%s", body, media_title)
async with _client() as c:
# --- Resolve mediaId (Seerr internal ID for /issue endpoint) ---
if not media_id and media_title:
search_r = await c.get("/api/v1/search", params={
"query": quote(media_title), "page": 1,
})
search_r.raise_for_status()
results = search_r.json().get("results", [])
logger.info("🔍 Search for '%s'%d results", media_title, len(results))
# Filter to actual media (not persons) and prefer exact title match
media_results = [
item for item in results
if item.get("mediaType") in ("movie", "tv")
]
if media_results:
media_info = media_results[0].get("mediaInfo", {})
media_id = media_info.get("id") or media_results[0].get("id")
logger.info("🔍 Resolved mediaId=%s for '%s'", media_id, media_title)
if media_id:
body["mediaId"] = int(media_id)
logger.info("📤 POST /api/v1/issue body=%s", body)
r = await c.post("/api/v1/issue", json=body)
logger.info("📥 Response status=%s body=%s", r.status_code, r.text[:500])
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 — search, trending, discover, request media, "
"look up details, check requests, 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_search** — when a user wants to find a specific movie, show, or person
- **seerr_trending** — when a user asks what is trending/popular/new
- **seerr_discover** — when a user asks for recommendations by genre/category
- **seerr_media_details** — when a user wants full info about a movie or show
- **seerr_my_requests** — when a user asks about their pending/approved requests
- **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)
**TMDb ID Rule**: Every movie and TV show has a unique TMDb ID. When you see
`[tmdb:123456]` in search/trending/discover results, always **show it to the user**
in your response. Never strip or omit the TMDb ID when presenting results — the
user needs it to reference items for follow-up actions. Similarly, capture the ID
for any follow-up action you take (request details, submit a request, file an
issue, etc.). If you don't have a TMDb ID and need to take action on a title,
search first to get one. Never rely on title alone when an ID is available —
titles are ambiguous, IDs are not. This rule applies to all media tools, present
and future.
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 agents.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)
+80
View File
@@ -0,0 +1,80 @@
"""
Watch History skill — fetch the user's Jellyfin watch history.
Currently a placeholder — returns a "coming soon" message.
The auth gate (`requires_auth=["jellyfin"]`) is already active:
users who haven't linked Jellyfin will be prompted to /login first.
"""
from __future__ import annotations
from agents.skills import Skill, register, ToolResult
# ---------------------------------------------------------------------------
# Tool definitions
# ---------------------------------------------------------------------------
TOOLS = [
{
"type": "function",
"function": {
"name": "watch_history",
"description": (
"Get the user's recent Jellyfin watch history — movies and TV "
"episodes they have watched, sorted by most recent. "
"Call this when a user asks about their watching activity."
),
"parameters": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "How many items to return (default 10, max 20)",
}
},
},
},
}
]
# ---------------------------------------------------------------------------
# Executor (placeholder)
# ---------------------------------------------------------------------------
async def _execute(tool_name: str, args: dict) -> ToolResult:
if tool_name == "watch_history":
return ToolResult.ok(
"👷 **Watch History — Coming Soon!**\n\n"
"This feature is currently being built. Soon you'll be able to "
"see your recently watched movies and TV episodes right here.\n\n"
"In the meantime, you can check your watch history directly in Jellyfin."
)
return ToolResult.fail(f"Unknown tool: {tool_name}")
# ---------------------------------------------------------------------------
# Skill registration
# ---------------------------------------------------------------------------
watch_history_skill = Skill(
name="watch_history",
description="User's Jellyfin watch history (coming soon)",
requires_auth=["jellyfin"],
prompt_fragment="""## Watch History
You can fetch the user's Jellyfin watch history with the `watch_history` tool.
Call it when users ask things like:
- "what have I watched?"
- "show my watch history"
- "what did I watch recently?"
- "what was the last movie I saw?"
- "what TV shows have I been watching?"
The tool is currently a **placeholder** — it returns a "coming soon" message.
Tell the user this feature is being worked on and will be available soon.""",
tools=TOOLS,
execute=_execute,
)
register(watch_history_skill)