From ca493b855123502932f9fbbcc126626267a04ba8 Mon Sep 17 00:00:00 2001 From: Daniel Eder Date: Sun, 28 Sep 2025 10:31:42 +0200 Subject: [PATCH] refactor: renamed agents to tools --- README.md | 46 +++--- app.py | 152 ++++++++++---------- intelligent_agent.py => intelligent_tool.py | 14 +- movement_agent.py => movement_tool.py | 10 +- agent.py => tools.py | 25 ++-- 5 files changed, 126 insertions(+), 121 deletions(-) rename intelligent_agent.py => intelligent_tool.py (90%) rename movement_agent.py => movement_tool.py (96%) rename agent.py => tools.py (83%) diff --git a/README.md b/README.md index 770a27b..ef7d0fa 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # Mistle Mudbot -Python-based Telnet helper for connecting to MUD servers, handling login flows, and optionally running automated "agents" alongside interactive play. +Python-based Telnet helper for connecting to MUD servers, handling login flows, and optionally running automated "tools" alongside interactive play. ## Features - Lightweight wrapper (`TelnetClient`) around `telnetlib` with sane defaults and context-manager support. - Loads credentials and connection settings from a local `.env` file. - Interactive console session that mirrors server output and lets you type commands directly. -- Optional always-on agent mode plus an on-demand `#execute ` escape hatch for ad-hoc automations. -- Built-in agents (`SimpleAgent`, `ExploreAgent`, `CommunicationAgent`, `MovementAgent`, `IntelligentCommunicationAgent`) with a pluggable interface for custom behaviours. +- Optional always-on tool mode plus an on-demand `#execute ` escape hatch for ad-hoc automations. +- Built-in tools (`SimpleTool`, `ExploreTool`, `CommunicationTool`, `MovementTool`, `IntelligentCommunicationTool`) with a pluggable interface for custom behaviours. ## Requirements @@ -33,13 +33,13 @@ Python-based Telnet helper for connecting to MUD servers, handling login flows, 4. Type commands directly into the console. Press `Ctrl-C` to exit; the client will send any configured shutdown command to the MUD. -5. When *not* running in agent mode you can kick off one-off automations from the prompt: +5. When *not* running in tool mode you can kick off one-off automations from the prompt: ```text #execute explore ``` - The command remains interactive while the agent works in the background and stops automatically a few seconds after things quiet down. + The command remains interactive while the tool works in the background and stops automatically a few seconds after things quiet down. ## Environment Variables @@ -53,37 +53,37 @@ All variables can be placed in the `.env` file (one `KEY=value` per line) or pro | `MISTLE_PASSWORD` | ❌ | Password sent after the username. Leave blank for manual entry. | | `MISTLE_LOGIN_PROMPT` | ❌ | Prompt string that signals the client to send credentials (e.g., `"Name:"`). When omitted, the client just waits for the initial banner. | | `MISTLE_EXIT_COMMAND` | ❌ | Command issued during graceful shutdown (after pressing `Ctrl-C`). Useful for `quit`/`save` macros. | -| `MISTLE_AGENT_MODE` | ❌ | Enable full-time agent thread when set to truthy values (`1`, `true`, `yes`, `on`). Defaults to interactive-only mode. | -| `MISTLE_AGENT` | ❌ | Select which agent class to instantiate when agent mode is active. Accepted values: `simple` (default), `explore`, `communication`, `movement`, `intelligent`/`intelligentcommunication` (LLM-backed), or custom spec `module:ClassName`. | -| `MISTLE_LLM_MODEL` | ❌ | Override the `litellm` model used by the intelligent agent (defaults to `mistral/mistral-small-2407`). | -| `MISTRAL_API_KEY` | ❌ | API key used by `IntelligentCommunicationAgent` (via `litellm`) when calling the `mistral/mistral-small-2407` model. | +| `MISTLE_TOOL_MODE` | ❌ | Enable full-time tool thread when set to truthy values (`1`, `true`, `yes`, `on`). Defaults to interactive-only mode. (`MISTLE_AGENT_MODE` still works as a backwards-compatible alias.) | +| `MISTLE_TOOL` | ❌ | Select which tool class to instantiate when tool mode is active. Accepted values: `simple` (default), `explore`, `communication`, `movement`, `intelligent`/`intelligentcommunication` (LLM-backed), or custom spec `module:ClassName`. (`MISTLE_AGENT` remains as a legacy alias.) | +| `MISTLE_LLM_MODEL` | ❌ | Override the `litellm` model used by the intelligent tool (defaults to `mistral/mistral-small-2407`). | +| `MISTRAL_API_KEY` | ❌ | API key used by `IntelligentCommunicationTool` (via `litellm`) when calling the `mistral/mistral-small-2407` model. | -## Agent Development +## Tool Development -- Implement new agents by subclassing `agent.Agent` and overriding `observe()` and `decide()`. -- Register the agent by either: - - Adding the class to `agent.py` and referencing it in `MISTLE_AGENT` (e.g., `explore` for `ExploreAgent`). - - Placing the class elsewhere and configuring `MISTLE_AGENT` to `your_module:YourAgent`. +- Implement new tools by subclassing `tools.Tool` and overriding `observe()` and `decide()`. +- Register the tool by either: + - Adding the class to `tools.py` and referencing it in `MISTLE_TOOL` (e.g., `explore` for `ExploreTool`). + - Placing the class elsewhere and configuring `MISTLE_TOOL` to `your_module:YourTool`. - `observe(output)` receives the latest server text; `decide()` returns the next command string or `None` to stay idle. -- Commands issued by the agent are throttled to one per second so manual commands can still interleave smoothly. -- `ExploreAgent` showcases a richer workflow: it sends `schau`, identifies German nouns, inspects each with `untersuche`, and prints `[Agent]` progress updates like `Explored 3/7 — untersuche Tisch`. -- `MovementAgent` parses room descriptions/exits and issues direction commands (prefers `n`, `e`, `s`, `w`, diagonals, then vertical moves) while tracking which exits were already tried. Use `#execute move` for ad-hoc pathing or set `MISTLE_AGENT=movement` for continuous roaming. -- `CommunicationAgent` auto-replies to every direct tell with a canned greeting, while `IntelligentCommunicationAgent` routes each tell through `litellm` (default model `mistral/mistral-small-2407`) to craft a contextual answer. +- Commands issued by the tool are throttled to one per second so manual commands can still interleave smoothly. +- `ExploreTool` showcases a richer workflow: it sends `schau`, identifies German nouns, inspects each with `untersuche`, and prints `[Tool]` progress updates like `Explored 3/7 — untersuche Tisch`. +- `MovementTool` parses room descriptions/exits and issues a single direction command, preferring unvisited exits and randomising choices to avoid oscillation. Trigger it via `#execute move` (or set `MISTLE_TOOL=movement` for continuous roaming). +- `CommunicationTool` auto-replies to every direct tell with a canned greeting, while `IntelligentCommunicationTool` routes each tell through `litellm` (default model `mistral/mistral-small-2407`) to craft a contextual answer via the configured LLM. -## On-Demand Agents +## On-Demand Tools -- When `MISTLE_AGENT_MODE` is **off**, you can trigger an ephemeral agent at any time with `#execute `. -- The syntax accepts the same values as `MISTLE_AGENT` and reuses the `build_agent` helper, so `#execute simple`, `#execute explore`, `#execute move`, `#execute intelligent`, or `#execute mypackage.mymodule:CustomAgent` are all valid. +- When `MISTLE_TOOL_MODE` is **off**, you can trigger an ephemeral tool at any time with `#execute `. +- The syntax accepts the same values as `MISTLE_TOOL` and reuses the `build_tool` helper, so `#execute simple`, `#execute explore`, `#execute move`, `#execute intelligent`, or `#execute mypackage.mymodule:CustomTool` are all valid. - On-demand runs share the current session, respect the one-command-per-second limit, and stop automatically after a few seconds of inactivity. ## Danger Zone - The Telnet session runs until you interrupt it. Make sure the terminal is in a state where `Ctrl-C` is available. -- When adding new agents, guard any long-running logic to avoid blocking the agent thread. +- When adding new tools, guard any long-running logic to avoid blocking the tool thread. ## Contributing -Feel free to open issues or submit pull requests for additional MUD-specific helpers, new agents, or quality-of-life improvements. +Feel free to open issues or submit pull requests for additional MUD-specific helpers, new tools, or quality-of-life improvements. --- diff --git a/app.py b/app.py index 28fd4ae..a6412a0 100644 --- a/app.py +++ b/app.py @@ -7,7 +7,7 @@ from pathlib import Path from threading import Event, Lock, Thread from typing import Callable, Optional, Type -from agent import Agent, SimpleAgent +from tools import Tool, SimpleTool from telnetclient import TelnetClient @@ -19,13 +19,13 @@ class SessionState: self._output_lock = Lock() self._output_event = Event() self._last_output = "" - self._last_agent_send = 0.0 + self._last_tool_send = 0.0 def send(self, client: TelnetClient, message: str) -> None: with self._send_lock: client.send(message) - def agent_send( + def tool_send( self, client: TelnetClient, message: str, @@ -33,14 +33,14 @@ class SessionState: min_interval: float, stop_event: Event, ) -> bool: - """Send on behalf of the agent while respecting a minimum cadence.""" + """Send on behalf of the tool while respecting a minimum cadence.""" while not stop_event.is_set(): with self._send_lock: now = time.time() - elapsed = now - self._last_agent_send + elapsed = now - self._last_tool_send if elapsed >= min_interval: client.send(message) - self._last_agent_send = now + self._last_tool_send = now return True wait_time = min_interval - elapsed if wait_time <= 0: @@ -67,10 +67,10 @@ class SessionState: self._output_event.clear() -def run_agent_loop( +def run_tool_loop( client: TelnetClient, state: SessionState, - agent: Agent, + tool: Tool, stop_event: Event, *, idle_delay: float = 0.5, @@ -78,19 +78,19 @@ def run_agent_loop( auto_stop: bool = False, auto_stop_idle: float = 2.0, ) -> None: - """Invoke *agent* whenever new output arrives and send its response.""" + """Invoke *tool* whenever new output arrives and send its response.""" idle_started: Optional[float] = None def maybe_send() -> None: nonlocal idle_started try: - command = agent.decide() + command = tool.decide() except Exception as exc: # pragma: no cover - defensive logging - print(f"Agent failed: {exc}", file=sys.stderr) + print(f"[Tool] Failed: {exc}", file=sys.stderr) return if not command: return - sent = state.agent_send( + sent = state.tool_send( client, command, min_interval=min_send_interval, @@ -120,9 +120,9 @@ def run_agent_loop( if not last_output: continue try: - agent.observe(last_output) + tool.observe(last_output) except Exception as exc: # pragma: no cover - defensive logging - print(f"Agent failed during observe: {exc}", file=sys.stderr) + print(f"[Tool] Failed during observe: {exc}", file=sys.stderr) continue maybe_send() @@ -152,67 +152,67 @@ def require_env(key: str) -> str: return value -def build_agent(agent_spec: str) -> Agent: - """Instantiate an agent based on ``MISTLE_AGENT`` contents.""" - normalized = agent_spec.strip() +def build_tool(spec: str) -> Tool: + """Instantiate a tool based on configuration.""" + normalized = spec.strip() if not normalized: - return SimpleAgent() + return SimpleTool() key = normalized.lower() if key == "simple": - return SimpleAgent() + return SimpleTool() - builtin_agents = { - "explore": ("agent", "ExploreAgent", {}), - "communication": ("agent", "CommunicationAgent", {}), - "movement": ("movement_agent", "MovementAgent", {}), - "move": ("movement_agent", "MovementAgent", {}), + builtin_tools = { + "explore": ("tools", "ExploreTool", {}), + "communication": ("tools", "CommunicationTool", {}), + "movement": ("movement_tool", "MovementTool", {}), + "move": ("movement_tool", "MovementTool", {}), "intelligent": ( - "intelligent_agent", - "IntelligentCommunicationAgent", + "intelligent_tool", + "IntelligentCommunicationTool", {"model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-small-2407")}, ), "intelligentcommunication": ( - "intelligent_agent", - "IntelligentCommunicationAgent", + "intelligent_tool", + "IntelligentCommunicationTool", {"model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-small-2407")}, ), } - if key in builtin_agents: - module_name, class_name, kwargs = builtin_agents[key] + if key in builtin_tools: + module_name, class_name, kwargs = builtin_tools[key] try: module = import_module(module_name) - agent_cls = getattr(module, class_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 agent module") from exc - agent = _instantiate_agent(agent_cls, normalized, kwargs) + 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"[Agent] Using LLM model: {model_name}") - return agent + 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_AGENT must be in 'module:ClassName' format") + raise RuntimeError("MISTLE_TOOL must be in 'module:ClassName' format") module = import_module(module_name) - agent_cls = getattr(module, class_name) - return _instantiate_agent(agent_cls, normalized) + tool_cls = getattr(module, class_name) + return _instantiate_tool(tool_cls, normalized) - raise RuntimeError(f"Unknown agent spec '{agent_spec}'.") + raise RuntimeError(f"Unknown tool spec '{spec}'.") -def _instantiate_agent( - agent_cls: Type[Agent], agent_spec: str, kwargs: Optional[dict] = None -) -> Agent: - if not issubclass(agent_cls, Agent): - raise RuntimeError(f"{agent_spec} is not an Agent subclass") +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 agent_cls(**kwargs) + return tool_cls(**kwargs) except TypeError as exc: - raise RuntimeError(f"Failed to instantiate {agent_spec}: {exc}") from exc + raise RuntimeError(f"Failed to instantiate {tool_spec}: {exc}") from exc def login( @@ -256,7 +256,7 @@ def interactive_session( poll_interval: float = 0.2, receive_timeout: float = 0.2, exit_command: str, - agent_command: Optional[Callable[[str], None]] = None, + tool_command: Optional[Callable[[str], None]] = None, ) -> None: """Keep the Telnet session running, proxying input/output until interrupted.""" if exit_command: @@ -279,12 +279,12 @@ def interactive_session( line = line.rstrip("\r\n") if not line: continue - if agent_command and line.lower().startswith("#execute"): + if tool_command and line.lower().startswith("#execute"): parts = line.split(maxsplit=1) if len(parts) == 1: - print("[Agent] Usage: #execute ") + print("[Tool] Usage: #execute ") else: - agent_command(parts[1]) + tool_command(parts[1]) continue state.send(client, line) @@ -326,14 +326,20 @@ def main() -> int: password = os.environ.get("MISTLE_PASSWORD", "") login_prompt = os.environ.get("MISTLE_LOGIN_PROMPT", "") exit_command = os.environ.get("MISTLE_EXIT_COMMAND", "") - agent_mode = os.environ.get("MISTLE_AGENT_MODE", "").lower() in {"1", "true", "yes", "on"} - agent_spec = os.environ.get("MISTLE_AGENT", "") + tool_mode_env = os.environ.get("MISTLE_TOOL_MODE") + if tool_mode_env is None: + tool_mode_env = os.environ.get("MISTLE_AGENT_MODE", "") + tool_mode = tool_mode_env.lower() in {"1", "true", "yes", "on"} + + tool_spec = os.environ.get("MISTLE_TOOL") + if tool_spec is None: + tool_spec = os.environ.get("MISTLE_AGENT", "") state = SessionState() stop_event = Event() - agent_thread: Optional[Thread] = None - agent: Optional[Agent] = None - ephemeral_agents: list[Thread] = [] + tool_thread: Optional[Thread] = None + tool: Optional[Tool] = None + ephemeral_tools: list[Thread] = [] with TelnetClient(host=host, port=port, timeout=10.0) as client: login( @@ -344,29 +350,29 @@ def main() -> int: state=state, ) - if agent_mode: - agent = build_agent(agent_spec) - agent_thread = Thread( - target=run_agent_loop, - args=(client, state, agent, stop_event), + if tool_mode: + tool = build_tool(tool_spec) + tool_thread = Thread( + target=run_tool_loop, + args=(client, state, tool, stop_event), kwargs={"min_send_interval": 1.0}, daemon=True, ) - agent_thread.start() + tool_thread.start() - def run_ephemeral_agent(spec: str) -> None: + def run_ephemeral_tool(spec: str) -> None: spec = spec.strip() if not spec: - print("[Agent] Usage: #execute ") + print("[Tool] Usage: #execute ") return try: - temp_agent = build_agent(spec) + temp_tool = build_tool(spec) except RuntimeError as exc: - print(f"[Agent] Failed to load '{spec}': {exc}", file=sys.stderr) + print(f"[Tool] Failed to load '{spec}': {exc}", file=sys.stderr) return thread = Thread( - target=run_agent_loop, - args=(client, state, temp_agent, stop_event), + target=run_tool_loop, + args=(client, state, temp_tool, stop_event), kwargs={ "min_send_interval": 1.0, "auto_stop": True, @@ -374,8 +380,8 @@ def main() -> int: }, daemon=True, ) - ephemeral_agents.append(thread) - print(f"[Agent] Executing {spec!r} once") + ephemeral_tools.append(thread) + print(f"[Tool] Executing {spec!r} once") thread.start() interrupted = False @@ -385,16 +391,16 @@ def main() -> int: state=state, stop_event=stop_event, exit_command=exit_command, - agent_command=None if agent_mode else run_ephemeral_agent, + tool_command=None if tool_mode else run_ephemeral_tool, ) except KeyboardInterrupt: print() interrupted = True finally: stop_event.set() - if agent_thread: - agent_thread.join(timeout=1.0) - for thread in ephemeral_agents: + if tool_thread: + tool_thread.join(timeout=1.0) + for thread in ephemeral_tools: thread.join(timeout=1.0) if interrupted: diff --git a/intelligent_agent.py b/intelligent_tool.py similarity index 90% rename from intelligent_agent.py rename to intelligent_tool.py index d284d8e..2a9c78b 100644 --- a/intelligent_agent.py +++ b/intelligent_tool.py @@ -11,12 +11,12 @@ try: except ImportError: # pragma: no cover - optional dependency completion = None # type: ignore[assignment] -from agent import Agent +from tools import Tool @dataclass -class IntelligentCommunicationAgent(Agent): - """Agent that uses a language model to answer private tells.""" +class IntelligentCommunicationTool(Tool): + """Tool that uses a language model to answer private tells.""" model: str = "mistral/mistral-tiny" system_prompt: str = ( @@ -47,7 +47,7 @@ class IntelligentCommunicationAgent(Agent): if not player: continue self.pending_replies.append((player, message)) - print(f"[Agent] Received message from {player}: {message}") + print(f"[Tool] Received message from {player}: {message}") self._append_history(player, "user", message) def decide(self) -> Optional[str]: @@ -57,13 +57,13 @@ class IntelligentCommunicationAgent(Agent): reply_text = self._sanitize_reply(self._generate_reply(player)) self._append_history(player, "assistant", reply_text) reply = f"teile {player} mit {reply_text}" - print(f"[Agent] Replying to {player} with model output") + print(f"[Tool] Replying to {player} with model output") return reply def _generate_reply(self, player: str) -> str: if completion is None: print( - "[Agent] litellm is not installed; falling back to default reply", + "[Tool] litellm is not installed; falling back to default reply", file=sys.stderr, ) return self.fallback_reply @@ -76,7 +76,7 @@ class IntelligentCommunicationAgent(Agent): max_tokens=self.max_output_tokens, ) except Exception as exc: # pragma: no cover - network/runtime errors - print(f"[Agent] Model call failed: {exc}", file=sys.stderr) + print(f"[Tool] Model call failed: {exc}", file=sys.stderr) return self.fallback_reply try: diff --git a/movement_agent.py b/movement_tool.py similarity index 96% rename from movement_agent.py rename to movement_tool.py index 70cc608..2f9c600 100644 --- a/movement_agent.py +++ b/movement_tool.py @@ -5,12 +5,12 @@ import random from dataclasses import dataclass, field from typing import Dict, Optional, Pattern, Sequence, Set, Tuple -from agent import Agent +from tools import Tool @dataclass -class MovementAgent(Agent): - """Agent that chooses movement commands based on room descriptions.""" +class MovementTool(Tool): + """Tool that issues a single movement based on the room description.""" look_command: str = "schau" command_format: str = "{direction}" @@ -102,7 +102,7 @@ class MovementAgent(Agent): return None if self.needs_look: self.needs_look = False - print("[Agent] Requesting room description via look command") + print("[Tool] Requesting room description via look command") return self.look_command if self.selected_direction is None: return None @@ -112,7 +112,7 @@ class MovementAgent(Agent): direction ) command = self.command_format.format(direction=direction) - print(f"[Agent] Moving via {direction}") + print(f"[Tool] Moving via {direction}") self.executed = True self.selected_direction = None return command diff --git a/agent.py b/tools.py similarity index 83% rename from agent.py rename to tools.py index acaf2a2..c0f0b09 100644 --- a/agent.py +++ b/tools.py @@ -8,8 +8,8 @@ from dataclasses import dataclass, field from typing import Deque, Optional, Pattern, Set, Tuple -class Agent(ABC): - """Interface for autonomous Telnet actors.""" +class Tool(ABC): + """Interface for autonomous Telnet tools.""" @abstractmethod def observe(self, output: str) -> None: @@ -21,8 +21,8 @@ class Agent(ABC): @dataclass -class SimpleAgent(Agent): - """Minimal agent that always returns the same command.""" +class SimpleTool(Tool): + """Minimal tool that always returns the same command.""" default_command: str = "schau" last_output: str = field(default="", init=False) @@ -36,8 +36,8 @@ class SimpleAgent(Agent): @dataclass -class ExploreAgent(Agent): - """Agent that inspects every noun it discovers in the room description.""" +class ExploreTool(Tool): + """Tool that inspects every noun it discovers in the room description.""" look_command: str = "schau" inspect_command: str = "untersuche" @@ -68,14 +68,14 @@ class ExploreAgent(Agent): def decide(self) -> Optional[str]: if not self.look_sent: self.look_sent = True - print("[Agent] Exploring room, sending 'schau'") + print("[Tool] Exploring room, sending 'schau'") return self.look_command if self.pending_targets: target = self.pending_targets.popleft() key = target.lower() self.inspected_targets.add(key) - progress = f"[Agent] Explored {len(self.inspected_targets)}/{len(self.seen_targets)} — untersuche {target}" + progress = f"[Tool] Explored {len(self.inspected_targets)}/{len(self.seen_targets)} — untersuche {target}" print(progress) return f"{self.inspect_command} {target}" @@ -83,8 +83,8 @@ class ExploreAgent(Agent): @dataclass -class CommunicationAgent(Agent): - """Agent that replies to private tells.""" +class CommunicationTool(Tool): + """Tool that replies to private tells.""" reply_template: str = "teile {player} mit Hallo! Ich bin Mistle und ein Bot." tell_pattern: Pattern[str] = field( @@ -106,13 +106,12 @@ class CommunicationAgent(Agent): if not player: continue self.pending_replies.append((player, message)) - print(f"[Agent] Received message from {player}: {message}") + print(f"[Tool] Received message from {player}: {message}") def decide(self) -> Optional[str]: if not self.pending_replies: return None player, _ = self.pending_replies.popleft() reply = self.reply_template.format(player=player) - print(f"[Agent] Replying to {player}") + print(f"[Tool] Replying to {player}") return reply -