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:
parent
d3943e894c
commit
4ae18536be
5 changed files with 82 additions and 8 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 "
|
||||||
|
|
|
||||||
|
|
@ -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)."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue