mistle/mud_tools.py

173 lines
5.5 KiB
Python

from __future__ import annotations
import os
from importlib import import_module
from typing import Optional, Type
from tools import Tool
DEFAULT_LLM_MODEL = "mistral/mistral-small"
LLM_MODEL_ENV = "MISTLE_LLM_MODEL"
TOOL_REGISTRY = {
"look": {
"module": "tools",
"class": "LookTool",
"kwargs": {},
"description": "Sends the 'schau' look command to refresh the room description.",
},
"simple": {
"module": "tools",
"class": "LookTool",
"kwargs": {},
"description": "Alias of 'look'. Sends the 'schau' look command to refresh the room description.",
},
"move": {
"module": "movement_tool",
"class": "MovementTool",
"kwargs": {},
"description": "Looks around and moves in one available direction, chosen randomly among unvisited exits.",
},
"movement": {
"module": "movement_tool",
"class": "MovementTool",
"kwargs": {},
"description": "Alias of 'move'. Looks around and moves in one available direction, chosen randomly among unvisited exits.",
},
"explore": {
"module": "tools",
"class": "ExploreTool",
"kwargs": {},
"description": (
"Sends 'schau' once, then 'untersuche <noun>' for each noun found in the "
"room description. Supports targeted mode: 'explore <target1>,<target2>'."
),
},
"communication": {
"module": "tools",
"class": "CommunicationTool",
"kwargs": {},
"description": "Responds to private tells with a friendly greeting via 'teile <player> mit ...'.",
},
"intelligent": {
"module": "intelligent_tool",
"class": "IntelligentCommunicationTool",
"kwargs": {},
"description": "Uses an LLM to craft a polite reply to private tells.",
},
"intelligentcommunication": {
"module": "intelligent_tool",
"class": "IntelligentCommunicationTool",
"kwargs": {},
"description": "Alias of 'intelligent'. Uses an LLM to craft a polite reply to private tells.",
},
}
TOOL_DESCRIPTIONS = {
name: meta["description"] for name, meta in TOOL_REGISTRY.items()
}
def build_tool(spec: str) -> Tool:
"""Instantiate a tool based on configuration."""
normalized = spec.strip() or "look"
key, args = _parse_builtin_spec(normalized)
if key in TOOL_REGISTRY:
meta = TOOL_REGISTRY[key]
module_name = meta["module"]
class_name = meta["class"]
kwargs = dict(meta.get("kwargs", {}))
kwargs = _apply_builtin_args(key, args, kwargs)
if key in {"intelligent", "intelligentcommunication"}:
default_model = kwargs.get("model", DEFAULT_LLM_MODEL)
env_model = os.environ.get(LLM_MODEL_ENV, "").strip()
kwargs["model"] = env_model or default_model
try:
module = import_module(module_name)
tool_cls = getattr(module, class_name)
except AttributeError as exc: # pragma: no cover - optional dependency
raise RuntimeError(f"{class_name} is not available in tools module") from exc
tool = _instantiate_tool(tool_cls, normalized, kwargs)
model_name = kwargs.get("model") if kwargs else None
if model_name:
print(f"[Tool] Using LLM model: {model_name}")
return tool
if ":" in normalized:
module_name, class_name = normalized.split(":", 1)
if not module_name or not class_name:
raise RuntimeError("MISTLE_TOOL must be in 'module:ClassName' format")
module = import_module(module_name)
tool_cls = getattr(module, class_name)
return _instantiate_tool(tool_cls, normalized)
raise RuntimeError(f"Unknown tool spec '{spec}'.")
def _parse_builtin_spec(spec: str) -> tuple[str, Optional[str]]:
if ":" in spec:
prefix, suffix = spec.split(":", 1)
key = prefix.lower()
if key in {"explore"}:
return key, suffix.strip()
parts = spec.split(maxsplit=1)
key = parts[0].lower()
if key not in TOOL_REGISTRY:
return spec.lower(), None
args = parts[1].strip() if len(parts) > 1 else None
return key, args
def _apply_builtin_args(
key: str, args: Optional[str], kwargs: dict
) -> dict:
if not args:
return kwargs
if key in {"explore"}:
targets = _parse_targets(args)
if not targets:
raise RuntimeError("Explore targets cannot be empty")
kwargs["targets"] = tuple(targets)
return kwargs
raise RuntimeError(
f"Tool '{key}' does not accept arguments. Received extra input: {args!r}"
)
def _parse_targets(raw: str) -> list[str]:
value = raw.strip()
if not value:
return []
if "," in value:
parts = [segment.strip() for segment in value.split(",") if segment.strip()]
else:
parts = [value]
deduped: list[str] = []
seen: set[str] = set()
for part in parts:
lowered = part.lower()
if lowered in seen:
continue
seen.add(lowered)
deduped.append(part)
return deduped
def _instantiate_tool(
tool_cls: Type[Tool], tool_spec: str, kwargs: Optional[dict] = None
) -> Tool:
if not issubclass(tool_cls, Tool):
raise RuntimeError(f"{tool_spec} is not a Tool subclass")
try:
kwargs = kwargs or {}
return tool_cls(**kwargs)
except TypeError as exc:
raise RuntimeError(f"Failed to instantiate {tool_spec}: {exc}") from exc