diff --git a/CLAUDE.md b/CLAUDE.md index cb4e0e51..8d0ea7cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,11 +150,16 @@ When invoked through the dashboard's chat window (the **🤖 Assistant** button) - `get_live_players` — current online characters with positions/kills/state - `get_recent_rares` — rare item finds in the last N hours - `query_telemetry_db` — read-only SQL on the telemetry DB for ad-hoc analysis -- (more tools added over time — call `list_tools` if unsure) +- `search_items` — **cross-character** inventory search (use this instead of looping `get_inventory` per character — single call is much faster) +- `get_inventory` / `get_inventory_search` — single-character inventory +- `get_player_state` / `get_combat_stats` / `get_equipment_cantrips` — per-character lookups +- `get_quest_status` / `get_server_health` — global state +- `suitbuilder_search` — armor optimization (slow, only on explicit request) ### Behaviour rules 1. **Use tools, don't speculate.** If the user asks "how many chars are online" — call `get_live_players`. Don't say "I'd need to check" — just check. +1a. **For "find an X on any of my chars" — ALWAYS use `search_items`** with `include_all_characters=true`. Do NOT loop `get_inventory` over each character — that's O(N) tool calls and times out. 2. **Be concise.** The user is glancing at a chat window, not reading a report. 2-5 sentences for most answers. Use markdown tables for tabular data. 3. **No code unless asked.** This mode is about *operating* the system, not editing it. Don't open files or write code unless the user explicitly asks. 4. **Real numbers, real names.** Cite actual character names and counts from tools — never make up sample data. diff --git a/agent/claude_wrapper.py b/agent/claude_wrapper.py index 338fb53b..af23432f 100644 --- a/agent/claude_wrapper.py +++ b/agent/claude_wrapper.py @@ -27,7 +27,7 @@ CLAUDE_CWD = os.getenv("CLAUDE_CWD", "/home/erik/MosswartOverlord") # Hard cap on how long a single agent turn may take. Claude Code can spin a # while when chaining many tool calls; we don't want to leave a zombie # subprocess if something gets stuck. -CLAUDE_TIMEOUT_S = int(os.getenv("CLAUDE_TIMEOUT_S", "120")) +CLAUDE_TIMEOUT_S = int(os.getenv("CLAUDE_TIMEOUT_S", "240")) @dataclass @@ -82,6 +82,7 @@ async def ask_claude(message: str, session_id: str) -> ClaudeResult: "mcp__overlord__get_player_state", "mcp__overlord__get_inventory", "mcp__overlord__get_inventory_search", + "mcp__overlord__search_items", "mcp__overlord__get_combat_stats", "mcp__overlord__get_equipment_cantrips", "mcp__overlord__get_quest_status", diff --git a/agent/mcp_overlord.py b/agent/mcp_overlord.py index 8ad0f31a..320f3131 100644 --- a/agent/mcp_overlord.py +++ b/agent/mcp_overlord.py @@ -127,10 +127,8 @@ TOOL_DEFS: dict[str, dict[str, Any]] = { }, "get_inventory_search": { "description": ( - "Filtered inventory search for one character. Pass filter query " - "params as the `filters` object. Common filters: name (substring), " - "armor_level_min, armor_level_max, material, item_set, has_spell. " - "Returns matching items in the same shape as get_inventory." + "Filtered inventory search for ONE character. Use search_items " + "instead when the user wants to find something across ALL chars." ), "schema": { "type": "object", @@ -147,6 +145,39 @@ TOOL_DEFS: dict[str, dict[str, Any]] = { str(args["character_name"]), args.get("filters") or {} ), }, + "search_items": { + "description": ( + "CROSS-CHARACTER item search — one query that scans every " + "character's inventory. Use this whenever the user asks " + "'find me an X on any of my chars'. **Do not** iterate " + "get_inventory per character — this single tool call is far " + "faster and avoids agent timeouts.\n\n" + "Filter keys (pass as `filters` object, all optional):\n" + " include_all_characters: true (default if no scope given)\n" + " character: 'Name' (single char)\n" + " characters: 'A,B,C' (specific list, comma-separated)\n" + " text: substring of item name/description\n" + " has_spell: 'Legendary Acid Ward' (exact spell name match)\n" + " spell_contains: 'Legendary' (substring)\n" + " legendary_cantrips: 'Foo,Bar'\n" + " equipment_status: 'equipped' | 'unequipped'\n" + " equipment_slot: int bitmask (4=chest, 2048=bracelet, 4096=ring)\n" + " slot_names: 'Bracelet,Ring'\n" + " armor_only / jewelry_only / weapon_only: bool\n" + " min_armor / max_armor / min_damage / max_damage: int\n" + ), + "schema": { + "type": "object", + "required": ["filters"], + "properties": { + "filters": { + "type": "object", + "description": "Query params dict — see tool description for keys.", + }, + }, + }, + "fn": lambda args: T.search_items_global(args.get("filters") or {}), + }, "get_combat_stats": { "description": ( "Lifetime + session combat stats for one character. Includes total " diff --git a/agent/tools.py b/agent/tools.py index e9e743f8..554f587f 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -297,6 +297,40 @@ async def get_inventory_search( return resp.json() +async def search_items_global(filters: dict[str, Any]) -> dict[str, Any]: + """Cross-character item search via the inventory service's /search/items. + + Use this INSTEAD of looping per-character when the user asks "find an X + on any of my chars" — one DB query vs. 60+ HTTP roundtrips. + + Common filter keys (passed straight through as query params): + include_all_characters: bool (set true to search every char) + character: str (single char) | characters: "A,B,C" + text: str (name/description substring) + has_spell: "Legendary Acid Ward" — exact spell name + spell_contains: "Legendary" — substring match + legendary_cantrips: "Foo,Bar" + equipment_status: "equipped" | "unequipped" + equipment_slot: int (bitmask: 4=chest, 2048=bracelet, 4096=ring, ...) + slot_names: "Bracelet,Ring" + armor_only / jewelry_only / weapon_only: bool + min_armor / max_armor / min_damage / max_damage: int + ...and many more — see /search/items endpoint docs. + """ + client = await _http() + # Default to all-character search if caller didn't scope; otherwise the + # endpoint refuses with a 400. + params = dict(filters or {}) + if not any( + k in params + for k in ("character", "characters", "include_all_characters") + ): + params["include_all_characters"] = True + resp = await client.get("/search/items", params=params) + resp.raise_for_status() + return resp.json() + + async def get_combat_stats(character_name: str) -> dict[str, Any]: """Lifetime + session combat stats for one character (per-element split, monster encounters, surge counts).""" diff --git a/nginx/overlord.conf b/nginx/overlord.conf index 38579ace..f61aad78 100644 --- a/nginx/overlord.conf +++ b/nginx/overlord.conf @@ -64,8 +64,11 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass_request_headers on; - proxy_read_timeout 180s; - proxy_send_timeout 180s; + # Heavy tool calls (cross-char search, suitbuilder) can take a while; + # the python wrapper caps each turn at 240s, so 300s gives some + # headroom for the round trip. + proxy_read_timeout 300s; + proxy_send_timeout 300s; } # API endpoints (live, trails, history, stats) — short-lived HTTP