feat: support agent selection via environment variables

This commit is contained in:
Daniel Eder 2025-09-27 08:02:50 +02:00
parent 0c88bd5a2a
commit 5f3ea6a781
5 changed files with 134 additions and 4 deletions

3
.env
View file

@ -4,4 +4,5 @@ MISTLE_USER=mistle
MISTLE_PASSWORD=sl-mudbot MISTLE_PASSWORD=sl-mudbot
MISTLE_LOGIN_PROMPT=Wie heisst Du denn ("neu" fuer neuen Spieler) ? MISTLE_LOGIN_PROMPT=Wie heisst Du denn ("neu" fuer neuen Spieler) ?
MISTLE_EXIT_COMMAND=schlaf ein MISTLE_EXIT_COMMAND=schlaf ein
MISTLE_AGENT_MODE=true MISTLE_AGENT_MODE=true
MISTLE_AGENT=simple

5
.gitignore vendored
View file

@ -8,4 +8,7 @@ wheels/
# Virtual environments # Virtual environments
.venv .venv
.DS_STORE .DS_STORE
# config
.env

View file

@ -0,0 +1,71 @@
# Mistle Mudbot
Python-based Telnet helper for connecting to MUD servers, handling login flows, and optionally running an automated "agent" 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 agent mode that runs on a background thread and can dispatch automated commands without blocking manual input.
- Pluggable agent selection via environment variables (built-in `SimpleAgent`, extendable with custom classes).
## Requirements
- Python 3.10+
- A reachable Telnet-based MUD server
## Quick Start
1. Create a virtual environment and install dependencies:
```bash
pip install -e .
```
2. Copy `.env.example` (if available) or create a `.env` file in the project root, then fill in the connection details described below.
3. Run the client:
```bash
python app.py
```
4. Type commands directly into the console. Press `Ctrl-C` to exit; the client will send any configured shutdown command to the MUD.
## Environment Variables
All variables can be placed in the `.env` file (one `KEY=value` per line) or provided through the shell environment.
| Variable | Required | Description |
| --- | --- | --- |
| `MISTLE_HOST` | ✅ | DNS name or IP of the target MUD server. |
| `MISTLE_PORT` | ✅ | Telnet port number (will be cast to `int`). |
| `MISTLE_USER` | ❌ | Username or character name; sent automatically after login banner, if provided. |
| `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 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` (requires `ExploreAgent` inside `agent.py`), or custom spec `module:ClassName`. |
## Agent 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` if the class is named `ExploreAgent`).
- Placing the class elsewhere and configuring `MISTLE_AGENT` to `your_module:YourAgent`.
- `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.
## 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.
## Contributing
Feel free to open issues or submit pull requests for additional MUD-specific helpers, new agents, or quality-of-life improvements.
---
Happy MUDding!

View file

@ -30,3 +30,18 @@ class SimpleAgent(Agent):
def decide(self) -> Optional[str]: def decide(self) -> Optional[str]:
return self.default_command return self.default_command
@dataclass
class ExploreAgent(Agent):
"""Very small stateful agent that decides which command to run next."""
default_command: str = "untersuche boden"
last_output: str = field(default="", init=False)
def observe(self, output: str) -> None:
if output:
self.last_output = output
def decide(self) -> Optional[str]:
return self.default_command

44
app.py
View file

@ -2,9 +2,10 @@ import os
import select import select
import sys import sys
import time import time
from importlib import import_module
from pathlib import Path from pathlib import Path
from threading import Event, Lock, Thread from threading import Event, Lock, Thread
from typing import Optional from typing import Optional, Type
from agent import Agent, SimpleAgent from agent import Agent, SimpleAgent
from telnetclient import TelnetClient from telnetclient import TelnetClient
@ -129,6 +130,44 @@ def require_env(key: str) -> str:
return value return value
def build_agent(agent_spec: str) -> Agent:
"""Instantiate an agent based on ``MISTLE_AGENT`` contents."""
normalized = agent_spec.strip()
if not normalized:
return SimpleAgent()
key = normalized.lower()
if key == "simple":
return SimpleAgent()
if key == "explore":
try:
module = import_module("agent")
agent_cls = getattr(module, "ExploreAgent")
except AttributeError as exc: # pragma: no cover - optional dependency
raise RuntimeError("ExploreAgent is not available in agent module") from exc
return _instantiate_agent(agent_cls, normalized)
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")
module = import_module(module_name)
agent_cls = getattr(module, class_name)
return _instantiate_agent(agent_cls, normalized)
raise RuntimeError(f"Unknown agent spec '{agent_spec}'.")
def _instantiate_agent(agent_cls: Type[Agent], agent_spec: str) -> Agent:
if not issubclass(agent_cls, Agent):
raise RuntimeError(f"{agent_spec} is not an Agent subclass")
try:
return agent_cls()
except TypeError as exc:
raise RuntimeError(f"Failed to instantiate {agent_spec}: {exc}") from exc
def login( def login(
client: TelnetClient, client: TelnetClient,
*, *,
@ -233,6 +272,7 @@ def main() -> int:
login_prompt = os.environ.get("MISTLE_LOGIN_PROMPT", "") login_prompt = os.environ.get("MISTLE_LOGIN_PROMPT", "")
exit_command = os.environ.get("MISTLE_EXIT_COMMAND", "") exit_command = os.environ.get("MISTLE_EXIT_COMMAND", "")
agent_mode = os.environ.get("MISTLE_AGENT_MODE", "").lower() in {"1", "true", "yes", "on"} agent_mode = os.environ.get("MISTLE_AGENT_MODE", "").lower() in {"1", "true", "yes", "on"}
agent_spec = os.environ.get("MISTLE_AGENT", "")
state = SessionState() state = SessionState()
stop_event = Event() stop_event = Event()
@ -249,7 +289,7 @@ def main() -> int:
) )
if agent_mode: if agent_mode:
agent = SimpleAgent() agent = build_agent(agent_spec)
agent_thread = Thread( agent_thread = Thread(
target=run_agent_loop, target=run_agent_loop,
args=(client, state, agent, stop_event), args=(client, state, agent, stop_event),