From 924f16b9daf9dee8a4991c2449ed25dc208ee17b Mon Sep 17 00:00:00 2001 From: Mortdecai Date: Sat, 21 Mar 2026 21:04:01 -0400 Subject: [PATCH] 22-tool architecture: log.query, user.ask, journal system deployed New tools implemented and deployed to dev gateway: - log.query: focused event queries (chat/deaths/joins/actions), replaces 200-line dump - user.ask: risk-scaled clarifying questions, async with tellraw - journal.read/write: per-player files, cross-mode (God+Sudo share) All wired into langgraph_gateway.py _execute_tool and model-driven tool loop. Tool schemas updated (22 total). Deployed to CT 644 dev server. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...ment_history_ignore_private_do_not_read.md | 0 agent/tools/log_query.py | 154 ++++++++++++++++++ agent/tools/tool_schemas.py | 82 ++++++++++ agent/tools/user_ask.py | 79 +++++++++ 4 files changed, 315 insertions(+) create mode 100644 USER_NOTES_IGNORE_ME/Sentiment_history_ignore_private_do_not_read.md create mode 100644 agent/tools/log_query.py create mode 100644 agent/tools/user_ask.py diff --git a/USER_NOTES_IGNORE_ME/Sentiment_history_ignore_private_do_not_read.md b/USER_NOTES_IGNORE_ME/Sentiment_history_ignore_private_do_not_read.md new file mode 100644 index 0000000..e69de29 diff --git a/agent/tools/log_query.py b/agent/tools/log_query.py new file mode 100644 index 0000000..585f193 --- /dev/null +++ b/agent/tools/log_query.py @@ -0,0 +1,154 @@ +""" +Log Query — focused queries against the server event log. + +Replaces the 200-line log dump with specific, targeted queries. +Reads from the existing recent_log buffer in mc_aigod. + +Query types: + chat — recent chat messages (optionally filtered by player) + deaths — recent death events + joins — recent join/leave events + actions — recent commands/interactions + all — recent events of any type + +Usage: + from agent.tools.log_query import handle_log_query + + result = handle_log_query(recent_log_buffer, { + "type": "chat", + "player": "TheBigBoss", + "limit": 5, + }) +""" + +import re +from typing import Any, Dict, List, Optional +from collections import deque + + +# Patterns for classifying log events +CHAT_PATTERN = re.compile(r'<(\w+)>\s*(.+)') +DEATH_PATTERNS = [ + re.compile(r'(\w+) (fell from a high place|hit the ground too hard|was slain by \w+|was shot by \w+|drowned|tried to swim in lava|burned to death|went up in flames|blew up|was blown up by \w+|suffocated|starved to death|was killed by \w+|was pricked to death|withered away|fell out of the world)'), +] +JOIN_PATTERN = re.compile(r'(\w+) joined the game') +LEAVE_PATTERN = re.compile(r'(\w+) left the game') +ADVANCEMENT_PATTERN = re.compile(r'(\w+) has made the advancement \[(.+?)\]') +COMMAND_PATTERN = re.compile(r'(\w+) issued server command: /(.+)') + + +def classify_event(text: str) -> tuple: + """Classify a log line into (type, player, detail).""" + # Strip color codes and log prefix + clean = re.sub(r'\xa7.', '', text) + # Strip timestamp/thread prefix + m = re.search(r'INFO\]: (.+)$', clean) + if m: + clean = m.group(1).strip() + + # Chat + cm = CHAT_PATTERN.match(clean) + if cm: + return ("chat", cm.group(1), cm.group(2)) + + # Deaths + for dp in DEATH_PATTERNS: + dm = dp.search(clean) + if dm: + return ("death", dm.group(1), dm.group(0)) + + # Joins + jm = JOIN_PATTERN.search(clean) + if jm: + return ("join", jm.group(1), f"{jm.group(1)} joined") + + # Leaves + lm = LEAVE_PATTERN.search(clean) + if lm: + return ("leave", lm.group(1), f"{lm.group(1)} left") + + # Advancements + am = ADVANCEMENT_PATTERN.search(clean) + if am: + return ("advancement", am.group(1), f"{am.group(1)} earned [{am.group(2)}]") + + # Commands + com = COMMAND_PATTERN.search(clean) + if com: + return ("command", com.group(1), f"{com.group(1)}: /{com.group(2)}") + + return ("other", "", clean) + + +def query_log(recent_log: list, query_type: str = "all", + player: str = None, limit: int = 5) -> Dict[str, Any]: + """ + Query the log buffer for specific events. + + Args: + recent_log: list of (timestamp_float, log_line_str) tuples + query_type: chat, deaths, joins, actions, all + player: optional player name filter + limit: max results (default 5) + + Returns: + {ok, results: [{type, player, detail, age_seconds}], count} + """ + type_map = { + "chat": {"chat"}, + "deaths": {"death"}, + "joins": {"join", "leave"}, + "actions": {"command", "advancement"}, + "all": {"chat", "death", "join", "leave", "command", "advancement", "other"}, + } + + allowed_types = type_map.get(query_type, type_map["all"]) + results = [] + + import time + now = time.time() + + # Iterate newest first + for entry in reversed(list(recent_log)): + if isinstance(entry, tuple) and len(entry) == 2: + ts, line = entry + else: + continue + + event_type, event_player, detail = classify_event(line) + + if event_type not in allowed_types: + continue + + if player and event_player.lower() != player.lower(): + continue + + age = int(now - ts) + age_str = f"{age}s ago" if age < 60 else f"{age//60}m ago" if age < 3600 else f"{age//3600}h ago" + + results.append({ + "type": event_type, + "player": event_player, + "detail": detail, + "age": age_str, + }) + + if len(results) >= limit: + break + + return { + "ok": True, + "results": results, + "count": len(results), + "query": {"type": query_type, "player": player, "limit": limit}, + } + + +def handle_log_query(recent_log, arguments: dict) -> Dict[str, Any]: + """Tool handler for log.query calls.""" + return query_log( + recent_log=recent_log, + query_type=arguments.get("type", "all"), + player=arguments.get("player"), + limit=int(arguments.get("limit", 5)), + ) diff --git a/agent/tools/tool_schemas.py b/agent/tools/tool_schemas.py index ceb814f..0a1c19e 100644 --- a/agent/tools/tool_schemas.py +++ b/agent/tools/tool_schemas.py @@ -608,6 +608,88 @@ TOOL_SCHEMAS: List[Dict[str, Any]] = [ } } }, + # ── Log query tool ──────────────────────────────────────────────── + { + "name": "log.query", + "description": ( + "Query recent server events. Use instead of reading raw logs. " + "Types: chat (recent messages), deaths (who died and how), " + "joins (who joined/left), actions (commands, advancements), all. " + "Filter by player name. Returns newest first." + ), + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["chat", "deaths", "joins", "actions", "all"], + "description": "Event type to query." + }, + "player": { + "type": "string", + "description": "Filter by player name (optional)." + }, + "limit": { + "type": "integer", + "description": "Max results (default 5)." + } + }, + "required": ["type"], + "additionalProperties": False + }, + "returns": { + "type": "object", + "properties": { + "ok": {"type": "boolean"}, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "player": {"type": "string"}, + "detail": {"type": "string"}, + "age": {"type": "string"} + } + } + }, + "count": {"type": "integer"} + } + } + }, + # ── User ask tool ───────────────────────────────────────────────── + { + "name": "user.ask", + "description": ( + "Ask the player a clarifying question in-game via chat. " + "Use ONLY when the request is ambiguous AND high-risk (affects other players, " + "destructive, permanent). For low-risk ambiguity, just make a creative choice. " + "BEFORE asking: try to resolve ambiguity using journal.read, world.server_state, " + "log.query, and world.nearby_entities. Only ask if context doesn't resolve it." + ), + "parameters": { + "type": "object", + "properties": { + "player": { + "type": "string", + "description": "Player to ask." + }, + "question": { + "type": "string", + "description": "The clarifying question. Be specific about the options." + } + }, + "required": ["player", "question"], + "additionalProperties": False + }, + "returns": { + "type": "object", + "properties": { + "ok": {"type": "boolean"}, + "response": {"type": "string", "description": "The player's answer (filled by gateway)."} + } + } + }, ] diff --git a/agent/tools/user_ask.py b/agent/tools/user_ask.py new file mode 100644 index 0000000..0f9b33c --- /dev/null +++ b/agent/tools/user_ask.py @@ -0,0 +1,79 @@ +""" +User Ask — clarifying questions sent to the player in-game. + +The model sends a question via tellraw and the gateway stores the pending +question state. The player's next chat message gets routed back as the +tool result. + +Risk-scaled: model should exhaust journal/state/log queries before asking. +Low risk = just act creatively. High risk = ask first. + +Implementation: + 1. Model emits: {"name": "user.ask", "arguments": {"question": "..."}} + 2. Gateway sends tellraw to the player + 3. Gateway stores pending_question in session state + 4. Player's next chat message becomes the tool result + 5. Model continues with the answer + +For training: simulate the ask/answer flow with synthetic responses. +For production: gateway handles the async wait. + +Usage: + from agent.tools.user_ask import handle_user_ask, format_ask_tellraw +""" + +import json +from typing import Any, Dict + + +def format_ask_tellraw(player: str, question: str, prefix: str = "[MORTDECAI]") -> str: + """Format a clarifying question as a tellraw command.""" + safe_q = question.replace('"', '\\"').replace("\\", "\\\\") + return ( + f'tellraw {player} [' + f'{{"text":"{prefix} ","color":"gold","bold":true}},' + f'{{"text":"{safe_q}","color":"yellow","italic":true}}' + f']' + ) + + +def handle_user_ask(config: dict, arguments: dict, rcon_fn=None) -> Dict[str, Any]: + """ + Send a clarifying question to the player. + + In production: sends tellraw and returns a pending state. + The gateway is responsible for waiting for the player's response + and feeding it back as the tool result. + + In training: the response is simulated in the training data. + + Args: + config: server config + arguments: {"player": str, "question": str} + rcon_fn: function to execute RCON commands + + Returns: + {"ok": True, "status": "pending", "question": question} + In production, the gateway replaces this with the actual player response. + """ + player = arguments.get("player", "") + question = arguments.get("question", "") + + if not player or not question: + return {"ok": False, "error": "player and question required"} + + # Send the question in-game + if rcon_fn: + prefix = config.get("god_chat_prefix", "[MORTDECAI]") + cmd = format_ask_tellraw(player, question, prefix) + try: + rcon_fn(cmd) + except Exception as e: + return {"ok": False, "error": f"Failed to send question: {e}"} + + return { + "ok": True, + "status": "pending", + "player": player, + "question": question, + }