From 2adf17493ae748a3b33f0448a75a8c5c8682d3a0 Mon Sep 17 00:00:00 2001 From: TimHoogervorst Date: Thu, 14 May 2026 14:25:48 +0200 Subject: [PATCH] fixed api calls with seerr, added full context for models, beginning to standardizing single id as source of truths for future tools --- .gitignore | 1 + api/ARCHITECTURE.md | 221 ++++++++++++++++++++++ api/dependencies.py | 2 +- api/v1/chat.py | 185 ++++++++---------- skills/seerr.py | 444 +++++++++++++++++++++++++++++++++++++++----- 5 files changed, 692 insertions(+), 161 deletions(-) create mode 100644 api/ARCHITECTURE.md diff --git a/.gitignore b/.gitignore index 36b13f1..089e111 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,4 @@ cython_debug/ # PyPI configuration file .pypirc +.docs/ \ No newline at end of file diff --git a/api/ARCHITECTURE.md b/api/ARCHITECTURE.md new file mode 100644 index 0000000..9034573 --- /dev/null +++ b/api/ARCHITECTURE.md @@ -0,0 +1,221 @@ +# API Architecture — Agent + Skill + Tool Pipeline + +This document explains how the API routes user messages through the agent/skill/tool pipeline to produce responses. + +--- + +## Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ OpenWebUI / Client │ +│ POST /v1/chat/completions { model, messages, stream } │ +└──────────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ api/v1/chat.py — chat_completions() │ +│ │ +│ 1. _resolve_agent(req.model) → Agent │ +│ 2. agent.build_system_prompt() → system prompt │ +│ 3. Build full_messages = [system] + req.messages │ +│ 4. run_agent_with_tools(client, messages, agent_id) │ +└──────────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Tool-Calling Loop (run_agent_with_tools / run_agent_stream) │ +│ │ +│ while turns < max_turns: │ +│ response = LLM.chat(messages, tools=agent_tools) │ +│ if response has tool_calls: │ +│ for each tool_call: │ +│ result = execute_tool(skills, name, args) │ +│ append result to messages │ +│ else: │ +│ return response.text (stream tokens if streaming) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Key Concepts + +### 1. Agent + +An **Agent** is a persona + skill bundle. Defined in `agents/`. + +```python +# agents/media_agent.py +Agent( + agent_id="media-agent", + description="Media assistant with Seerr integration", + skills=["media_info", "seerr", "triage"], + base_prompt="You are a media assistant...", +) +``` + +- `agent_id` — unique name, exposed as a model in OpenWebUI +- `skills` — list of skill names to load +- `base_prompt` — starting system prompt, combined with skill fragments +- `build_system_prompt()` — merges base_prompt + all skill prompt fragments + +Agents self-register at import time via `agents/__init__.py`'s `register()`. +`main.py` calls `load_all_agents()` at startup to import all agent/skill modules. + +### 2. Skill + +A **Skill** is a capability bundle. Defined in `skills/`. + +```python +# skills/seerr.py +Skill( + name="seerr", + description="Seerr integration — trending, discover, request media, submit issues", + prompt_fragment="## Seerr Media Tools\n...", + tools=[...], # OpenAI function-calling schema + execute=_execute, # async handler: tool_name + args → ToolResult +) +``` + +- `prompt_fragment` — injected into the agent's system prompt. Teaches the LLM what tools are available and when to use them. +- `tools` — list of OpenAI function definitions (name, description, parameters). +- `execute` — async callable that routes tool calls to API handlers. + +### 3. Tool + +A **Tool** is a single function the LLM can call. Defined as part of a skill's `tools` list. + +```python +{ + "type": "function", + "function": { + "name": "seerr_trending", + "description": "Get trending movies and TV shows from Seerr...", + "parameters": { + "type": "object", + "properties": { + "kind": {"type": "string", "enum": ["movie", "tv", "all"]}, + "language": {"type": "string"}, + }, + "required": ["kind"], + }, + }, +} +``` + +When the LLM responds with a tool call, the loop: +1. Extracts `function.name` (e.g. `"seerr_trending"`) and `function.arguments` (e.g. `{"kind": "movie"}`) +2. Calls `execute_tool(agent.skills, name, args)` which finds the owning skill and runs it +3. Appends the result text to the message history +4. Sends back to the LLM for a follow-up response + +--- + +## Full Request Flow + +### Step-by-step: "What are trending movies?" + +``` +1. OpenWebUI sends: + POST /v1/chat/completions + { + "model": "media-agent", + "messages": [ + {"role": "user", "content": "What are trending movies?"} + ], + "stream": false + } + +2. chat_completions(): + → _resolve_agent(model="media-agent") + → get_agent("media-agent") → Agent(skills=["media_info", "seerr", "triage"]) + → tools = get_all_tools(["media_info", "seerr", "triage"]) + → Returns 7 tool definitions from seerr.py + → system_prompt = agent.build_system_prompt() + → base_prompt + media_info fragment + seerr fragment + triage fragment + +3. run_agent_with_tools() — Turn 1: + → LLM receives: [system prompt with tools] + [user: "What are trending movies?"] + → LLM responds: tool_calls = [{"function": {"name": "seerr_trending", "arguments": {"kind": "movie"}}}] + +4. Execute tool: + → execute_tool(["media_info", "seerr", "triage"], "seerr_trending", {"kind": "movie"}) + → Finds seerr skill → calls _execute("seerr_trending", ...) → _trending(args) + → GET /api/v1/discover/trending?mediaType=movie + → Returns formatted list with [tmdb:IDs] + +5. run_agent_with_tools() — Turn 2: + → LLM receives: previous messages + [tool: "Found 20 trending movies..."] + → LLM responds: text = "Here are the top trending movies! 🎬 ..." + → finish_reason="stop" → return the text + +6. chat_completions() returns: + { "choices": [{"message": {"content": "Here are the top trending movies!..."}}] } +``` + +### Step-by-step: "Request the 2026 one" (multi-turn context) + +``` +1. OpenWebUI sends the FULL history: + { + "model": "media-agent", + "messages": [ + {"role": "user", "content": "What are trending movies?"}, + {"role": "assistant", "content": "Here are the top 10 trending movies! + 1. **Mortal Kombat II** (2026) [tmdb:931285] — ..."}, + {"role": "user", "content": "could request the mortal kombat one?"}, + {"role": "assistant", "content": "There are several Mortal Kombat entries! ..."}, + {"role": "user", "content": "the 2026 one"} + ] + } + +2. chat_completions(): + → req.messages contains the ENTIRE conversation history + → System prompt prepended → full_messages = [system] + 5 history messages + → LLM sees everything: the trending list with [tmdb:931285], the disambiguation, "the 2026 one" + +3. LLM reasons: + - I previously listed Mortal Kombat II (2026) with [tmdb:931285] + - The user said "request the mortal kombat one" → I searched and showed 4 options + - Now they say "the 2026 one" → that matches Mortal Kombat II (2026) [tmdb:931285] + - I should call seerr_request_media(kind="movie", title="Mortal Kombat II", tmdb_id=931285) + +4. Tool executes the request → ✅ Success +``` + +--- + +## File Map + +``` +main.py # FastAPI app entry point, creates singletons +├── core/ +│ ├── config.py # .env loader, config constants +│ └── llm.py # create_client() factory for OpenAI client +├── api/ +│ ├── dependencies.py # FastAPI Depends: get_llm_client() +│ └── v1/ +│ └── chat.py # APIRouter, endpoints, tool-calling loop +├── agents/ +│ ├── __init__.py # Agent dataclass, registry, load_all_agents() +│ ├── naked.py # Agent: barebone LLM, no skills +│ └── media_agent.py # Agent: media assistant with Seerr skills +└── skills/ + ├── __init__.py # Skill dataclass, ToolResult, registry, execution + ├── media_info.py # Skill: base media assistant persona (prompt-only) + ├── seerr.py # Skill: Seerr API tools (7 tools, real API calls) + └── triage.py # Skill: fallback for unsupported actions (prompt-only) +``` + +## Key Design Decisions + +1. **Full multi-turn history**: `req.messages` passes through unchanged. The LLM has access to its own previous responses (including `[tmdb:IDs]`). No external state management needed. + +2. **No deterministic pre-processing**: No affirmation detectors, reference resolvers, or hardcoded rules. The LLM interprets user intent naturally from full conversation context. + +3. **Agent selection via `model` field**: OpenWebUI sends `model` in the request. `_resolve_agent()` maps it to a registered agent. The `/v1/models` endpoint lists all agents as selectable models. + +4. **Skills = prompts + tools**: Skills inject prompt fragments AND optionally expose OpenAI function-calling tools. Prompt-only skills (like `triage`) just shape behavior. Tool-enabled skills (like `seerr`) let the LLM take real actions. + +5. **Singleton LLM client**: Created once in `main.py`, stored on `app.state.llm_client`, accessed via FastAPI `Depends(get_llm_client)`. diff --git a/api/dependencies.py b/api/dependencies.py index fe41741..fc19b7a 100644 --- a/api/dependencies.py +++ b/api/dependencies.py @@ -3,5 +3,5 @@ from openai import OpenAI def get_llm_client(request: Request) -> OpenAI: - """FastAPI dependency - returns the singleton OpenAI client from app.state.""" + """FastAPI dependency — returns the singleton OpenAI client from app.state.""" return request.app.state.llm_client diff --git a/api/v1/chat.py b/api/v1/chat.py index d9ad5cf..70365a5 100644 --- a/api/v1/chat.py +++ b/api/v1/chat.py @@ -7,7 +7,7 @@ import asyncio from api.dependencies import get_llm_client from agents import get as get_agent, list_all as list_all_agents -from skills import get_all_tools, execute_tool, ToolResult +from skills import get_all_tools, execute_tool router = APIRouter() @@ -15,7 +15,7 @@ router = APIRouter() class ChatRequest(BaseModel): message: str session_id: str | None = None - agent_id: str | None = None # which agent to use ("naked", "media-agent", …) + agent_id: str | None = None class ChatCompletionRequest(BaseModel): @@ -30,7 +30,6 @@ class ChatCompletionRequest(BaseModel): def _resolve_agent(agent_id: str | None = None, model: str | None = None): """ - Resolution order: 1. explicit agent_id 2. model field (OpenWebUI sends this — maps to agent_id if registered) 3. fallback to "naked" @@ -48,23 +47,18 @@ def _resolve_agent(agent_id: str | None = None, model: str | None = None): async def run_agent_with_tools( client: OpenAI, - message: str, + messages: list[dict], 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. - """ + """Send messages to the LLM with tool definitions. Tool-calling loop.""" 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}, - ] + full_messages: list[dict] = [{"role": "system", "content": system_prompt}] + full_messages.extend(messages) loop = asyncio.get_running_loop() @@ -73,129 +67,89 @@ async def run_agent_with_tools( None, lambda: client.chat.completions.create( model="deepseek-chat", - messages=messages, + messages=full_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)) - + full_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, + result = tr.content if tr else f"Tool '{fn_name}' is not available." + full_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, - message: str, - agent_id: str | None = None, - model: str | None = None, -) -> str: - """Plain LLM call — no tools. Used when the agent has no tool-enabled skills.""" - agent = _resolve_agent(agent_id, model) - response = client.chat.completions.create( - model="deepseek-chat", - messages=[ - {"role": "system", "content": agent.build_system_prompt()}, - {"role": "user", "content": message}, - ], - ) - return response.choices[0].message.content - - # --------------------------------------------------------------------------- # Streaming generators # --------------------------------------------------------------------------- async def _stream_with_tools( client: OpenAI, - message: str, + messages: list[dict], 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). - """ + """Streaming tool-calling loop. Tools run silently, final text is streamed.""" 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}, - ] + full_messages: list[dict] = [{"role": "system", "content": system_prompt}] + full_messages.extend(messages) 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, + messages=full_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)) + full_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({ + result = tr.content if tr else f"Tool '{fn_name}' is not available." + full_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, + model="deepseek-chat", messages=full_messages, stream=True, ) for chunk in stream: delta = chunk.choices[0].delta @@ -209,12 +163,12 @@ async def _stream_with_tools( return yield token - yield "…" + yield "\u2026" async def run_agent_stream( client: OpenAI, - message: str, + messages: list[dict], agent_id: str | None = None, model: str | None = None, ): @@ -223,22 +177,20 @@ async def run_agent_stream( tools = get_all_tools(agent.skills) if tools: - async for token in _stream_with_tools(client, message, agent_id, model): + async for token in _stream_with_tools(client, messages, agent_id, model): yield token return # No tools — simple streaming system_prompt = agent.build_system_prompt() + full_messages: list[dict] = [{"role": "system", "content": system_prompt}] + full_messages.extend(messages) + loop = asyncio.get_running_loop() def _sync_stream(): stream = client.chat.completions.create( - model="deepseek-chat", - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": message}, - ], - stream=True, + model="deepseek-chat", messages=full_messages, stream=True, ) for chunk in stream: delta = chunk.choices[0].delta @@ -263,15 +215,17 @@ def root(): @router.post("/chat") -async def chat(req: ChatRequest, client: OpenAI = Depends(get_llm_client)): - """Streaming chat endpoint — returns Server-Sent Events.""" +async def chat( + req: ChatRequest, + client: OpenAI = Depends(get_llm_client), +): + """Streaming chat — single message, no history.""" + messages = [{"role": "user", "content": req.message}] + async def event_stream(): - async for token in run_agent_stream( - client, req.message, req.agent_id, - ): + async for token in run_agent_stream(client, messages, req.agent_id): payload = json.dumps({"token": token, "session_id": req.session_id}) yield f"data: {payload}\n\n" - yield f"data: {json.dumps({'done': True, 'session_id': req.session_id})}\n\n" return StreamingResponse( @@ -286,24 +240,34 @@ async def chat(req: ChatRequest, client: OpenAI = Depends(get_llm_client)): @router.post("/chat/sync") -async def chat_sync(req: ChatRequest, client: OpenAI = Depends(get_llm_client)): - """Non-streaming endpoint — uses tool-calling when the agent has tools.""" +async def chat_sync( + req: ChatRequest, + client: OpenAI = Depends(get_llm_client), +): + """Non-streaming chat — single message.""" agent = _resolve_agent(req.agent_id) tools = get_all_tools(agent.skills) + messages = [{"role": "user", "content": req.message}] if tools: - response = await run_agent_with_tools( - client, req.message, req.agent_id, - ) + response = await run_agent_with_tools(client, messages, req.agent_id) else: - response = run_agent_simple(client, req.message, req.agent_id) + agent_obj = _resolve_agent(req.agent_id) + resp = client.chat.completions.create( + model="deepseek-chat", + messages=[ + {"role": "system", "content": agent_obj.build_system_prompt()}, + {"role": "user", "content": req.message}, + ], + ) + response = resp.choices[0].message.content return {"response": response, "session_id": req.session_id} @router.get("/agents") def list_agents(): - """Return all registered agents with their ids, descriptions, and skills.""" + """Return all registered agents.""" return { "agents": [ { @@ -318,7 +282,7 @@ def list_agents(): @router.get("/models") def list_models(): - """Return all registered agents as selectable models for OpenWebUI.""" + """Return agents as selectable models for OpenWebUI.""" return { "object": "list", "data": [ @@ -339,36 +303,28 @@ async def chat_completions( client: OpenAI = Depends(get_llm_client), ): """OpenAI-compatible /chat/completions — supports stream=True. - Resolves the agent from the model field (OpenWebUI sends this). + Multi-turn: req.messages contains the FULL conversation history. + Agent resolved from the model field (OpenWebUI sends this). """ - user_message = req.messages[-1]["content"] agent = _resolve_agent(model=req.model) if req.stream: async def sse_stream(): - async for token in run_agent_stream(client, user_message, agent_id=agent.agent_id): + async for token in run_agent_stream( + client, req.messages, agent_id=agent.agent_id, + ): chunk = { "id": "chatcmpl-local", "object": "chat.completion.chunk", "choices": [ - { - "index": 0, - "delta": {"content": token}, - "finish_reason": None, - } + {"index": 0, "delta": {"content": token}, "finish_reason": None} ], } yield f"data: {json.dumps(chunk)}\n\n" final_chunk = { "id": "chatcmpl-local", "object": "chat.completion.chunk", - "choices": [ - { - "index": 0, - "delta": {}, - "finish_reason": "stop", - } - ], + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], } yield f"data: {json.dumps(final_chunk)}\n\n" yield "data: [DONE]\n\n" @@ -376,18 +332,23 @@ async def chat_completions( return StreamingResponse( sse_stream(), media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - }, + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, ) - # Non-streaming path + # Non-streaming — full history, tool-calling tools = get_all_tools(agent.skills) if tools: - response = await run_agent_with_tools(client, user_message, agent_id=agent.agent_id) + response = await run_agent_with_tools( + client, req.messages, agent_id=agent.agent_id, + ) else: - response = run_agent_simple(client, user_message, agent_id=agent.agent_id) + system_prompt = agent.build_system_prompt() + full_msgs: list[dict] = [{"role": "system", "content": system_prompt}] + full_msgs.extend(req.messages) + resp = client.chat.completions.create( + model="deepseek-chat", messages=full_msgs, + ) + response = resp.choices[0].message.content return { "id": "chatcmpl-local", @@ -401,4 +362,4 @@ async def chat_completions( "finish_reason": "stop", } ], - } \ No newline at end of file + } diff --git a/skills/seerr.py b/skills/seerr.py index 05ab870..f806bfb 100644 --- a/skills/seerr.py +++ b/skills/seerr.py @@ -37,7 +37,8 @@ def _client() -> httpx.AsyncClient: def _fmt_items(items: list[dict], kind: str) -> str: - """Format a list of media items for the LLM to present.""" + """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" @@ -46,8 +47,10 @@ def _fmt_items(items: list[dict], kind: str) -> str: or item.get("firstAirDate", "")[:4] or "?" ) + tmdb_id = item.get("id", "") overview = (item.get("overview") or "")[:120] - lines.append(f"{i}. **{title}** ({year}) — {overview}…") + 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) @@ -160,6 +163,104 @@ TOOLS = [ }, }, }, + { + "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": { @@ -218,6 +319,9 @@ async def _execute(tool_name: str, args: dict) -> ToolResult: "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: @@ -277,9 +381,11 @@ async def _trending(args: dict) -> ToolResult: 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. + 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() @@ -298,9 +404,8 @@ async def _discover(args: dict) -> ToolResult: "war": 10752, "western": 37, } - base = f"/api/v1/discover/{'movies' if kind == 'movie' else 'tv'}" params: dict = {"page": page} - endpoint = base + endpoint: str if genre: genre_id = genre_map.get(genre.lower()) @@ -309,22 +414,52 @@ async def _discover(args: dict) -> ToolResult: f"I don't recognise the genre '{genre}'. " f"Try one of: {', '.join(sorted(genre_map.keys()))}." ) - endpoint = f"{base}/genre/{genre_id}" + 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: - endpoint = f"{base}/studio/{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 - 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", []) + 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 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")) @@ -335,16 +470,52 @@ async def _request_media(args: dict) -> ToolResult: 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}) + # --- 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 if we have results from unified search + # 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: - filtered = results # fallback if mediaType not set if not filtered: return ToolResult.fail( @@ -352,25 +523,35 @@ async def _request_media(args: dict) -> ToolResult: 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] + # --- 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)) - # Seerr's request endpoint expects the local mediaInfo.id - media_info = match.get("mediaInfo", {}) - media_id = media_info.get("id") or match.get("id") + # --- 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] ) - # Step 2: Submit the request request_body = { "mediaType": kind, "mediaId": media_id, @@ -398,6 +579,154 @@ async def _request_media(args: dict) -> ToolResult: ) +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"] @@ -407,23 +736,28 @@ async def _submit_issue(args: dict) -> ToolResult: 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) --- + # --- 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}) - 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") + 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", []) + + # 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") if media_id: body["mediaId"] = int(media_id) @@ -449,18 +783,32 @@ async def _submit_issue(args: dict) -> ToolResult: # --------------------------------------------------------------------------- seerr_skill = Skill( name="seerr", - description="Seerr integration — trending, discover, request media, submit issues", + 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,