feat: new agent to move around

This commit is contained in:
Daniel Eder 2025-09-28 10:15:33 +02:00
parent bcc453ba37
commit 9b8584561e
4 changed files with 230 additions and 17 deletions

View file

@ -8,7 +8,7 @@ Python-based Telnet helper for connecting to MUD servers, handling login flows,
- Loads credentials and connection settings from a local `.env` file. - Loads credentials and connection settings from a local `.env` file.
- Interactive console session that mirrors server output and lets you type commands directly. - Interactive console session that mirrors server output and lets you type commands directly.
- Optional always-on agent mode plus an on-demand `#execute <agent>` escape hatch for ad-hoc automations. - Optional always-on agent mode plus an on-demand `#execute <agent>` escape hatch for ad-hoc automations.
- Built-in agents (`SimpleAgent`, `ExploreAgent`, `CommunicationAgent`, `IntelligentCommunicationAgent`) with a pluggable interface for custom behaviours. - Built-in agents (`SimpleAgent`, `ExploreAgent`, `CommunicationAgent`, `MovementAgent`, `IntelligentCommunicationAgent`) with a pluggable interface for custom behaviours.
## Requirements ## Requirements
@ -54,7 +54,7 @@ All variables can be placed in the `.env` file (one `KEY=value` per line) or pro
| `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_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_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_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`, `intelligent`/`intelligentcommunication` (LLM-backed), or custom spec `module:ClassName`. | | `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`). | | `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. | | `MISTRAL_API_KEY` | ❌ | API key used by `IntelligentCommunicationAgent` (via `litellm`) when calling the `mistral/mistral-small-2407` model. |
@ -67,12 +67,13 @@ All variables can be placed in the `.env` file (one `KEY=value` per line) or pro
- `observe(output)` receives the latest server text; `decide()` returns the next command string or `None` to stay idle. - `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. - 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`. - `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. - `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.
## On-Demand Agents ## On-Demand Agents
- When `MISTLE_AGENT_MODE` is **off**, you can trigger an ephemeral agent at any time with `#execute <agent_spec>`. - When `MISTLE_AGENT_MODE` is **off**, you can trigger an ephemeral agent at any time with `#execute <agent_spec>`.
- The syntax accepts the same values as `MISTLE_AGENT` and reuses the `build_agent` helper, so `#execute simple`, `#execute explore`, `#execute intelligent`, or `#execute mypackage.mymodule:CustomAgent` are all valid. - 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.
- On-demand runs share the current session, respect the one-command-per-second limit, and stop automatically after a few seconds of inactivity. - 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 ## Danger Zone

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import re import re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import deque from collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Deque, Optional, Pattern, Set, Tuple from typing import Deque, Optional, Pattern, Set, Tuple

40
app.py
View file

@ -81,7 +81,27 @@ def run_agent_loop(
"""Invoke *agent* whenever new output arrives and send its response.""" """Invoke *agent* whenever new output arrives and send its response."""
idle_started: Optional[float] = None idle_started: Optional[float] = None
def maybe_send() -> None:
nonlocal idle_started
try:
command = agent.decide()
except Exception as exc: # pragma: no cover - defensive logging
print(f"Agent failed: {exc}", file=sys.stderr)
return
if not command:
return
sent = state.agent_send(
client,
command,
min_interval=min_send_interval,
stop_event=stop_event,
)
if not sent:
return
idle_started = None
while not stop_event.is_set(): while not stop_event.is_set():
maybe_send()
triggered = state.wait_for_output(timeout=idle_delay) triggered = state.wait_for_output(timeout=idle_delay)
if stop_event.is_set(): if stop_event.is_set():
break break
@ -101,20 +121,10 @@ def run_agent_loop(
continue continue
try: try:
agent.observe(last_output) agent.observe(last_output)
command = agent.decide()
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
print(f"Agent failed: {exc}", file=sys.stderr) print(f"Agent failed during observe: {exc}", file=sys.stderr)
continue continue
if not command: maybe_send()
continue
sent = state.agent_send(
client,
command,
min_interval=min_send_interval,
stop_event=stop_event,
)
if not sent:
break
def load_env_file(path: str = ".env") -> None: def load_env_file(path: str = ".env") -> None:
@ -155,15 +165,17 @@ def build_agent(agent_spec: str) -> Agent:
builtin_agents = { builtin_agents = {
"explore": ("agent", "ExploreAgent", {}), "explore": ("agent", "ExploreAgent", {}),
"communication": ("agent", "CommunicationAgent", {}), "communication": ("agent", "CommunicationAgent", {}),
"movement": ("movement_agent", "MovementAgent", {}),
"move": ("movement_agent", "MovementAgent", {}),
"intelligent": ( "intelligent": (
"intelligent_agent", "intelligent_agent",
"IntelligentCommunicationAgent", "IntelligentCommunicationAgent",
{"model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-tiny")}, {"model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-small-2407")},
), ),
"intelligentcommunication": ( "intelligentcommunication": (
"intelligent_agent", "intelligent_agent",
"IntelligentCommunicationAgent", "IntelligentCommunicationAgent",
{"model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-tiny")}, {"model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-small-2407")},
), ),
} }

199
movement_agent.py Normal file
View file

