feat(agent): cross-char search_items tool + bump timeouts

Adds an MCP tool wrapping the inventory-service /search/items endpoint
with include_all_characters=true, so questions like 'find me a bracelet
with Legendary Acid Ward on any unequipped char' resolve in ONE tool call
instead of looping get_inventory over 60+ chars (which timed out at 120s).

- agent/tools.py: search_items_global wrapper
- agent/mcp_overlord.py: register new tool with detailed schema doc
- agent/claude_wrapper.py: include in --allowed-tools whitelist;
  bump timeout 120s -> 240s
- nginx/overlord.conf: bump /api/agent/ proxy timeout 180s -> 300s
- CLAUDE.md: brief Claude to USE search_items for cross-char searches
This commit is contained in:
Erik 2026-04-25 21:13:26 +02:00
parent d3943e894c
commit 4ae18536be
5 changed files with 82 additions and 8 deletions

View file

@ -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_live_players` — current online characters with positions/kills/state
- `get_recent_rares` — rare item finds in the last N hours - `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 - `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 ### 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. 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. 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. 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. 4. **Real numbers, real names.** Cite actual character names and counts from tools — never make up sample data.

View file

@ -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 # 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 # while when chaining many tool calls; we don't want to leave a zombie
# subprocess if something gets stuck. # 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 @dataclass
@ -82,6 +82,7 @@ async def ask_claude(message: str, session_id: str) -> ClaudeResult:
"mcp__overlord__get_player_state", "mcp__overlord__get_player_state",
"mcp__overlord__get_inventory", "mcp__overlord__get_inventory",
"mcp__overlord__get_inventory_search", "mcp__overlord__get_inventory_search",
"mcp__overlord__search_items",
"mcp__overlord__get_combat_stats", "mcp__overlord__get_combat_stats",
"mcp__overlord__get_equipment_cantrips", "mcp__overlord__get_equipment_cantrips",
"mcp__overlord__get_quest_status", "mcp__overlord__get_quest_status",

View file

@ -127,10 +127,8 @@ TOOL_DEFS: dict[str, dict[str, Any]] = {
}, },
"get_inventory_search": { "get_inventory_search": {
"description": ( "description": (
"Filtered inventory search for one character. Pass filter query " "Filtered inventory search for ONE character. Use search_items "
"params as the `filters` object. Common filters: name (substring), " "instead when the user wants to find something across ALL chars."
"armor_level_min, armor_level_max, material, item_set, has_spell. "
"Returns matching items in the same shape as get_inventory."
), ),
"schema": { "schema": {
"type": "object", "type": "object",
@ -147,6 +145,39 @@ TOOL_DEFS: dict[str, dict[str, Any]] = {
str(args["character_name"]), args.get("filters") or {} 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": { "get_combat_stats": {
"description": ( "description": (
"Lifetime + session combat stats for one character. Includes total " "Lifetime + session combat stats for one character. Includes total "

View file

@ -297,6 +297,40 @@ async def get_inventory_search(
return resp.json() 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]: async def get_combat_stats(character_name: str) -> dict[str, Any]:
"""Lifetime + session combat stats for one character (per-element split, """Lifetime + session combat stats for one character (per-element split,
monster encounters, surge counts).""" monster encounters, surge counts)."""

View file

@ -64,8 +64,11 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass_request_headers on; proxy_pass_request_headers on;
proxy_read_timeout 180s; # Heavy tool calls (cross-char search, suitbuilder) can take a while;
proxy_send_timeout 180s; # 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 # API endpoints (live, trails, history, stats) — short-lived HTTP