@ -0,0 +1,199 @@
from __future__ import annotations
import re
import random
from dataclasses import dataclass, field
from typing import Dict, Optional, Pattern, Sequence, Set, Tuple
from agent import Agent
@dataclass
class MovementAgent(Agent):
"""Agent that chooses movement commands based on room descriptions."""
look_command: str = "schau"
command_format: str = "{direction}"
preferred_order: Tuple[str, ...] = (
"n",
"ne",
"e",
"se",
"s",
"sw",
"w",
"nw",
"u",
"d",
)
last_output: str = field(default="", init=False)
current_room_key: Optional[str] = field(default=None, init=False)
visited_directions: Dict[str, Set[str]] = field(default_factory=dict, init=False)
executed: bool = field(default=False, init=False)
needs_look: bool = field(default=True, init=False)
selected_direction: Optional[str] = field(default=None, init=False)
random_choice: bool = True
_direction_aliases: Dict[str, str] = field(
default_factory=lambda: {
"n": "n",
"north": "n",
"norden": "n",
"nord": "n",
"no": "ne",
"ne": "ne",
"nordost": "ne",
"nordosten": "ne",
"e": "e",
"east": "e",
"osten": "e",
"ost": "e",
"so": "se",
"se": "se",
"südost": "se",
"südosten": "se",
"s": "s",
"south": "s",
"süden": "s",
"süd": "s",
"sw": "sw",
"südwest": "sw",
"südwesten": "sw",
"w": "w",
"west": "w",
"westen": "w",
"nw": "nw",
"nordwest": "nw",
"nordwesten": "nw",
"u": "u",
"oben": "u",
"up": "u",
"rauf": "u",
"d": "d",
"down": "d",
"hinunter": "d",
"unten": "d",
},
init=False,
)
_exit_line_pattern: Pattern[str] = field(
default_factory=lambda: re.compile(
r"(?:ausg(?:ä|a)nge?|exits?)[:\s]+(.+)", re.IGNORECASE
),
init=False,
)
def observe(self, output: str) -> None:
if not output or self.executed:
return
self.last_output = output
if self.needs_look:
return
room_key = self._room_key(output)
if self.selected_direction is None:
direction = self._select_direction(output, room_key)
if direction:
self.current_room_key = room_key
self.selected_direction = direction
def decide(self) -> Optional[str]:
if self.executed:
return None
if self.needs_look:
self.needs_look = False
print("[Agent] Requesting room description via look command")
return self.look_command
if self.selected_direction is None:
return None
direction = self.selected_direction
if self.current_room_key is not None:
self.visited_directions.setdefault(self.current_room_key, set()).add(
direction
)
command = self.command_format.format(direction=direction)
print(f"[Agent] Moving via {direction}")
self.executed = True
self.selected_direction = None
return command
def _room_key(self, text: str) -> str:
return re.sub(r"\s+", " ", text.strip())
def _select_direction(self, text: str, room_key: str) -> Optional[str]:
exits = self._parse_exit_lines(text)
if not exits:
exits = self._scan_for_directions(text)
if not exits:
return None
ordered = self._prioritized_exits(exits, room_key)
if not ordered:
return None
visited = self.visited_directions.setdefault(room_key, set())
candidates = [direction for direction in ordered if direction not in visited]
pool = candidates or ordered
return self._choose_direction(pool)
def _prioritized_exits(
self, exits: Sequence[Tuple[str, Optional[str]]], room_key: str
) -> list[str]:
ordered: list[str] = []
seen: Set[str] = set()
for pref in self.preferred_order:
for raw, canonical in exits:
if canonical == pref and raw not in seen:
ordered.append(raw)
seen.add(raw)
for raw, _ in exits:
if raw not in seen:
ordered.append(raw)
seen.add(raw)
return ordered
def _choose_direction(self, pool: Sequence[str]) -> str:
if not pool:
raise ValueError("No exits available to choose from")
if self.random_choice and len(pool) > 1:
return random.choice(list(pool))
return pool[0]
def _parse_exit_lines(self, text: str) -> list[Tuple[str, Optional[str]]]:
exits: list[Tuple[str, Optional[str]]] = []
for line in text.splitlines():
match = self._exit_line_pattern.search(line)
if not match:
continue
candidates = re.split(r"[,;/]| und | oder |\\s", match.group(1))
for candidate in candidates:
normalized = self._normalize_raw(candidate)
if not normalized:
continue
exits.append(normalized)
return exits
def _scan_for_directions(self, text: str) -> list[Tuple[str, Optional[str]]]:
exits: list[Tuple[str, Optional[str]]] = []
lowered = text.lower()
for word, direction in self._direction_aliases.items():
if re.search(rf"\b{re.escape(word)}\b", lowered):
normalized = self._normalize_raw(word)
if normalized:
exits.append((normalized[0], direction))
return exits
def _map_direction(self, raw: str) -> Optional[str]:
cleaned = re.sub(r"[^a-zäöüß]", "", raw.lower())
if not cleaned:
return None
return self._direction_aliases.get(cleaned)
def _normalize_raw(self, raw: str) -> Optional[Tuple[str, Optional[str]]]:
cleaned = re.sub(r"[\s\.,;:!?-]+$", "", raw.strip())
if not cleaned:
return None
return cleaned, self._map_direction(cleaned)